[Security Solution] Stateless FieldBrowser (#134495)

* remove redux from field browser

* test added

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2022-06-21 11:06:18 +02:00 committed by GitHub
parent a59c0482ca
commit 7649da18cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 441 additions and 338 deletions

View file

@ -19,7 +19,7 @@ const renderUseCreateFieldButton = (props: Partial<UseCreateFieldButtonProps> =
renderHook<UseCreateFieldButtonProps, ReturnType<UseCreateFieldButton>>(
() =>
useCreateFieldButton({
hasFieldEditPermission: true,
isAllowed: true,
loading: false,
openFieldEditor: mockOpenFieldEditor,
...props,
@ -40,7 +40,7 @@ describe('useCreateFieldButton', () => {
});
it('should return the undefined when user do not has edit permissions', async () => {
const { result } = renderUseCreateFieldButton({ hasFieldEditPermission: false });
const { result } = renderUseCreateFieldButton({ isAllowed: false });
expect(result.current).toBeUndefined();
});

View file

@ -18,7 +18,7 @@ const StyledButton = styled(EuiButton)`
`;
export interface UseCreateFieldButtonProps {
hasFieldEditPermission: boolean;
isAllowed: boolean;
loading: boolean;
openFieldEditor: OpenFieldEditor;
}
@ -30,7 +30,7 @@ export type UseCreateFieldButton = (
* Returns a memoised 'CreateFieldButton' with only an 'onClick' property.
*/
export const useCreateFieldButton: UseCreateFieldButton = ({
hasFieldEditPermission,
isAllowed,
loading,
openFieldEditor,
}) => {
@ -52,5 +52,5 @@ export const useCreateFieldButton: UseCreateFieldButton = ({
[loading, openFieldEditor]
);
return hasFieldEditPermission ? createFieldButton : undefined;
return isAllowed ? createFieldButton : undefined;
};

View file

@ -34,6 +34,22 @@ let mockIndexPatternFieldEditor: Start;
jest.mock('../../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const defaultDataviewState: {
missingPatterns: string[];
selectedDataViewId: string | null;
} = {
missingPatterns: [],
selectedDataViewId: 'security-solution',
};
const mockScopeIdSelector = jest.fn(() => defaultDataviewState);
jest.mock('../../../common/store', () => {
const original = jest.requireActual('../../../common/store');
return {
...original,
sourcererSelectors: { scopeIdSelector: () => mockScopeIdSelector },
};
});
const mockIndexFieldsSearch = jest.fn();
jest.mock('../../../common/containers/source/use_data_view', () => ({
useDataView: () => ({
@ -100,6 +116,7 @@ describe('useFieldBrowserOptions', () => {
...useKibanaMock().services.application.capabilities,
indexPatterns: { save: true },
};
mockScopeIdSelector.mockReturnValue(defaultDataviewState);
jest.clearAllMocks();
});
@ -123,6 +140,22 @@ describe('useFieldBrowserOptions', () => {
);
});
it('should return the button when a dataView is present', async () => {
const { result } = renderUseFieldBrowserOptions();
expect(result.current.createFieldButton).toBeDefined();
expect(result.current.getFieldTableColumns({ highlight: '', onHide: mockOnHide })).toHaveLength(
5
);
});
it('should not return the button when a dataView is not present', () => {
mockScopeIdSelector.mockReturnValue({ missingPatterns: [], selectedDataViewId: null });
const { result } = renderUseFieldBrowserOptions();
expect(result.current.createFieldButton).toBeUndefined();
});
it('should call onHide when button is pressed', async () => {
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
const { result } = await renderUpdatedUseFieldBrowserOptions();

View file

@ -155,7 +155,7 @@ export const useFieldBrowserOptions: UseFieldBrowserOptions = ({
);
const createFieldButton = useCreateFieldButton({
hasFieldEditPermission,
isAllowed: hasFieldEditPermission && !!selectedDataViewId,
loading: !dataView,
openFieldEditor,
});

View file

@ -0,0 +1,141 @@
/*
* 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 { TestProviders, mockTimelineModel } from '../../../../../common/mock';
import { HeaderActions } from './header_actions';
import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin';
import {
ColumnHeaderOptions,
HeaderActionProps,
TimelineTabs,
} from '../../../../../../common/types/timeline';
import { timelineActions } from '../../../../store/timeline';
import { getColumnHeader } from '../column_headers/helpers';
jest.mock('../../../row_renderers_browser', () => ({
StatefulRowRenderersBrowser: () => null,
}));
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
jest.mock('../../../../../common/hooks/use_selector', () => ({
useDeepEqualSelector: () => mockTimelineModel,
useShallowEqualSelector: jest.fn(),
}));
const fieldId = 'test-field';
const timelineId = 'test-timeline';
/* eslint-disable jsx-a11y/click-events-have-key-events */
mockTimelines.getFieldBrowser.mockImplementation(
({
onToggleColumn,
onResetColumns,
}: {
onToggleColumn: (field: string) => void;
onResetColumns: () => void;
}) => (
<div data-test-subj="mock-field-browser">
<div data-test-subj="mock-toggle-button" onClick={() => onToggleColumn(fieldId)} />
<div data-test-subj="mock-reset-button" onClick={onResetColumns} />
</div>
)
);
jest.mock('../../../../../common/lib/kibana', () => ({
useKibana: () => ({
services: {
timelines: { ...mockTimelines },
},
}),
}));
const defaultProps: HeaderActionProps = {
browserFields: {},
columnHeaders: [],
isSelectAllChecked: false,
onSelectAll: jest.fn(),
showEventsSelect: false,
showSelectAllCheckbox: false,
sort: [],
tabType: TimelineTabs.query,
timelineId,
width: 10,
};
describe('HeaderActions', () => {
describe('FieldBrowser', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the field browser', () => {
const result = render(
<TestProviders>
<HeaderActions {...defaultProps} />
</TestProviders>
);
expect(result.getByTestId('mock-field-browser')).toBeInTheDocument();
});
it('should dispatch upsertColumn when non existing column is toggled', () => {
const result = render(
<TestProviders>
<HeaderActions {...defaultProps} />
</TestProviders>
);
result.getByTestId('mock-toggle-button').click();
expect(mockDispatch).toHaveBeenCalledWith(
timelineActions.upsertColumn({
column: getColumnHeader(fieldId, []),
id: timelineId,
index: 1,
})
);
});
it('should dispatch removeColumn when existing column is toggled', () => {
const result = render(
<TestProviders>
<HeaderActions
{...defaultProps}
columnHeaders={[{ id: fieldId } as unknown as ColumnHeaderOptions]}
/>
</TestProviders>
);
result.getByTestId('mock-toggle-button').click();
expect(mockDispatch).toHaveBeenCalledWith(
timelineActions.removeColumn({
columnId: fieldId,
id: timelineId,
})
);
});
it('should dispatch updateColumns when columns are reset', () => {
const result = render(
<TestProviders>
<HeaderActions {...defaultProps} />
</TestProviders>
);
result.getByTestId('mock-reset-button').click();
expect(mockDispatch).toHaveBeenCalledWith(
timelineActions.updateColumns({ id: timelineId, columns: mockTimelineModel.defaultColumns })
);
});
});
});

View file

@ -29,13 +29,15 @@ import {
useGlobalFullScreen,
useTimelineFullScreen,
} from '../../../../../common/containers/use_full_screen';
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
import { StatefulRowRenderersBrowser } from '../../../row_renderers_browser';
import { EventsTh, EventsThContent } from '../../styles';
import { EventsSelect } from '../column_headers/events_select';
import * as i18n from '../column_headers/translations';
import { timelineActions } from '../../../../store/timeline';
import { timelineActions, timelineSelectors } from '../../../../store/timeline';
import { isFullScreen } from '../column_headers';
import { useKibana } from '../../../../../common/lib/kibana';
import { getColumnHeader } from '../column_headers/helpers';
const SortingColumnsContainer = styled.div`
button {
@ -92,6 +94,10 @@ const HeaderActionsComponent: React.FC<HeaderActionProps> = ({
const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen();
const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen();
const dispatch = useDispatch();
const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []);
const { defaultColumns } = useDeepEqualSelector((state) => getManageTimeline(state, timelineId));
const toggleFullScreen = useCallback(() => {
if (timelineId === TimelineId.active) {
setTimelineFullScreen(!timelineFullScreen);
@ -168,6 +174,32 @@ const HeaderActionsComponent: React.FC<HeaderActionProps> = ({
[columnHeaders]
);
const onResetColumns = useCallback(() => {
dispatch(timelineActions.updateColumns({ id: timelineId, columns: defaultColumns }));
}, [defaultColumns, dispatch, timelineId]);
const onToggleColumn = useCallback(
(fieldId: string) => {
if (columnHeaders.some(({ id }) => id === fieldId)) {
dispatch(
timelineActions.removeColumn({
columnId: fieldId,
id: timelineId,
})
);
} else {
dispatch(
timelineActions.upsertColumn({
column: getColumnHeader(fieldId, defaultColumns),
id: timelineId,
index: 1,
})
);
}
},
[columnHeaders, dispatch, timelineId, defaultColumns]
);
const ColumnSorting = useDataGridColumnSorting(myColumns, sortedColumns, {}, [], displayValues);
return (
@ -190,7 +222,8 @@ const HeaderActionsComponent: React.FC<HeaderActionProps> = ({
{timelinesUi.getFieldBrowser({
browserFields,
columnHeaders,
timelineId,
onResetColumns,
onToggleColumn,
options: fieldBrowserOptions,
})}
</FieldBrowserContainer>

View file

@ -11,7 +11,12 @@ import { BrowserFields } from '../../../../../../common/search_strategy';
import { ColumnHeaderOptions } from '../../../../../../common/types';
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants';
import { defaultHeaders } from './default_headers';
import { getColumnWidthFromType, getColumnHeaders, getRootCategory } from './helpers';
import {
getColumnWidthFromType,
getColumnHeaders,
getRootCategory,
getColumnHeader,
} from './helpers';
describe('helpers', () => {
describe('getColumnWidthFromType', () => {
@ -50,6 +55,40 @@ describe('helpers', () => {
});
});
describe('getColumnHeader', () => {
test('it should return column header non existing in defaultHeaders', () => {
const field = 'test_field_1';
expect(getColumnHeader(field, [])).toEqual({
columnHeaderType: 'not-filtered',
id: field,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
});
});
test('it should return column header existing in defaultHeaders', () => {
const field = 'test_field_1';
expect(
getColumnHeader(field, [
{
columnHeaderType: 'not-filtered',
id: field,
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
esTypes: ['date'],
type: 'date',
},
])
).toEqual({
columnHeaderType: 'not-filtered',
id: field,
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
esTypes: ['date'],
type: 'date',
});
});
});
describe('getColumnHeaders', () => {
test('should return a full object of ColumnHeader from the default header', () => {
const expectedData = [

View file

@ -10,6 +10,7 @@ import { ColumnHeaderOptions } from '../../../../../../common/types';
import { BrowserFields } from '../../../../../common/containers/source';
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants';
import { defaultColumnHeaderType } from './default_headers';
/**
* Returns the root category for fields that are only one level, e.g. `_id` or `test_field_1`
@ -50,3 +51,16 @@ export const getColumnHeaders = (
export const getColumnWidthFromType = (type: string): number =>
type !== 'date' ? DEFAULT_COLUMN_MIN_WIDTH : DEFAULT_DATE_COLUMN_MIN_WIDTH;
/**
* Returns the column header with field details from the defaultHeaders
*/
export const getColumnHeader = (
fieldName: string,
defaultHeaders: ColumnHeaderOptions[]
): ColumnHeaderOptions => ({
columnHeaderType: defaultColumnHeaderType,
id: fieldName,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
...(defaultHeaders.find((c) => c.id === fieldName) ?? {}),
});

View file

@ -38,14 +38,16 @@ export interface FieldBrowserOptions {
}
export interface FieldBrowserProps {
/** The timeline associated with this field browser */
timelineId: string;
/** The timeline's current column headers */
columnHeaders: ColumnHeaderOptions[];
/** A map of categoryId -> metadata about the fields in that category */
browserFields: BrowserFields;
/** When true, this Fields Browser is being used as an "events viewer" */
isEventViewer?: boolean;
/** Callback to reset the default columns */
onResetColumns: () => void;
/** Callback to toggle a field column */
onToggleColumn: (fieldId: string) => void;
/** The options to customize the field browser, supporting columns rendering and button to create fields */
options?: FieldBrowserOptions;
/** The width of the field browser */

View file

@ -5,12 +5,7 @@
* 2.0.
*/
import React from 'react';
import type { Store } from 'redux';
import { Provider } from 'react-redux';
import { I18nProvider } from '@kbn/i18n-react';
import { FieldBrowser } from '../t_grid/toolbar/field_browser';
import { FieldBrowserProps } from '../../../common/types/field_browser';
export type {
CreateFieldComponent,
FieldBrowserOptions,
@ -18,28 +13,5 @@ export type {
GetFieldTableColumns,
} from '../../../common/types/field_browser';
const EMPTY_BROWSER_FIELDS = {};
export type FieldBrowserWrappedComponentProps = FieldBrowserProps & {
store: Store;
};
export const FieldBrowserWrappedComponent = (props: FieldBrowserWrappedComponentProps) => {
const { store, ...restProps } = props;
const fieldBrowserProps = {
...restProps,
browserFields: restProps.browserFields ?? EMPTY_BROWSER_FIELDS,
};
return (
<Provider store={store}>
<I18nProvider>
<FieldBrowser {...fieldBrowserProps} />
</I18nProvider>
</Provider>
);
};
FieldBrowserWrappedComponent.displayName = 'FieldBrowserWrappedComponent';
// eslint-disable-next-line import/no-default-export
export { FieldBrowserWrappedComponent as default };
export { FieldBrowser as default };

View file

@ -16,6 +16,7 @@ import {
getColumnWidthFromType,
getColumnHeaders,
getSchema,
getColumnHeader,
} from './helpers';
import {
DEFAULT_ACTION_BUTTON_WIDTH,
@ -72,6 +73,40 @@ describe('helpers', () => {
});
});
describe('getColumnHeader', () => {
test('it should return column header non existing in defaultHeaders', () => {
const field = 'test_field_1';
expect(getColumnHeader(field, [])).toEqual({
columnHeaderType: 'not-filtered',
id: field,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
});
});
test('it should return column header existing in defaultHeaders', () => {
const field = 'test_field_1';
expect(
getColumnHeader(field, [
{
columnHeaderType: 'not-filtered',
id: field,
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
esTypes: ['date'],
type: 'date',
},
])
).toEqual({
columnHeaderType: 'not-filtered',
id: field,
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
esTypes: ['date'],
type: 'date',
});
});
});
describe('getColumnHeaders', () => {
// additional properties used by `EuiDataGrid`:
const actions = {

View file

@ -21,6 +21,7 @@ import {
DEFAULT_DATE_COLUMN_MIN_WIDTH,
} from '../constants';
import { allowSorting } from '../helpers';
import { defaultColumnHeaderType } from './default_headers';
const defaultActions: EuiDataGridColumnActions = {
showSortAsc: true,
@ -117,6 +118,19 @@ export const getColumnHeaders = (
: [];
};
/**
* Returns the column header with field details from the defaultHeaders
*/
export const getColumnHeader = (
fieldName: string,
defaultHeaders: ColumnHeaderOptions[]
): ColumnHeaderOptions => ({
columnHeaderType: defaultColumnHeaderType,
id: fieldName,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
...(defaultHeaders.find((c) => c.id === fieldName) ?? {}),
});
export const getColumnWidthFromType = (type: string): number =>
type !== 'date' ? DEFAULT_COLUMN_MIN_WIDTH : DEFAULT_DATE_COLUMN_MIN_WIDTH;

View file

@ -54,7 +54,7 @@ import {
import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy/timeline';
import { getColumnHeaders } from './column_headers/helpers';
import { getColumnHeader, getColumnHeaders } from './column_headers/helpers';
import {
addBuildingBlockStyle,
getEventIdToDataMapping,
@ -341,7 +341,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
const dispatch = useDispatch();
const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []);
const { queryFields, selectAll } = useDeepEqualSelector((state) =>
const { queryFields, selectAll, defaultColumns } = useDeepEqualSelector((state) =>
getManageTimeline(state, id)
);
@ -449,6 +449,32 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
return (bulkActions?.customBulkActions?.length || bulkActions?.alertStatusActions) ?? true;
}, [hasAlertsCrud, selectedCount, showCheckboxes, bulkActions]);
const onResetColumns = useCallback(() => {
dispatch(tGridActions.updateColumns({ id, columns: defaultColumns }));
}, [defaultColumns, dispatch, id]);
const onToggleColumn = useCallback(
(fieldId: string) => {
if (columnHeaders.some(({ id: columnId }) => columnId === fieldId)) {
dispatch(
tGridActions.removeColumn({
columnId: fieldId,
id,
})
);
} else {
dispatch(
tGridActions.upsertColumn({
column: getColumnHeader(fieldId, defaultColumns),
id,
index: 1,
})
);
}
},
[columnHeaders, dispatch, id, defaultColumns]
);
const alertToolbar = useMemo(
() => (
<EuiFlexGroup gutterSize="m" alignItems="center">
@ -523,8 +549,9 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
data-test-subj="field-browser"
browserFields={browserFields}
options={fieldBrowserOptions}
timelineId={id}
columnHeaders={columnHeaders}
onResetColumns={onResetColumns}
onToggleColumn={onToggleColumn}
/>
</>
)}
@ -556,6 +583,8 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
indexNames,
onAlertStatusActionSuccess,
onAlertStatusActionFailure,
onResetColumns,
onToggleColumn,
additionalBulkActions,
refetch,
additionalControls,

View file

@ -13,49 +13,39 @@ import { mockBrowserFields, TestProviders } from '../../../../mock';
import { FIELD_BROWSER_WIDTH } from './helpers';
import { FieldBrowserComponent } from './field_browser';
import { FieldBrowserProps } from '../../../field_browser';
const defaultProps: FieldBrowserProps = {
browserFields: mockBrowserFields,
columnHeaders: [],
onToggleColumn: jest.fn(),
onResetColumns: jest.fn(),
};
const renderComponent = (props: Partial<FieldBrowserProps> = {}) =>
render(
<TestProviders>
<FieldBrowserComponent {...{ ...defaultProps, ...props }} />
</TestProviders>
);
describe('StatefulFieldsBrowser', () => {
const timelineId = 'test';
it('should render the Fields button, which displays the fields browser on click', () => {
const result = render(
<TestProviders>
<FieldBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
timelineId={timelineId}
/>
</TestProviders>
);
const result = renderComponent();
expect(result.getByTestId('show-field-browser')).toBeInTheDocument();
});
describe('toggleShow', () => {
it('should NOT render the fields browser until the Fields button is clicked', () => {
const result = render(
<TestProviders>
<FieldBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
timelineId={timelineId}
/>
</TestProviders>
);
const result = renderComponent();
expect(result.queryByTestId('fields-browser-container')).toBeNull();
});
it('should render the fields browser when the Fields button is clicked', async () => {
const result = render(
<TestProviders>
<FieldBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
timelineId={timelineId}
/>
</TestProviders>
);
const result = renderComponent();
result.getByTestId('show-field-browser').click();
await waitFor(() => {
expect(result.getByTestId('fields-browser-container')).toBeInTheDocument();
@ -65,15 +55,7 @@ describe('StatefulFieldsBrowser', () => {
describe('updateSelectedCategoryIds', () => {
it('should add a selected category, which creates the category badge', async () => {
const result = render(
<TestProviders>
<FieldBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
timelineId={timelineId}
/>
</TestProviders>
);
const result = renderComponent();
result.getByTestId('show-field-browser').click();
await waitFor(() => {
@ -91,15 +73,7 @@ describe('StatefulFieldsBrowser', () => {
});
it('should remove a selected category, which deletes the category badge', async () => {
const result = render(
<TestProviders>
<FieldBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
timelineId={timelineId}
/>
</TestProviders>
);
const result = renderComponent();
result.getByTestId('show-field-browser').click();
await waitFor(() => {
@ -121,15 +95,7 @@ describe('StatefulFieldsBrowser', () => {
});
it('should update the available categories according to the search input', async () => {
const result = render(
<TestProviders>
<FieldBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
timelineId={timelineId}
/>
</TestProviders>
);
const result = renderComponent();
result.getByTestId('show-field-browser').click();
await waitFor(() => {
@ -149,17 +115,7 @@ describe('StatefulFieldsBrowser', () => {
it('should render the Fields Browser button as a settings gear when the isEventViewer prop is true', () => {
const isEventViewer = true;
const result = render(
<TestProviders>
<FieldBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
isEventViewer={isEventViewer}
timelineId={timelineId}
/>
</TestProviders>
);
const result = renderComponent({ isEventViewer });
expect(result.getByTestId('show-field-browser')).toBeInTheDocument();
});
@ -167,18 +123,7 @@ describe('StatefulFieldsBrowser', () => {
it('should render the Fields Browser button as a settings gear when the isEventViewer prop is false', () => {
const isEventViewer = false;
const result = render(
<TestProviders>
<FieldBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
isEventViewer={isEventViewer}
timelineId={timelineId}
width={FIELD_BROWSER_WIDTH}
/>
</TestProviders>
);
const result = renderComponent({ isEventViewer, width: FIELD_BROWSER_WIDTH });
expect(result.getByTestId('show-field-browser')).toBeInTheDocument();
});
});

View file

@ -31,9 +31,10 @@ FieldBrowserButtonContainer.displayName = 'FieldBrowserButtonContainer';
* Manages the state of the field browser
*/
export const FieldBrowserComponent: React.FC<FieldBrowserProps> = ({
timelineId,
columnHeaders,
browserFields,
onResetColumns,
onToggleColumn,
options,
width,
}) => {
@ -149,13 +150,14 @@ export const FieldBrowserComponent: React.FC<FieldBrowserProps> = ({
setSelectedCategoryIds={setSelectedCategoryIds}
onFilterSelectedChange={onFilterSelectedChange}
onHide={onHide}
onResetColumns={onResetColumns}
onSearchInputChange={updateFilter}
onToggleColumn={onToggleColumn}
options={options}
restoreFocusTo={customizeColumnsButtonRef}
searchInput={filterInput}
appliedFilterInput={appliedFilterInput}
selectedCategoryIds={selectedCategoryIds}
timelineId={timelineId}
width={width}
/>
)}

View file

@ -9,24 +9,12 @@ import { mount } from 'enzyme';
import React from 'react';
import { TestProviders, mockBrowserFields, defaultHeaders } from '../../../../mock';
import { mockGlobalState } from '../../../../mock/global_state';
import { tGridActions } from '../../../../store/t_grid';
import { FieldBrowserModal, FieldBrowserModalProps } from './field_browser_modal';
import { createStore, State } from '../../../../types';
import { createSecuritySolutionStorageMock } from '../../../../mock/mock_local_storage';
const mockOnHide = jest.fn();
const mockOnToggleColumn = jest.fn();
const mockOnResetColumns = jest.fn();
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
const timelineId = 'test';
const onHide = jest.fn();
const testProps: FieldBrowserModalProps = {
columnHeaders: [],
filteredBrowserFields: mockBrowserFields,
@ -34,15 +22,15 @@ const testProps: FieldBrowserModalProps = {
appliedFilterInput: '',
isSearching: false,
setSelectedCategoryIds: jest.fn(),
onHide,
onHide: mockOnHide,
onResetColumns: mockOnResetColumns,
onSearchInputChange: jest.fn(),
onToggleColumn: mockOnToggleColumn,
restoreFocusTo: React.createRef<HTMLButtonElement>(),
selectedCategoryIds: [],
timelineId,
filterSelectedEnabled: false,
onFilterSelectedChange: jest.fn(),
};
const { storage } = createSecuritySolutionStorageMock();
describe('FieldBrowserModal', () => {
beforeEach(() => {
@ -67,7 +55,7 @@ describe('FieldBrowserModal', () => {
);
wrapper.find('[data-test-subj="close"]').first().simulate('click');
expect(onHide).toBeCalled();
expect(mockOnHide).toBeCalled();
});
test('it renders the Reset Fields button', () => {
@ -80,7 +68,7 @@ describe('FieldBrowserModal', () => {
expect(wrapper.find('[data-test-subj="reset-fields"]').first().text()).toEqual('Reset Fields');
});
test('it invokes updateColumns action when the user clicks the Reset Fields button', () => {
test('it invokes onResetColumns callback when the user clicks the Reset Fields button', () => {
const wrapper = mount(
<TestProviders>
<FieldBrowserModal {...testProps} columnHeaders={defaultHeaders} />
@ -88,13 +76,7 @@ describe('FieldBrowserModal', () => {
);
wrapper.find('[data-test-subj="reset-fields"]').first().simulate('click');
expect(mockDispatch).toBeCalledWith(
tGridActions.updateColumns({
id: timelineId,
columns: defaultHeaders,
})
);
expect(mockOnResetColumns).toHaveBeenCalled();
});
test('it invokes onHide when the user clicks the Reset Fields button', () => {
@ -106,7 +88,7 @@ describe('FieldBrowserModal', () => {
wrapper.find('[data-test-subj="reset-fields"]').first().simulate('click');
expect(onHide).toBeCalled();
expect(mockOnHide).toBeCalled();
});
test('it renders the search', () => {
@ -173,7 +155,7 @@ describe('FieldBrowserModal', () => {
expect(onSearchInputChange).toBeCalledWith(inputText);
});
test('does not render the CreateFieldButton when it is provided but does not have a dataViewId', () => {
test('it renders the CreateFieldButton when it is provided', () => {
const MyTestComponent = () => <div>{'test'}</div>;
const wrapper = mount(
@ -187,35 +169,6 @@ describe('FieldBrowserModal', () => {
</TestProviders>
);
expect(wrapper.find(MyTestComponent).exists()).toBeFalsy();
});
test('it renders the CreateFieldButton when it is provided and have a dataViewId', () => {
const state: State = {
...mockGlobalState,
timelineById: {
...mockGlobalState.timelineById,
test: {
...mockGlobalState.timelineById.test,
dataViewId: 'security-solution-default',
},
},
};
const store = createStore(state, storage);
const MyTestComponent = () => <div>{'test'}</div>;
const wrapper = mount(
<TestProviders store={store}>
<FieldBrowserModal
{...testProps}
options={{
createFieldButton: MyTestComponent,
}}
/>
</TestProviders>
);
expect(wrapper.find(MyTestComponent).exists()).toBeTruthy();
});
});

View file

@ -17,23 +17,23 @@ import {
EuiButtonEmpty,
EuiSpacer,
} from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import React, { useCallback } from 'react';
import type { BrowserFields } from '../../../../../common/search_strategy';
import type { FieldBrowserProps, ColumnHeaderOptions } from '../../../../../common/types';
import { Search } from './search';
import { CLOSE_BUTTON_CLASS_NAME, FIELD_BROWSER_WIDTH, RESET_FIELDS_CLASS_NAME } from './helpers';
import { tGridActions, tGridSelectors } from '../../../../store/t_grid';
import * as i18n from './translations';
import { useDeepEqualSelector } from '../../../../hooks/use_selector';
import { CategoriesSelector } from './categories_selector';
import { FieldTable } from './field_table';
import { CategoriesBadges } from './categories_badges';
export type FieldBrowserModalProps = Pick<FieldBrowserProps, 'timelineId' | 'width' | 'options'> & {
export type FieldBrowserModalProps = Pick<
FieldBrowserProps,
'width' | 'onResetColumns' | 'onToggleColumn' | 'options'
> & {
/**
* The current timeline column headers
*/
@ -93,6 +93,8 @@ const FieldBrowserModalComponent: React.FC<FieldBrowserModalProps> = ({
filterSelectedEnabled,
isSearching,
onFilterSelectedChange,
onToggleColumn,
onResetColumns,
setSelectedCategoryIds,
onSearchInputChange,
onHide,
@ -100,17 +102,8 @@ const FieldBrowserModalComponent: React.FC<FieldBrowserModalProps> = ({
restoreFocusTo,
searchInput,
selectedCategoryIds,
timelineId,
width = FIELD_BROWSER_WIDTH,
}) => {
const dispatch = useDispatch();
const onUpdateColumns = useCallback(
(columns: ColumnHeaderOptions[]) =>
dispatch(tGridActions.updateColumns({ id: timelineId, columns })),
[dispatch, timelineId]
);
const closeAndRestoreFocus = useCallback(() => {
onHide();
setTimeout(() => {
@ -119,15 +112,10 @@ const FieldBrowserModalComponent: React.FC<FieldBrowserModalProps> = ({
}, 0);
}, [onHide, restoreFocusTo]);
const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []);
const { dataViewId, defaultColumns } = useDeepEqualSelector((state) =>
getManageTimeline(state, timelineId)
);
const onResetColumns = useCallback(() => {
onUpdateColumns(defaultColumns);
const resetColumns = useCallback(() => {
onResetColumns();
closeAndRestoreFocus();
}, [onUpdateColumns, closeAndRestoreFocus, defaultColumns]);
}, [closeAndRestoreFocus, onResetColumns]);
/** Invoked when the user types in the input to filter the field browser */
const onInputChange = useCallback(
@ -159,7 +147,6 @@ const FieldBrowserModalComponent: React.FC<FieldBrowserModalProps> = ({
isSearching={isSearching}
onSearchInputChange={onInputChange}
searchInput={searchInput}
timelineId={timelineId}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -170,9 +157,7 @@ const FieldBrowserModalComponent: React.FC<FieldBrowserModalProps> = ({
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{CreateFieldButton && dataViewId != null && dataViewId.length > 0 && (
<CreateFieldButton onHide={onHide} />
)}
{CreateFieldButton && <CreateFieldButton onHide={onHide} />}
</EuiFlexItem>
</EuiFlexGroup>
@ -184,13 +169,13 @@ const FieldBrowserModalComponent: React.FC<FieldBrowserModalProps> = ({
<EuiSpacer size="l" />
<FieldTable
timelineId={timelineId}
columnHeaders={columnHeaders}
filteredBrowserFields={filteredBrowserFields}
filterSelectedEnabled={filterSelectedEnabled}
searchInput={appliedFilterInput}
selectedCategoryIds={selectedCategoryIds}
onFilterSelectedChange={onFilterSelectedChange}
onToggleColumn={onToggleColumn}
getFieldTableColumns={getFieldTableColumns}
onHide={onHide}
/>
@ -201,7 +186,7 @@ const FieldBrowserModalComponent: React.FC<FieldBrowserModalProps> = ({
<EuiButtonEmpty
className={RESET_FIELDS_CLASS_NAME}
data-test-subj="reset-fields"
onClick={onResetColumns}
onClick={resetColumns}
>
{i18n.RESET_FIELDS}
</EuiButtonEmpty>

View file

@ -29,12 +29,9 @@ import type {
FieldTableColumns,
GetFieldTableColumns,
} from '../../../../../common/types';
import { defaultColumnHeaderType } from '../../body/column_headers/default_headers';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../../body/constants';
import { TruncatableText } from '../../../truncatable_text';
import { FieldName } from './field_name';
import * as i18n from './translations';
import { getAlertColumnHeader } from './helpers';
const TypeIcon = styled(EuiIcon)`
margin: 0 4px;
@ -87,16 +84,6 @@ export const getFieldItems = ({
);
};
/**
* Returns the column header for a field
*/
export const getColumnHeader = (timelineId: string, fieldName: string): ColumnHeaderOptions => ({
columnHeaderType: defaultColumnHeaderType,
id: fieldName,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
...getAlertColumnHeader(timelineId, fieldName),
});
const getDefaultFieldTableColumns = (highlight: string): FieldTableColumns => [
{
field: 'name',

View file

@ -8,22 +8,12 @@
import React from 'react';
import { render, RenderResult } from '@testing-library/react';
import { mockBrowserFields, TestProviders } from '../../../../mock';
import { tGridActions } from '../../../../store/t_grid';
import { defaultColumnHeaderType } from '../../body/column_headers/default_headers';
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../body/constants';
import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../body/constants';
import { ColumnHeaderOptions } from '../../../../../common';
import { FieldTable, FieldTableProps } from './field_table';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
const timestampFieldId = '@timestamp';
const columnHeaders: ColumnHeaderOptions[] = [
@ -39,16 +29,18 @@ const columnHeaders: ColumnHeaderOptions[] = [
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
},
];
const timelineId = 'test';
const mockOnToggleColumn = jest.fn();
const defaultProps: FieldTableProps = {
selectedCategoryIds: [],
columnHeaders: [],
filteredBrowserFields: {},
searchInput: '',
timelineId,
filterSelectedEnabled: false,
onFilterSelectedChange: jest.fn(),
onHide: jest.fn(),
onToggleColumn: mockOnToggleColumn,
};
describe('FieldTable', () => {
@ -56,7 +48,7 @@ describe('FieldTable', () => {
const defaultPageSize = 10;
beforeEach(() => {
mockDispatch.mockClear();
jest.clearAllMocks();
});
it('should render empty field table', () => {
@ -156,7 +148,7 @@ describe('FieldTable', () => {
});
describe('selection', () => {
it('should dispatch remove column action on field unchecked', () => {
it('should call onToggleColumn callback when field unchecked', () => {
const result = render(
<TestProviders>
<FieldTable
@ -170,13 +162,11 @@ describe('FieldTable', () => {
result.getByTestId(`field-${timestampFieldId}-checkbox`).click();
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith(
tGridActions.removeColumn({ id: timelineId, columnId: timestampFieldId })
);
expect(mockOnToggleColumn).toHaveBeenCalledTimes(1);
expect(mockOnToggleColumn).toHaveBeenCalledWith(timestampFieldId);
});
it('should dispatch upsert column action on field checked', () => {
it('should call onToggleColumn callback when field checked', () => {
const result = render(
<TestProviders>
<FieldTable
@ -189,18 +179,8 @@ describe('FieldTable', () => {
result.getByTestId(`field-${timestampFieldId}-checkbox`).click();
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith(
tGridActions.upsertColumn({
id: timelineId,
column: {
columnHeaderType: defaultColumnHeaderType,
id: timestampFieldId,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
},
index: 1,
})
);
expect(mockOnToggleColumn).toHaveBeenCalledTimes(1);
expect(mockOnToggleColumn).toHaveBeenCalledWith(timestampFieldId);
});
});
@ -241,7 +221,7 @@ describe('FieldTable', () => {
changePage(result);
result.getAllByRole('checkbox').at(0)?.click();
expect(mockDispatch).toHaveBeenCalled(); // assert some field has been selected
expect(mockOnToggleColumn).toHaveBeenCalled(); // assert some field has been selected
expect(isAtFirstPage(result)).toBeFalsy();
});

View file

@ -8,11 +8,9 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { EuiInMemoryTable, Pagination, Direction } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { BrowserFields, ColumnHeaderOptions } from '../../../../../common';
import { getColumnHeader, getFieldColumns, getFieldItems, isActionsColumn } from './field_items';
import { getFieldColumns, getFieldItems, isActionsColumn } from './field_items';
import { CATEGORY_TABLE_CLASS_NAME, TABLE_HEIGHT } from './helpers';
import { tGridActions } from '../../../../store/t_grid';
import type { GetFieldTableColumns } from '../../../../../common/types/field_browser';
import { FieldTableHeader } from './field_table_header';
@ -22,7 +20,6 @@ const DEFAULT_SORTING: { field: string; direction: Direction } = {
} as const;
export interface FieldTableProps {
timelineId: string;
columnHeaders: ColumnHeaderOptions[];
/**
* A map of categoryId -> metadata about the fields in that category,
@ -33,6 +30,7 @@ export interface FieldTableProps {
/** when true, show only the the selected field */
filterSelectedEnabled: boolean;
onFilterSelectedChange: (enabled: boolean) => void;
onToggleColumn: (fieldId: string) => void;
/**
* Optional function to customize field table columns
*/
@ -71,7 +69,7 @@ const FieldTableComponent: React.FC<FieldTableProps> = ({
searchInput,
selectedCategoryIds,
onFilterSelectedChange,
timelineId,
onToggleColumn,
onHide,
}) => {
const [pageIndex, setPageIndex] = useState(0);
@ -80,8 +78,6 @@ const FieldTableComponent: React.FC<FieldTableProps> = ({
const [sortField, setSortField] = useState<string>(DEFAULT_SORTING.field);
const [sortDirection, setSortDirection] = useState<Direction>(DEFAULT_SORTING.direction);
const dispatch = useDispatch();
const fieldItems = useMemo(
() =>
getFieldItems({
@ -92,28 +88,6 @@ const FieldTableComponent: React.FC<FieldTableProps> = ({
[columnHeaders, filteredBrowserFields, selectedCategoryIds]
);
const onToggleColumn = useCallback(
(fieldId: string) => {
if (columnHeaders.some(({ id }) => id === fieldId)) {
dispatch(
tGridActions.removeColumn({
columnId: fieldId,
id: timelineId,
})
);
} else {
dispatch(
tGridActions.upsertColumn({
column: getColumnHeader(timelineId, fieldId),
id: timelineId,
index: 1,
})
);
}
},
[columnHeaders, dispatch, timelineId]
);
/**
* Pagination controls
*/

View file

@ -8,9 +8,7 @@
import { EuiBadge, EuiLoadingSpinner } from '@elastic/eui';
import styled from 'styled-components';
import { TimelineId } from '../../../../types';
import type { BrowserField, BrowserFields } from '../../../../../common/search_strategy';
import { defaultHeaders } from '../../../../store/t_grid/defaults';
import { ColumnHeaderOptions } from '../../../../../common';
export const LoadingSpinner = styled(EuiLoadingSpinner)`
@ -149,11 +147,6 @@ export const filterSelectedBrowserFields = ({
return result;
};
export const getAlertColumnHeader = (timelineId: string, fieldId: string) =>
timelineId === TimelineId.detectionsPage || timelineId === TimelineId.detectionsRulesDetailsPage
? defaultHeaders.find((c) => c.id === fieldId) ?? {}
: {};
export const CATEGORY_TABLE_CLASS_NAME = 'category-table';
export const CLOSE_BUTTON_CLASS_NAME = 'close-button';
export const RESET_FIELDS_CLASS_NAME = 'reset-fields';

View file

@ -10,18 +10,11 @@ import React from 'react';
import { TestProviders } from '../../../../mock';
import { Search } from './search';
const timelineId = 'test';
describe('Search', () => {
test('it renders the field search input with the expected placeholder text when the searchInput prop is empty', () => {
const wrapper = mount(
<TestProviders>
<Search
isSearching={false}
onSearchInputChange={jest.fn()}
searchInput=""
timelineId={timelineId}
/>
<Search isSearching={false} onSearchInputChange={jest.fn()} searchInput="" />
</TestProviders>
);
@ -35,12 +28,7 @@ describe('Search', () => {
const wrapper = mount(
<TestProviders>
<Search
isSearching={false}
onSearchInputChange={jest.fn()}
searchInput={searchInput}
timelineId={timelineId}
/>
<Search isSearching={false} onSearchInputChange={jest.fn()} searchInput={searchInput} />
</TestProviders>
);
@ -50,12 +38,7 @@ describe('Search', () => {
test('it renders the field search input with a spinner when isSearching is true', () => {
const wrapper = mount(
<TestProviders>
<Search
isSearching={true}
onSearchInputChange={jest.fn()}
searchInput=""
timelineId={timelineId}
/>
<Search isSearching={true} onSearchInputChange={jest.fn()} searchInput="" />
</TestProviders>
);
@ -67,12 +50,7 @@ describe('Search', () => {
const wrapper = mount(
<TestProviders>
<Search
isSearching={false}
onSearchInputChange={onSearchInputChange}
searchInput=""
timelineId={timelineId}
/>
<Search isSearching={false} onSearchInputChange={onSearchInputChange} searchInput="" />
</TestProviders>
);

View file

@ -12,22 +12,19 @@ interface Props {
isSearching: boolean;
onSearchInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
searchInput: string;
timelineId: string;
}
const inputRef = (node: HTMLInputElement | null) => node?.focus();
export const Search = React.memo<Props>(
({ isSearching, onSearchInputChange, searchInput, timelineId }) => (
<EuiFieldSearch
data-test-subj="field-search"
inputRef={inputRef}
isLoading={isSearching}
onChange={onSearchInputChange}
placeholder={i18n.FILTER_PLACEHOLDER}
value={searchInput}
fullWidth
/>
)
);
export const Search = React.memo<Props>(({ isSearching, onSearchInputChange, searchInput }) => (
<EuiFieldSearch
data-test-subj="field-search"
inputRef={inputRef}
isLoading={isSearching}
onChange={onSearchInputChange}
placeholder={i18n.FILTER_PLACEHOLDER}
value={searchInput}
fullWidth
/>
));
Search.displayName = 'Search';

View file

@ -74,10 +74,10 @@ export const getLoadingPanelLazy = (props: LoadingPanelProps) => {
};
const FieldBrowserLazy = lazy(() => import('../components/field_browser'));
export const getFieldBrowserLazy = (props: FieldBrowserProps, { store }: { store: Store }) => {
export const getFieldBrowserLazy = (props: FieldBrowserProps) => {
return (
<Suspense fallback={<EuiLoadingSpinner />}>
<FieldBrowserLazy {...props} store={store} />
<FieldBrowserLazy {...props} />
</Suspense>
);
};

View file

@ -75,10 +75,7 @@ export class TimelinesPlugin implements Plugin<void, TimelinesUIStart> {
return getLastUpdatedLazy(props);
},
getFieldBrowser: (props: FieldBrowserProps) => {
return getFieldBrowserLazy(props, {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
store: this._store!,
});
return getFieldBrowserLazy(props);
},
getUseAddToTimeline: () => {
return useAddToTimeline;