Skip to content

Commit

Permalink
[Security Solution] Add Discover Data View picker to Timeline (#184928)
Browse files Browse the repository at this point in the history
## Summary

Add new `Dataview picker` component and some initial redux setup to feed
it with data.
Dont expect this to work just like the original timeline sourcerer does
just yet.

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### Testing
Do `localStorage.setItem('EXPERIMENTAL_SOURCERER_ENABLED', true)` in the
browser console, reload the page,
then open new timeline.

You should see the new dataview picker (colored in red temporarily),
that should allow data view switching.

Known issues: dataview editor is showing behind the picker (to be fixed
in subsequent PR).
  • Loading branch information
lgestc authored Jun 21, 2024
1 parent 4c3afc5 commit 7129eea
Show file tree
Hide file tree
Showing 23 changed files with 715 additions and 23 deletions.
4 changes: 4 additions & 0 deletions src/plugins/data_view_editor/public/open_editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ export const getEditorOpener =
{
hideCloseButton: true,
size: 'l',
maskProps: {
// EUI TODO: This z-index override of EuiOverlayMask is a workaround, and ideally should be resolved with a cleaner UI/UX flow long-term
style: 'z-index: 1003', // we need this flyout to be above the timeline flyout (which has a z-index of 1002)
},
}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import type { KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template';
import { ExpandableFlyoutProvider } from '@kbn/expandable-flyout';
import { DataViewPickerProvider } from '../../../sourcerer/experimental/containers/dataview_picker_provider';
import { AttackDiscoveryTour } from '../../../attack_discovery/tour';
import { URL_PARAM_KEY } from '../../../common/hooks/use_url_state';
import { SecuritySolutionFlyout, TimelineFlyout } from '../../../flyout';
Expand Down Expand Up @@ -103,10 +104,12 @@ export const SecuritySolutionTemplateWrapper: React.FC<SecuritySolutionTemplateW
component="div"
grow={true}
>
<ExpandableFlyoutProvider urlKey={isPreview ? undefined : URL_PARAM_KEY.flyout}>
{children}
<SecuritySolutionFlyout />
</ExpandableFlyoutProvider>
<DataViewPickerProvider>
<ExpandableFlyoutProvider urlKey={isPreview ? undefined : URL_PARAM_KEY.flyout}>
{children}
<SecuritySolutionFlyout />
</ExpandableFlyoutProvider>
</DataViewPickerProvider>

{didMount && <AttackDiscoveryTour />}
</KibanaPageTemplate.Section>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { initialGroupingState } from '../store/grouping/reducer';
import type { SourcererState } from '../../sourcerer/store';
import { EMPTY_RESOLVER } from '../../resolver/store/helpers';
import { getMockDiscoverInTimelineState } from './mock_discover_state';
import { initialState as dataViewPickerInitialState } from '../../sourcerer/experimental/redux/reducer';

const mockFieldMap: DataViewSpec['fields'] = Object.fromEntries(
mockIndexFields.map((field) => [field.name, field])
Expand Down Expand Up @@ -501,6 +502,7 @@ export const mockGlobalState: State = {
*/
management: mockManagementState as ManagementState,
discover: getMockDiscoverInTimelineState(),
dataViewPicker: dataViewPickerInitialState,
notes: {
ids: ['1'],
entities: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useSourcererDataView } from '../../sourcerer/containers';
import { renderHook } from '@testing-library/react-hooks';
import { initialGroupingState } from './grouping/reducer';
import { initialAnalyzerState } from '../../resolver/store/helpers';
import { initialState as dataViewPickerInitialState } from '../../sourcerer/experimental/redux/reducer';
import { initialNotesState } from '../../notes/store/notes.slice';

jest.mock('../hooks/use_selector');
Expand Down Expand Up @@ -71,6 +72,7 @@ describe('createInitialState', () => {
{
analyzer: initialAnalyzerState,
},
dataViewPickerInitialState,
initialNotesState
);

Expand Down Expand Up @@ -110,7 +112,7 @@ describe('createInitialState', () => {
{
analyzer: initialAnalyzerState,
},

dataViewPickerInitialState,
initialNotesState
);
const { result } = renderHook(() => useSourcererDataView(), {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import type { GroupState } from './grouping/types';
import { analyzerReducer } from '../../resolver/store/reducer';
import { securitySolutionDiscoverReducer } from './discover/reducer';
import type { AnalyzerState } from '../../resolver/types';
import {
type DataviewPickerState,
reducer as dataviewPickerReducer,
} from '../../sourcerer/experimental/redux/reducer';
import type { NotesState } from '../../notes/store/notes.slice';
import { notesReducer } from '../../notes/store/notes.slice';

Expand Down Expand Up @@ -69,6 +73,7 @@ export const createInitialState = (
dataTableState: DataTableState,
groupsState: GroupState,
analyzerState: AnalyzerState,
dataviewPickerState: DataviewPickerState,
notesState: NotesState
): State => {
const initialPatterns = {
Expand Down Expand Up @@ -131,6 +136,7 @@ export const createInitialState = (
internal: undefined,
savedSearch: undefined,
},
dataViewPicker: dataviewPickerState,
notes: notesState,
};

Expand All @@ -150,6 +156,7 @@ export const createReducer: (
sourcerer: sourcererReducer,
globalUrlParam: globalUrlParamReducer,
dataTable: dataTableReducer,
dataViewPicker: dataviewPickerReducer,
groups: groupsReducer,
analyzer: analyzerReducer,
discover: securitySolutionDiscoverReducer,
Expand Down
8 changes: 8 additions & 0 deletions x-pack/plugins/security_solution/public/common/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ import { dataAccessLayerFactory } from '../../resolver/data_access_layer/factory
import { sourcererActions } from '../../sourcerer/store';
import { createMiddlewares } from './middlewares';
import { addNewTimeline } from '../../timelines/store/helpers';
import {
reducer as dataViewPickerReducer,
initialState as dataViewPickerState,
} from '../../sourcerer/experimental/redux/reducer';
import { listenerMiddleware } from '../../sourcerer/experimental/redux/listeners';
import { initialNotesState } from '../../notes/store/notes.slice';

let store: Store<State, Action> | null = null;
Expand Down Expand Up @@ -171,19 +176,22 @@ export const createStoreFactory = async (
dataTableInitialState,
groupsInitialState,
analyzerInitialState,
dataViewPickerState,
initialNotesState
);

const rootReducer = {
...subPlugins.explore.store.reducer,
timeline: timelineReducer,
...subPlugins.management.store.reducer,
dataViewPicker: dataViewPickerReducer,
};

return createStore(initialState, rootReducer, coreStart, storage, [
...(subPlugins.management.store.middleware ?? []),
...(subPlugins.explore.store.middleware ?? []),
...[resolverMiddlewareFactory(dataAccessLayerFactory(coreStart)) ?? []],
listenerMiddleware.middleware,
]);
};

Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/security_solution/public/common/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { GlobalUrlParam } from './global_url_param';
import type { GroupState } from './grouping/types';
import type { SecuritySolutionDiscoverState } from './discover/model';
import type { AnalyzerState } from '../../resolver/types';
import { type DataviewPickerState } from '../../sourcerer/experimental/redux/reducer';
import type { NotesState } from '../../notes/store/notes.slice';

export type State = HostsPluginState &
Expand All @@ -38,6 +39,7 @@ export type State = HostsPluginState &
sourcerer: SourcererState;
globalUrlParam: GlobalUrlParam;
discover: SecuritySolutionDiscoverState;
dataViewPicker: DataviewPickerState;
} & DataTableState &
GroupState &
AnalyzerState & { notes: NotesState };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { useKibana } from '../../../../common/lib/kibana/kibana_react';
import { useDispatch, useSelector } from 'react-redux';
import { selectDataView } from '../../redux/actions';
import { DataViewPicker } from '.';

// Mock the required hooks and dependencies
jest.mock('../../../../common/lib/kibana/kibana_react', () => ({
useKibana: jest.fn(),
}));

jest.mock('react-redux', () => ({
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));

jest.mock('../../redux/actions', () => ({
selectDataView: jest.fn(),
}));

jest.mock('@kbn/unified-search-plugin/public', () => ({
DataViewPicker: jest.fn((props) => (
<div>
<div>{props.trigger.label}</div>
<button
type="button"
onClick={() => props.onChangeDataView('new-id')}
>{`Change DataView`}</button>
<button type="button" onClick={props.onAddField}>
{`Add Field`}
</button>
<button type="button" onClick={props.onDataViewCreated}>
{`Create New DataView`}
</button>
</div>
)),
}));

describe('DataViewPicker', () => {
const mockDispatch = jest.fn();
const mockDataViewEditor = {
openEditor: jest.fn(),
};
const mockDataViewFieldEditor = {
openEditor: jest.fn(),
};
const mockData = {
dataViews: {
get: jest.fn().mockResolvedValue({}),
},
};

beforeEach(() => {
(useDispatch as jest.Mock).mockReturnValue(mockDispatch);
(useKibana as jest.Mock).mockReturnValue({
services: {
dataViewEditor: mockDataViewEditor,
data: mockData,
dataViewFieldEditor: mockDataViewFieldEditor,
},
});
(useSelector as jest.Mock).mockReturnValue({ dataViewId: 'test-id' });
});

afterEach(() => {
jest.clearAllMocks();
});

test('renders the DataviewPicker component', () => {
render(<DataViewPicker />);
expect(screen.getByText('Dataview')).toBeInTheDocument();
});

test('calls dispatch on data view change', () => {
render(<DataViewPicker />);
fireEvent.click(screen.getByText('Change DataView'));
expect(mockDispatch).toHaveBeenCalledWith(selectDataView('new-id'));
});

test('opens data view editor when creating a new data view', () => {
render(<DataViewPicker />);
fireEvent.click(screen.getByText('Create New DataView'));
expect(mockDataViewEditor.openEditor).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* 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 { DataViewPicker as USDataViewPicker } from '@kbn/unified-search-plugin/public';
import React, { useCallback, useRef, useMemo, memo } from 'react';
import { useDispatch, useSelector } from 'react-redux';

import { useKibana } from '../../../../common/lib/kibana/kibana_react';
import { DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID } from '../../constants';
import { selectDataView } from '../../redux/actions';
import { sourcererAdapterSelector } from '../../redux/selectors';

const TRIGGER_CONFIG = {
label: 'Dataview',
color: 'danger',
title: 'Experimental data view picker',
iconType: 'beaker',
} as const;

export const DataViewPicker = memo(() => {
const dispatch = useDispatch();

const {
services: { dataViewEditor, data, dataViewFieldEditor },
} = useKibana();

const closeDataViewEditor = useRef<() => void | undefined>();
const closeFieldEditor = useRef<() => void | undefined>();

// TODO: should this be implemented like that? If yes, we need to source dataView somehow or implement the same thing based on the existing state value.
// const canEditDataView =
// Boolean(dataViewEditor?.userPermissions.editDataView()) || !dataView.isPersisted();
const canEditDataView = true;

const { dataViewId } = useSelector(sourcererAdapterSelector);

const createNewDataView = useCallback(() => {
closeDataViewEditor.current = dataViewEditor.openEditor({
// eslint-disable-next-line no-console
onSave: () => console.log('new data view saved'),
allowAdHocDataView: true,
});
}, [dataViewEditor]);

const onFieldEdited = useCallback(() => {}, []);

const editField = useMemo(() => {
if (!canEditDataView) {
return;
}
return async (fieldName?: string, _uiAction: 'edit' | 'add' = 'edit') => {
if (!dataViewId) {
return;
}

const dataViewInstance = await data.dataViews.get(dataViewId);
closeFieldEditor.current = dataViewFieldEditor.openEditor({
ctx: {
dataView: dataViewInstance,
},
fieldName,
onSave: async () => {
onFieldEdited();
},
});
};
}, [canEditDataView, dataViewId, data.dataViews, dataViewFieldEditor, onFieldEdited]);

const addField = useMemo(
() => (canEditDataView && editField ? () => editField(undefined, 'add') : undefined),
[editField, canEditDataView]
);

const handleChangeDataView = useCallback(
(id: string) => {
dispatch(selectDataView(id));
},
[dispatch]
);

const handleEditDataView = useCallback(() => {}, []);

return (
<USDataViewPicker
currentDataViewId={dataViewId || DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID}
trigger={TRIGGER_CONFIG}
onChangeDataView={handleChangeDataView}
onEditDataView={handleEditDataView}
onAddField={addField}
onDataViewCreated={createNewDataView}
/>
);
});

DataViewPicker.displayName = 'DataviewPicker';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Dataview Picker

A replacement for the Sourcerer component, based on the Discover implementation.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* 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.
*/

export const DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID = 'security-solution-default';
Loading

0 comments on commit 7129eea

Please sign in to comment.