diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx index 0da4a459352ea..a1d7ec1a5af5a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx @@ -27,18 +27,32 @@ import { TopValuesPopover } from '../components/top_values_popover/top_values_po import { AssistantOverlay } from '../../assistant/overlay'; import { useInitSourcerer } from '../../sourcerer/containers/use_init_sourcerer'; import { useInitDataViewManager } from '../../data_view_manager/hooks/use_init_data_view_manager'; +import { useRestoreDataViewManagerStateFromURL } from '../../data_view_manager/hooks/use_sync_url_state'; +import { useBrowserFields } from '../../data_view_manager/hooks/use_browser_fields'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { type BrowserFields } from '../../common/containers/source'; interface HomePageProps { children: React.ReactNode; } const HomePageComponent: React.FC = ({ children }) => { + const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); + const { pathname } = useLocation(); - const { browserFields } = useInitSourcerer(getScopeFromPath(pathname)); + const sourcererScope = getScopeFromPath(pathname); + const { browserFields: oldBrowserFields } = useInitSourcerer(sourcererScope); + const { browserFields: experimentalBrowserFields } = useBrowserFields(sourcererScope); + + useRestoreDataViewManagerStateFromURL(useInitDataViewManager(), sourcererScope); + useUrlState(); useUpdateBrowserTitle(); useUpdateExecutionContext(); - useInitDataViewManager(); + + const browserFields = ( + newDataViewPickerEnabled ? experimentalBrowserFields : oldBrowserFields + ) as BrowserFields; // side effect: this will attempt to upgrade the endpoint package if it is not up to date // this will run when a user navigates to the Security Solution app and when they navigate between diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/constants.ts new file mode 100644 index 0000000000000..8ff90991e9ef2 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/constants.ts @@ -0,0 +1,21 @@ +/* + * 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 URL_PARAM_KEY = { + appQuery: 'query', + /** @deprecated */ + eventFlyout: 'eventFlyout', // TODO remove when we assume it's been long enough that all users should use the newer `flyout` key + flyout: 'flyout', + timelineFlyout: 'timelineFlyout', + filters: 'filters', + savedQuery: 'savedQuery', + sourcerer: 'sourcerer', + timeline: 'timeline', + timerange: 'timerange', + pageFilter: 'pageFilters', + rulesTable: 'rulesTable', +} as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/timeline/use_investigate_in_timeline.ts b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/timeline/use_investigate_in_timeline.ts index b73a69ab2bfa3..dfbe4b5fc3915 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/timeline/use_investigate_in_timeline.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/timeline/use_investigate_in_timeline.ts @@ -131,7 +131,7 @@ export const useInvestigateInTimeline = () => { if (!keepDataView) { if (newDataViewPickerEnabled) { setSelectedDataView({ - scope: [SourcererScopeName.timeline], + scope: SourcererScopeName.timeline, id: defaultDataView.id, fallbackPatterns: [signalIndexName || ''], }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_url_state.ts b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_url_state.ts index 47168d76e8ec3..50db4bcd48452 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_url_state.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/use_url_state.ts @@ -23,17 +23,4 @@ export const useUrlState = () => { useQueryTimelineByIdOnUrlChange(); }; -export const URL_PARAM_KEY = { - appQuery: 'query', - /** @deprecated */ - eventFlyout: 'eventFlyout', // TODO remove when we assume it's been long enough that all users should use the newer `flyout` key - flyout: 'flyout', - timelineFlyout: 'timelineFlyout', - filters: 'filters', - savedQuery: 'savedQuery', - sourcerer: 'sourcerer', - timeline: 'timeline', - timerange: 'timerange', - pageFilter: 'pageFilters', - rulesTable: 'rulesTable', -} as const; +export { URL_PARAM_KEY } from './constants'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.test.tsx index 2054f8266edc3..17f0044bbc9d7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.test.tsx @@ -17,6 +17,12 @@ import { DataView } from '@kbn/data-views-plugin/common'; import { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common'; import { TestProviders } from '../../../common/mock/test_providers'; import { useSelectDataView } from '../../hooks/use_select_data_view'; +import { useUpdateUrlParam } from '../../../common/utils/global_query_string'; +import { URL_PARAM_KEY } from '../../../common/hooks/constants'; + +jest.mock('../../../common/utils/global_query_string', () => ({ + useUpdateUrlParam: jest.fn(), +})); jest.mock('../../hooks/use_data_view_spec', () => ({ useDataViewSpec: jest.fn(), @@ -66,6 +72,8 @@ describe('DataViewPicker', () => { let mockDispatch = jest.fn(); beforeEach(() => { + jest.mocked(useUpdateUrlParam).mockReturnValue(jest.fn()); + jest.mocked(useDataViewSpec).mockReturnValue({ dataViewSpec: { id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, @@ -113,7 +121,24 @@ describe('DataViewPicker', () => { expect(jest.mocked(useSelectDataView())).toHaveBeenCalledWith({ id: 'new-data-view-id', - scope: ['default'], + scope: 'default', + }); + }); + + it('calls useUpdateUrlParam when changing the data view', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('changeDataView')); + + expect(jest.mocked(useUpdateUrlParam(URL_PARAM_KEY.sourcerer))).toHaveBeenCalledWith({ + default: { + id: 'new-data-view-id', + selectedPatterns: [], + }, }); }); @@ -144,7 +169,7 @@ describe('DataViewPicker', () => { ); expect(jest.mocked(useSelectDataView())).toHaveBeenCalledWith({ id: 'new-data-view-id', - scope: ['default'], + scope: 'default', }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.tsx index 75dec8d4f7525..e083a01046e8f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.tsx @@ -10,17 +10,19 @@ import React, { useCallback, useRef, useMemo, memo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { DataView } from '@kbn/data-views-plugin/public'; -import type { DataViewManagerScopeName } from '../../constants'; +import type { SourcererUrlState } from '../../../sourcerer/store/model'; +import { useUpdateUrlParam } from '../../../common/utils/global_query_string'; +import { URL_PARAM_KEY } from '../../../common/hooks/use_url_state'; import { useKibana } from '../../../common/lib/kibana'; -import { DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID } from '../../constants'; import { useDataViewSpec } from '../../hooks/use_data_view_spec'; import { sharedStateSelector } from '../../redux/selectors'; import { sharedDataViewManagerSlice } from '../../redux/slices'; import { useSelectDataView } from '../../hooks/use_select_data_view'; -import { DATA_VIEW_PICKER_TEST_ID } from './constants'; +import { DataViewManagerScopeName, DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID } from '../../constants'; import { useManagedDataViews } from '../../hooks/use_managed_data_views'; import { useSavedDataViews } from '../../hooks/use_saved_data_views'; import { DEFAULT_SECURITY_DATA_VIEW, LOADING } from './translations'; +import { DATA_VIEW_PICKER_TEST_ID } from './constants'; interface DataViewPickerProps { /** @@ -49,24 +51,43 @@ export const DataViewPicker = memo(({ scope, onClosePopover, disabled }: DataVie const { dataViewSpec, status } = useDataViewSpec(scope); + const isDefaultSourcerer = scope === DataViewManagerScopeName.default; + const updateUrlParam = useUpdateUrlParam(URL_PARAM_KEY.sourcerer); + const dataViewId = dataViewSpec?.id; + // NOTE: this function is called in response to user interaction with the picker, + // hence - it is the only place where we should update the url param for the data view selection. + const handleChangeDataView = useCallback( + (id: string, indexPattern: string = '') => { + selectDataView({ id, scope }); + + if (isDefaultSourcerer) { + updateUrlParam({ + [DataViewManagerScopeName.default]: { + id, + // NOTE: Boolean filter for removing empty patterns + selectedPatterns: indexPattern.split(',').filter(Boolean), + }, + }); + } + }, + [isDefaultSourcerer, scope, selectDataView, updateUrlParam] + ); + const createNewDataView = useCallback(() => { closeDataViewEditor.current = dataViewEditor.openEditor({ onSave: async (newDataView) => { + if (!newDataView.id) { + return; + } + dispatch(sharedDataViewManagerSlice.actions.addDataView(newDataView)); - selectDataView({ id: newDataView.id, scope: [scope] }); + handleChangeDataView(newDataView.id, newDataView.getIndexPattern()); }, allowAdHocDataView: true, }); - }, [dataViewEditor, dispatch, scope, selectDataView]); - - const handleChangeDataView = useCallback( - (id: string) => { - selectDataView({ id, scope: [scope] }); - }, - [scope, selectDataView] - ); + }, [dataViewEditor, dispatch, handleChangeDataView]); const editField = useCallback( async (fieldName?: string, _uiAction: 'edit' | 'add' = 'edit') => { @@ -86,7 +107,7 @@ export const DataViewPicker = memo(({ scope, onClosePopover, disabled }: DataVie return; } - handleChangeDataView(dataViewInstance.id); + handleChangeDataView(dataViewInstance.id, dataViewInstance.getIndexPattern()); }, }); }, @@ -98,9 +119,12 @@ export const DataViewPicker = memo(({ scope, onClosePopover, disabled }: DataVie */ const handleDataViewModified = useCallback( (updatedDataView: DataView) => { - selectDataView({ id: updatedDataView.id, scope: [scope] }); + if (!updatedDataView.id) { + return; + } + handleChangeDataView(updatedDataView.id, updatedDataView.getIndexPattern()); }, - [scope, selectDataView] + [handleChangeDataView] ); const handleAddField = useCallback(() => editField(undefined, 'add'), [editField]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.test.ts index 0e5b1ba3bdb26..c924783f3b0e5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.test.ts @@ -28,11 +28,11 @@ describe('useInitDataViewPicker', () => { it('should render and dispatch an init action', () => { renderHook( () => { - return useInitDataViewManager(); + return useInitDataViewManager()([]); }, { wrapper: TestProviders } ); - expect(useDispatch()).toHaveBeenCalledWith(sharedDataViewManagerSlice.actions.init()); + expect(useDispatch()).toHaveBeenCalledWith(sharedDataViewManagerSlice.actions.init([])); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.ts index d48af1ec4c650..67711aecbf730 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.ts @@ -6,7 +6,7 @@ */ import { useDispatch } from 'react-redux'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import type { AnyAction, Dispatch, ListenerEffectAPI } from '@reduxjs/toolkit'; import { addListener as originalAddListener, @@ -18,6 +18,8 @@ import { createDataViewSelectedListener } from '../redux/listeners/data_view_sel import { createInitListener } from '../redux/listeners/init_listener'; import { useEnableExperimental } from '../../common/hooks/use_experimental_features'; import { sharedDataViewManagerSlice } from '../redux/slices'; +import { type SelectDataViewAsyncPayload } from '../redux/actions'; +import { DataViewManagerScopeName } from '../constants'; type OriginalListener = Parameters[0]; @@ -49,19 +51,39 @@ export const useInitDataViewManager = () => { dataViews: services.dataViews, }); - const dataViewSelectedListener = createDataViewSelectedListener({ - dataViews: services.dataViews, - }); - dispatch(addListener(dataViewsLoadingListener)); - dispatch(addListener(dataViewSelectedListener)); + + // NOTE: Every scope has its own listener instance; this allows for cancellation + const listeners = [ + DataViewManagerScopeName.default, + DataViewManagerScopeName.timeline, + DataViewManagerScopeName.detections, + DataViewManagerScopeName.analyzer, + ].map((scope) => + createDataViewSelectedListener({ + scope, + dataViews: services.dataViews, + }) + ); + + listeners.forEach((dataViewSelectedListener) => { + dispatch(addListener(dataViewSelectedListener)); + }); // NOTE: this kicks off the data loading in the Data View Picker - dispatch(sharedDataViewManagerSlice.actions.init()); return () => { dispatch(removeListener(dataViewsLoadingListener)); - dispatch(removeListener(dataViewSelectedListener)); + listeners.forEach((dataViewSelectedListener) => { + dispatch(removeListener(dataViewSelectedListener)); + }); }; }, [dispatch, newDataViewPickerEnabled, services.dataViews]); + + return useCallback( + (initialSelection: SelectDataViewAsyncPayload[]) => { + dispatch(sharedDataViewManagerSlice.actions.init(initialSelection)); + }, + [dispatch] + ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.test.ts index 45c5323946870..178ff8f610db7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.test.ts @@ -29,10 +29,10 @@ describe('useSelectDataView', () => { { wrapper: TestProviders } ); - result.current({ id: 'test', scope: [DataViewManagerScopeName.default] }); + result.current({ id: 'test', scope: DataViewManagerScopeName.default }); expect(useDispatch()).toHaveBeenCalledWith({ - payload: { id: 'test', scope: ['default'] }, + payload: { id: 'test', scope: 'default' }, type: 'x-pack/security_solution/dataViewManager/selectDataView', }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.ts index 84bb4365015d1..aaadb27f297af 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.ts @@ -10,6 +10,11 @@ import { useCallback } from 'react'; import type { DataViewManagerScopeName } from '../constants'; import { selectDataViewAsync } from '../redux/actions'; +/** + * This hook wraps the dispatch call that updates the redux store with new data view selection. + * It is the recommended entry point for altering the data view selection. + * Manual action dispatches are not required and should be avoided outside of the data view manager scope. + */ export const useSelectDataView = () => { const dispatch = useDispatch(); @@ -24,7 +29,7 @@ export const useSelectDataView = () => { /** * Data view selection will be applied to the scopes listed here */ - scope: DataViewManagerScopeName[]; + scope: DataViewManagerScopeName; }) => { dispatch(selectDataViewAsync(params)); }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_sync_url_state.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_sync_url_state.test.ts new file mode 100644 index 0000000000000..c445a3c2803d9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_sync_url_state.test.ts @@ -0,0 +1,118 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { + useSyncSourcererUrlState, + useRestoreDataViewManagerStateFromURL, +} from './use_sync_url_state'; +import * as reactRedux from 'react-redux'; +import * as experimentalFeatures from '../../common/hooks/use_experimental_features'; +import * as globalQueryString from '../../common/utils/global_query_string'; +import { SourcererScopeName } from '../../sourcerer/store/model'; + +jest.mock('react-redux'); +jest.mock('../../common/hooks/use_experimental_features'); +jest.mock('../../common/utils/global_query_string'); +jest.mock('../../common/store/selectors'); +jest.mock('./use_select_data_view'); + +describe('useSyncSourcererUrlState', () => { + const mockDispatch = jest.fn(); + const mockUpdateUrlParam = jest.fn(); + const mockUseSelector = reactRedux.useSelector as jest.Mock; + const mockUseDispatch = reactRedux.useDispatch as jest.Mock; + const mockUseIsExperimentalFeatureEnabled = + experimentalFeatures.useIsExperimentalFeatureEnabled as jest.Mock; + const mockUseUpdateUrlParam = globalQueryString.useUpdateUrlParam as jest.Mock; + const mockUseInitializeUrlParam = globalQueryString.useInitializeUrlParam as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + mockUseUpdateUrlParam.mockReturnValue(mockUpdateUrlParam); + mockUseInitializeUrlParam.mockImplementation((_key, cb) => cb); + mockUseSelector.mockImplementation((fn) => fn({})); + }); + + it('should not dispatch or update url param if newDataViewPickerEnabled is true', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + renderHook(() => useSyncSourcererUrlState(SourcererScopeName.default)); + // Simulate onInitializeUrlParam call + const onInitializeUrlParam = mockUseInitializeUrlParam.mock.calls[0][1]; + onInitializeUrlParam({ default: { id: 'test-id', selectedPatterns: ['a'] } }); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockUpdateUrlParam).not.toHaveBeenCalled(); + }); + + it('should dispatch setSelectedDataView if newDataViewPickerEnabled is false and initialState is provided', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + renderHook(() => useSyncSourcererUrlState(SourcererScopeName.default)); + const onInitializeUrlParam = mockUseInitializeUrlParam.mock.calls[0][1]; + onInitializeUrlParam({ default: { id: 'test-id', selectedPatterns: ['a'] } }); + expect(mockDispatch).toHaveBeenCalledWith({ + payload: { + id: 'default', + selectedDataViewId: 'test-id', + selectedPatterns: ['a'], + }, + type: 'x-pack/security_solution/local/sourcerer/SET_SELECTED_DATA_VIEW', + }); + }); + + it('should update url param if newDataViewPickerEnabled is false and initialState is null', () => { + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + mockUseSelector + .mockReturnValueOnce('test-id') // scopeDataViewId + .mockReturnValueOnce(['a']); // selectedPatterns + renderHook(() => useSyncSourcererUrlState(SourcererScopeName.default)); + const onInitializeUrlParam = mockUseInitializeUrlParam.mock.calls[0][1]; + onInitializeUrlParam(null); + expect(mockUpdateUrlParam).toHaveBeenCalledWith({ + default: { id: 'test-id', selectedPatterns: ['a'] }, + }); + }); +}); + +describe('useRestoreDataViewManagerStateFromURL', () => { + const mockUseIsExperimentalFeatureEnabled = + experimentalFeatures.useIsExperimentalFeatureEnabled as jest.Mock; + const mockUseInitializeUrlParam = globalQueryString.useInitializeUrlParam as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseInitializeUrlParam.mockImplementation((_key, cb) => cb); + }); + + it('should not call initDataViewSelection if newDataViewPickerEnabled is false', () => { + const initDataViewSelection = jest.fn(); + mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); + renderHook(() => + useRestoreDataViewManagerStateFromURL(initDataViewSelection, SourcererScopeName.default) + ); + const onInitializeUrlParam = mockUseInitializeUrlParam.mock.calls[0][1]; + onInitializeUrlParam({ default: { id: 'test-id', selectedPatterns: ['a'] } }); + expect(initDataViewSelection).not.toHaveBeenCalled(); + }); + + it('should call initDataViewSelection for each scope if newDataViewPickerEnabled is true', () => { + const initDataViewSelection = jest.fn(); + mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + renderHook(() => + useRestoreDataViewManagerStateFromURL(initDataViewSelection, SourcererScopeName.default) + ); + const onInitializeUrlParam = mockUseInitializeUrlParam.mock.calls[0][1]; + onInitializeUrlParam({ + default: { id: 'test-id', selectedPatterns: ['a'] }, + detections: { id: 'det-id', selectedPatterns: ['b'] }, + }); + expect(initDataViewSelection).toHaveBeenCalledWith([ + { fallbackPatterns: ['a'], id: 'test-id', scope: 'default' }, + { fallbackPatterns: ['b'], id: 'det-id', scope: 'detections' }, + ]); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_sync_url_state.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_sync_url_state.ts new file mode 100644 index 0000000000000..4ca16f8e57224 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_sync_url_state.ts @@ -0,0 +1,124 @@ +/* + * 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 { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { SourcererScopeName, type SourcererUrlState } from '../../sourcerer/store/model'; +import { useInitializeUrlParam, useUpdateUrlParam } from '../../common/utils/global_query_string'; +import { URL_PARAM_KEY } from '../../common/hooks/constants'; +import type { State } from '../../common/store/types'; +import { sourcererSelectors } from '../../common/store/selectors'; +import { sourcererActions } from '../../common/store/actions'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { type SelectDataViewAsyncPayload } from '../redux/actions'; + +// TODO: remove this in cleanup phase Remove deprecated sourcerer code https://github.com/elastic/security-team/issues/12665 +export const useSyncSourcererUrlState = ( + scopeId: SourcererScopeName.default | SourcererScopeName.detections = SourcererScopeName.default +) => { + const scopeDataViewId = useSelector((state: State) => { + return sourcererSelectors.sourcererScopeSelectedDataViewId(state, scopeId); + }); + const selectedPatterns = useSelector((state: State) => { + return sourcererSelectors.sourcererScopeSelectedPatterns(state, scopeId); + }); + + const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); + + const dispatch = useDispatch(); + + const updateUrlParam = useUpdateUrlParam(URL_PARAM_KEY.sourcerer); + + const onInitializeUrlParam = useCallback( + (initialState: SourcererUrlState | null) => { + // TODO: This is due to a new feature https://github.com/elastic/security-team/issues/11959 + // if new picker flag is enabled, we should not kick off the legacy url flow + if (newDataViewPickerEnabled) { + return; + } + + // Initialize the store with value from UrlParam. + if (initialState != null) { + (Object.keys(initialState) as SourcererScopeName[]).forEach((scope) => { + if ( + !(scope === SourcererScopeName.default && scopeId === SourcererScopeName.detections) + ) { + dispatch( + sourcererActions.setSelectedDataView({ + id: scope, + selectedDataViewId: initialState[scope]?.id ?? null, + selectedPatterns: initialState[scope]?.selectedPatterns ?? [], + }) + ); + } + }); + } else { + // Initialize the UrlParam with values from the store. + // It isn't strictly necessary but I am keeping it for compatibility with the previous implementation. + if (scopeDataViewId) { + updateUrlParam({ + [SourcererScopeName.default]: { + id: scopeDataViewId, + selectedPatterns, + }, + }); + } + } + }, + [dispatch, newDataViewPickerEnabled, scopeDataViewId, scopeId, selectedPatterns, updateUrlParam] + ); + + useInitializeUrlParam(URL_PARAM_KEY.sourcerer, onInitializeUrlParam); +}; + +/** + * Restores data view selection automatically if (and only if) the sourcerer url param is set during app init. (only during the initial render) + * See `useInitializeUrlParam` for details. + * The param itself is updated in the picker component, after user changes the selection manually. + */ +export const useRestoreDataViewManagerStateFromURL = ( + initDataViewPickerWithSelection: (initialSelection: SelectDataViewAsyncPayload[]) => void, + scopeId: SourcererScopeName.default | SourcererScopeName.detections = SourcererScopeName.default +) => { + const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); + + const onInitializeUrlParam = useCallback( + (initialState: SourcererUrlState | null) => { + // TODO: This is due to a new feature https://github.com/elastic/security-team/issues/11959 + // dont do anything if new picker is not enabled + if (!newDataViewPickerEnabled) { + return; + } + + if (initialState === null) { + return initDataViewPickerWithSelection([]); + } + + // Select data view for specific scope, based on the UrlParam. + const urlBasedSelection = (Object.keys(initialState) as SourcererScopeName[]) + .map((scope) => { + // NOTE: looks like this is about skipping the init when the active page is detections + // We should investigate this. + if (scope === SourcererScopeName.default && scopeId === SourcererScopeName.detections) { + return undefined; + } + + return { + scope, + id: initialState[scope]?.id, + fallbackPatterns: initialState[scope]?.selectedPatterns, + }; + }) + .filter(Boolean) as SelectDataViewAsyncPayload[]; + + initDataViewPickerWithSelection(urlBasedSelection); + }, + [initDataViewPickerWithSelection, newDataViewPickerEnabled, scopeId] + ); + + useInitializeUrlParam(URL_PARAM_KEY.sourcerer, onInitializeUrlParam); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/actions.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/actions.ts index 6b802468eb3a8..3c391155e53df 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/actions.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/actions.ts @@ -10,7 +10,7 @@ import { createAction } from '@reduxjs/toolkit'; import type { DataViewManagerScopeName } from '../constants'; import { SLICE_PREFIX } from '../constants'; -export const selectDataViewAsync = createAction<{ +export interface SelectDataViewAsyncPayload { id?: string | null; /** * Fallback patterns are used when the specific data view ID is undefined. This flow results in an ad-hoc data view creation @@ -19,5 +19,11 @@ export const selectDataViewAsync = createAction<{ /** * Specify one or more security solution scopes where the data view selection should be applied */ - scope: DataViewManagerScopeName[]; -}>(`${SLICE_PREFIX}/selectDataView`); + scope: DataViewManagerScopeName; +} + +// NOTE: You should not need to dispatch any actions by yourself. Instead, use one of the hooks that we built to faciliate data view selection and retrieval based on current scope. + +export const selectDataViewAsync = createAction( + `${SLICE_PREFIX}/selectDataView` +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.test.ts index 9371597f0bb7c..58c8085eb3127 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.test.ts @@ -75,6 +75,8 @@ const mockGetState = jest.fn(() => mockedState); const mockListenerApi = { dispatch: mockDispatch, getState: mockGetState, + cancelActiveListeners: jest.fn(), + signal: { aborted: false }, } as unknown as ListenerEffectAPI>; describe('createDataViewSelectedListener', () => { @@ -82,12 +84,24 @@ describe('createDataViewSelectedListener', () => { beforeEach(() => { jest.clearAllMocks(); - listener = createDataViewSelectedListener({ dataViews: mockDataViewsService }); + listener = createDataViewSelectedListener({ + dataViews: mockDataViewsService, + scope: DataViewManagerScopeName.default, + }); + }); + + it('should cancel previous effects that would set the data view for given scope', async () => { + await listener.effect( + selectDataViewAsync({ id: 'adhoc_test-*', scope: DataViewManagerScopeName.default }), + mockListenerApi + ); + + expect(mockListenerApi.cancelActiveListeners).toHaveBeenCalled(); }); it('should return cached adhoc data view first', async () => { await listener.effect( - selectDataViewAsync({ id: 'adhoc_test-*', scope: [DataViewManagerScopeName.default] }), + selectDataViewAsync({ id: 'adhoc_test-*', scope: DataViewManagerScopeName.default }), mockListenerApi ); @@ -99,7 +113,7 @@ describe('createDataViewSelectedListener', () => { selectDataViewAsync({ id: 'fetched-id', fallbackPatterns: ['test-*'], - scope: [DataViewManagerScopeName.default], + scope: DataViewManagerScopeName.default, }), mockListenerApi ); @@ -126,7 +140,7 @@ describe('createDataViewSelectedListener', () => { await listener.effect( selectDataViewAsync({ fallbackPatterns: ['test-*'], - scope: [DataViewManagerScopeName.default], + scope: DataViewManagerScopeName.default, }), mockListenerApi ); @@ -156,7 +170,7 @@ describe('createDataViewSelectedListener', () => { await listener.effect( selectDataViewAsync({ fallbackPatterns: ['test-*'], - scope: [DataViewManagerScopeName.default], + scope: DataViewManagerScopeName.default, }), mockListenerApi ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.ts index 1ab474edfde0d..4a208f37ddb3a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.ts @@ -12,8 +12,10 @@ import type { RootState } from '../reducer'; import { scopes } from '../reducer'; import { selectDataViewAsync } from '../actions'; import { sharedDataViewManagerSlice } from '../slices'; +import type { DataViewManagerScopeName } from '../../constants'; export const createDataViewSelectedListener = (dependencies: { + scope: DataViewManagerScopeName; dataViews: DataViewsServicePublic; }) => { return { @@ -22,6 +24,13 @@ export const createDataViewSelectedListener = (dependencies: { action: ReturnType, listenerApi: ListenerEffectAPI> ) => { + if (dependencies.scope !== action.payload.scope) { + return; + } + + // Cancel effects running for the current scope to prevent race conditions + listenerApi.cancelActiveListeners(); + let dataViewByIdError: unknown; let adhocDataViewCreationError: unknown; let dataViewById: DataViewLazy | null = null; @@ -89,19 +98,23 @@ export const createDataViewSelectedListener = (dependencies: { const resolvedIdToUse = cachedDataViewSpec?.id || dataViewById?.id || adHocDataView?.id; - action.payload.scope.forEach((scope) => { - const currentScopeActions = scopes[scope].actions; - if (resolvedIdToUse && resolvedIdToUse) { - listenerApi.dispatch(currentScopeActions.setSelectedDataView(resolvedIdToUse)); - } else if (dataViewByIdError || adhocDataViewCreationError) { - const err = dataViewByIdError || adhocDataViewCreationError; - listenerApi.dispatch( - currentScopeActions.dataViewSelectionError( - `An error occured when setting data view: ${err}` - ) - ); + const currentScopeActions = scopes[action.payload.scope].actions; + if (resolvedIdToUse) { + // NOTE: this skips data view selection if an override selection + // has been dispatched + if (listenerApi.signal.aborted) { + return; } - }); + + listenerApi.dispatch(currentScopeActions.setSelectedDataView(resolvedIdToUse)); + } else if (dataViewByIdError || adhocDataViewCreationError) { + const err = dataViewByIdError || adhocDataViewCreationError; + listenerApi.dispatch( + currentScopeActions.dataViewSelectionError( + `An error occured when setting data view: ${err}` + ) + ); + } }, }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.test.ts index efdcfbed30ca9..9ed3ea7394b08 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.test.ts @@ -41,7 +41,7 @@ describe('createInitListener', () => { }); it('should load the data views and dispatch further actions', async () => { - await listener.effect(sharedDataViewManagerSlice.actions.init(), mockListenerApi); + await listener.effect(sharedDataViewManagerSlice.actions.init([]), mockListenerApi); expect(jest.mocked(mockDataViewsService.getAllDataViewLazy)).toHaveBeenCalled(); @@ -51,12 +51,25 @@ describe('createInitListener', () => { expect(jest.mocked(mockListenerApi.dispatch)).toBeCalledWith( selectDataViewAsync({ id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, - scope: [ - DataViewManagerScopeName.default, - DataViewManagerScopeName.detections, - DataViewManagerScopeName.timeline, - DataViewManagerScopeName.analyzer, - ], + scope: DataViewManagerScopeName.default, + }) + ); + expect(jest.mocked(mockListenerApi.dispatch)).toBeCalledWith( + selectDataViewAsync({ + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + scope: DataViewManagerScopeName.timeline, + }) + ); + expect(jest.mocked(mockListenerApi.dispatch)).toBeCalledWith( + selectDataViewAsync({ + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + scope: DataViewManagerScopeName.detections, + }) + ); + expect(jest.mocked(mockListenerApi.dispatch)).toBeCalledWith( + selectDataViewAsync({ + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + scope: DataViewManagerScopeName.detections, }) ); }); @@ -69,7 +82,7 @@ describe('createInitListener', () => { }); it('should dispatch error correctly', async () => { - await listener.effect(sharedDataViewManagerSlice.actions.init(), mockListenerApi); + await listener.effect(sharedDataViewManagerSlice.actions.init([]), mockListenerApi); expect(jest.mocked(mockListenerApi.dispatch)).toBeCalledWith( sharedDataViewManagerSlice.actions.error() diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.ts index 7e460cb47b36b..e19ad6e6f37c6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.ts @@ -16,7 +16,7 @@ export const createInitListener = (dependencies: { dataViews: DataViewsServicePu return { actionCreator: sharedDataViewManagerSlice.actions.init, effect: async ( - _action: AnyAction, + action: ReturnType, listenerApi: ListenerEffectAPI> ) => { try { @@ -26,19 +26,27 @@ export const createInitListener = (dependencies: { dataViews: DataViewsServicePu listenerApi.dispatch(sharedDataViewManagerSlice.actions.setDataViews(dataViewSpecs)); - // Preload the default data view for related scopes - // NOTE: we will remove this ideally and load only when particular dataview is necessary - listenerApi.dispatch( - selectDataViewAsync({ - id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, - scope: [ - DataViewManagerScopeName.default, - DataViewManagerScopeName.detections, - DataViewManagerScopeName.timeline, - DataViewManagerScopeName.analyzer, - ], - }) - ); + // Preload the default data view for all the scopes + // Immediate calls that would dispatch this call from other places will cancel this action, + // preventing race conditions + [ + DataViewManagerScopeName.detections, + DataViewManagerScopeName.analyzer, + DataViewManagerScopeName.timeline, + DataViewManagerScopeName.default, + ].forEach((scope) => { + listenerApi.dispatch( + selectDataViewAsync({ + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + scope, + }) + ); + }); + + // NOTE: if there is a list of data views to preload other than default one (eg. coming in from the url storage) + action.payload.forEach((defaultSelection) => { + listenerApi.dispatch(selectDataViewAsync(defaultSelection)); + }); } catch (error: unknown) { listenerApi.dispatch(sharedDataViewManagerSlice.actions.error()); } diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/slices.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/slices.test.ts index f5cc829b23a27..bd1bb5307dec4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/slices.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/slices.test.ts @@ -100,7 +100,7 @@ describe('slices', () => { describe('state transitions', () => { it('should set status to loading when init is called', () => { - const state = reducer(initialSharedState, actions.init()); + const state = reducer(initialSharedState, actions.init([])); expect(state.status).toBe('loading'); }); @@ -142,7 +142,7 @@ describe('slices', () => { initialScopeState, selectDataViewAsync({ id: '1', - scope: [testScope], + scope: testScope, }) ); @@ -154,7 +154,7 @@ describe('slices', () => { initialScopeState, selectDataViewAsync({ id: '1', - scope: [DataViewManagerScopeName.analyzer], + scope: DataViewManagerScopeName.analyzer, }) ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/slices.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/slices.ts index a83df00443be6..e71707cec7e50 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/slices.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/slices.ts @@ -11,7 +11,7 @@ import type { DataViewSpec, DataView } from '@kbn/data-views-plugin/common'; import type { DataViewManagerScopeName } from '../constants'; import { SLICE_PREFIX } from '../constants'; import type { ScopedDataViewSelectionState, SharedDataViewSelectionState } from './types'; -import { selectDataViewAsync } from './actions'; +import { selectDataViewAsync, type SelectDataViewAsyncPayload } from './actions'; export const initialScopeState: ScopedDataViewSelectionState = { dataViewId: null, @@ -49,7 +49,7 @@ export const sharedDataViewManagerSlice = createSlice({ state.adhocDataViews.push(dataViewSpec); } }, - init: (state) => { + init: (state, _: PayloadAction) => { state.status = 'loading'; }, error: (state) => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/sourcerer/containers/use_init_sourcerer.tsx b/x-pack/solutions/security/plugins/security_solution/public/sourcerer/containers/use_init_sourcerer.tsx index 7092c4a985943..5903d8a140d6f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/sourcerer/containers/use_init_sourcerer.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/sourcerer/containers/use_init_sourcerer.tsx @@ -9,7 +9,6 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { sourcererActions, sourcererSelectors } from '../store'; -import type { SourcererUrlState } from '../store/model'; import { SourcererScopeName } from '../store/model'; import { useUserInfo } from '../../detections/components/user_info'; import { timelineSelectors } from '../../timelines/store'; @@ -20,10 +19,9 @@ import { useAppToasts } from '../../common/hooks/use_app_toasts'; import { createSourcererDataView } from './create_sourcerer_data_view'; import { useDataView } from '../../common/containers/source/use_data_view'; import type { State } from '../../common/store/types'; -import { useInitializeUrlParam, useUpdateUrlParam } from '../../common/utils/global_query_string'; -import { URL_PARAM_KEY } from '../../common/hooks/use_url_state'; import { useKibana } from '../../common/lib/kibana'; import { useSourcererDataView } from '.'; +import { useSyncSourcererUrlState } from '../../data_view_manager/hooks/use_sync_url_state'; export const useInitSourcerer = ( scopeId: SourcererScopeName.default | SourcererScopeName.detections = SourcererScopeName.default @@ -36,7 +34,8 @@ export const useInitSourcerer = ( const initialTimelineSourcerer = useRef(true); const initialDetectionSourcerer = useRef(true); const { loading: loadingSignalIndex, isSignalIndexExists, signalIndexName } = useUserInfo(); - const updateUrlParam = useUpdateUrlParam(URL_PARAM_KEY.sourcerer); + + useSyncSourcererUrlState(scopeId); const signalIndexNameSourcerer = useSelector(sourcererSelectors.signalIndexName); const defaultDataView = useSelector(sourcererSelectors.defaultDataView); @@ -87,41 +86,6 @@ export const useInitSourcerer = ( const { indexFieldsSearch } = useDataView(); - const onInitializeUrlParam = useCallback( - (initialState: SourcererUrlState | null) => { - // Initialize the store with value from UrlParam. - if (initialState != null) { - (Object.keys(initialState) as SourcererScopeName[]).forEach((scope) => { - if ( - !(scope === SourcererScopeName.default && scopeId === SourcererScopeName.detections) - ) { - dispatch( - sourcererActions.setSelectedDataView({ - id: scope, - selectedDataViewId: initialState[scope]?.id ?? null, - selectedPatterns: initialState[scope]?.selectedPatterns ?? [], - }) - ); - } - }); - } else { - // Initialize the UrlParam with values from the store. - // It isn't strictly necessary but I am keeping it for compatibility with the previous implementation. - if (scopeDataViewId) { - updateUrlParam({ - [SourcererScopeName.default]: { - id: scopeDataViewId, - selectedPatterns, - }, - }); - } - } - }, - [dispatch, scopeDataViewId, scopeId, selectedPatterns, updateUrlParam] - ); - - useInitializeUrlParam(URL_PARAM_KEY.sourcerer, onInitializeUrlParam); - /* * Note for future engineer: * we changed the logic to not fetch all the index fields for every data view on the loading of the app diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx index 7b6b1f62a0689..1bc33046044bd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx @@ -69,7 +69,7 @@ export const useUpdateTimeline = () => { selectDataView({ id: _timeline.dataViewId, fallbackPatterns: _timeline.indexNames, - scope: [DataViewManagerScopeName.timeline], + scope: DataViewManagerScopeName.timeline, }); } diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx index 21f5f89123db4..653c3943efd85 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx @@ -99,7 +99,7 @@ export const useCreateTimeline = ({ setSelectedDataView({ id: dataViewId, fallbackPatterns: selectedPatterns, - scope: [DataViewManagerScopeName.timeline], + scope: DataViewManagerScopeName.timeline, }); dispatch(