diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/containers/SafeDataViewProvider.tsx b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/containers/SafeDataViewProvider.tsx new file mode 100644 index 0000000000000..a1a7a37b6a62f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/containers/SafeDataViewProvider.tsx @@ -0,0 +1,70 @@ +/* + * 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, { + createContext, + type ReactNode, + type FC, + type PropsWithChildren, + useMemo, +} from 'react'; + +import { type DataViewManagerScopeName } from '../constants'; +import { useDataView, type UseDataViewAsyncReturnValue } from '../hooks/use_data_view'; + +export interface DataViewContextValue { + readonly results: Record; +} + +export const DataViewContext = createContext(undefined); + +export interface DataViewProviderProps { + /** + * Specify scopes that are required by the wrapped component + * Only these scopes will be available through useDataViewSafe. + */ + scopes: readonly DataViewManagerScopeName[]; + /** + * Optional fallback component + */ + fallback?: ReactNode; +} + +const fallbackElement =
{`WIP Loading`}
; + +/** + * Data view provider. We call it safe, because obtaining data view instance (for specified scopes) inside of it + * does not require addtional checks for nullish value or loading state. + */ +export const DataViewProvider: FC> = ({ + children, + scopes, + fallback = fallbackElement, +}) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const results = scopes.map((scope) => useDataView(scope, false)); + const allReady = Object.values(results).every((result) => result.status === 'ready'); + const dataViews = Object.values(results).map((result) => result.dataView); + + const value = useMemo(() => { + return results.reduce( + (acc, result) => { + acc.results[result.scope] = result; + return acc; + }, + { results: {} } as DataViewContextValue + ); + // NOTE: below is done on purpose to cache based on the data view instance + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...dataViews]); + + return ( + + {allReady ? children : fallback} + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_data_view.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_data_view.ts index 92b01544b2504..95fcd15dd3ee1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_data_view.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_data_view.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useEffect, useMemo, useState } from 'react'; +import { useContext, useEffect, useMemo, useState } from 'react'; import { type DataView } from '@kbn/data-views-plugin/public'; import { useSelector } from 'react-redux'; @@ -14,14 +14,32 @@ import { DataViewManagerScopeName } from '../constants'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; import { sourcererAdapterSelector } from '../redux/selectors'; import type { SharedDataViewSelectionState } from '../redux/types'; +import { DataViewContext } from '../containers/SafeDataViewProvider'; + +export interface UseDataViewAsyncReturnValue { + dataView: DataView | undefined; + status: SharedDataViewSelectionState['status']; + scope: DataViewManagerScopeName; +} + +type ConditionalReturn = IsSync extends true + ? DataView + : UseDataViewAsyncReturnValue; + +export { type DataView }; /* * This hook should be used whenever we need the actual DataView and not just the spec for the * selected data view. */ -export const useDataView = ( - dataViewManagerScope: DataViewManagerScopeName = DataViewManagerScopeName.default -): { dataView: DataView | undefined; status: SharedDataViewSelectionState['status'] } => { +export const useDataView = ( + dataViewManagerScope: DataViewManagerScopeName = DataViewManagerScopeName.default, + /** + * If isSync is set to true, return value is guaranteed to be a data view instance, + * given there is a SafeDataViewProvider on top level for the current component tree + */ + isSync?: IsSync +): ConditionalReturn => { const { services: { dataViews }, notifications, @@ -35,6 +53,11 @@ export const useDataView = ( useEffect(() => { (async () => { + // NOTE: in sync mode, we are not firing useEffect again and reuse the top level data view + if (isSync) { + return; + } + if (!dataViewId || internalStatus !== 'ready') { return setRetrievedDataView(undefined); } @@ -54,13 +77,60 @@ export const useDataView = ( }); } })(); - }, [dataViews, dataViewId, internalStatus, notifications]); + }, [dataViews, dataViewId, internalStatus, notifications, isSync]); + + // TODO: naming, maybe extract this into separate function or hook + const syncDataViewsContext = useContext(DataViewContext); return useMemo(() => { + if (!isSync) { + // TODO: remove this with when compatibility flag is no longer needed. This is for compatibility reasons only + if (!newDataViewPickerEnabled) { + return { + dataView: undefined, + status: internalStatus, + scope: dataViewManagerScope, + } as ConditionalReturn; + } + + return { + dataView: retrievedDataView, + status: retrievedDataView ? internalStatus : 'loading', + scope: dataViewManagerScope, + } as ConditionalReturn; + } + if (!newDataViewPickerEnabled) { - return { dataView: undefined, status: internalStatus }; + // TODO: remove this with when compatibility flag is no longer needed. This is for compatibility reasons only + return {} as ConditionalReturn; + } + + if (!syncDataViewsContext) { + throw new Error('You can only use useDataViewSafe inside DataViewProvider'); + } + + // NOTE: check if requested scope is present in the preloaded data views + if (!(dataViewManagerScope in syncDataViewsContext.results)) { + throw new Error( + 'No safeguards exist for requested scope, make sure it is included in `scopes` property of the wrapping DataViewProvider' + ); + } + + const dataView = syncDataViewsContext.results[dataViewManagerScope].dataView; + + if (!dataView) { + throw new Error( + 'Missing data view. This error should not occur (earlier conditions should fire or the fallback should be rendered instead in the provider)' + ); } - return { dataView: retrievedDataView, status: retrievedDataView ? internalStatus : 'loading' }; - }, [newDataViewPickerEnabled, retrievedDataView, internalStatus]); + return dataView as ConditionalReturn; + }, [ + isSync, + newDataViewPickerEnabled, + syncDataViewsContext, + dataViewManagerScope, + retrievedDataView, + internalStatus, + ]); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.tsx index a52aa638359e2..33937765f3cd8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -21,19 +21,19 @@ import { SecurityRoutePageWrapper } from '../../common/components/security_route import { DataViewManagerScopeName } from '../../data_view_manager/constants'; import { useSourcererDataView } from '../../sourcerer/containers'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { DataViewProvider } from '../../data_view_manager/containers/SafeDataViewProvider'; import { useDataView } from '../../data_view_manager/hooks/use_data_view'; export const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; -export const TimelinesPage = React.memo(() => { +const TimelinesPageContent = () => { const { tabName } = useParams<{ pageName: SecurityPageName; tabName: string }>(); const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); const { indicesExist: oldIndicesExist } = useSourcererDataView(); - const { dataView } = useDataView(DataViewManagerScopeName.default); - // NOTE: there should be a Suspense / some kind of loader here as this value is not settled immediately - const experimentalIndicesExist = !!dataView?.matchedIndices?.length; + const dataView = useDataView(DataViewManagerScopeName.default, true); + const experimentalIndicesExist = !!dataView.matchedIndices.length; const indicesExist = newDataViewPickerEnabled ? experimentalIndicesExist : oldIndicesExist; @@ -49,42 +49,51 @@ export const TimelinesPage = React.memo(() => { const timelineType = tabName === TimelineTypeEnum.default ? TimelineTypeEnum.default : TimelineTypeEnum.template; - return ( - - {indicesExist ? ( - - - - {canWriteTimeline && ( - - - {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} - - - )} + return indicesExist ? ( + + + + {canWriteTimeline && ( + + + {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} + + + )} + + + + + + - - - - - + + + ) : ( + + ); +}; - - - ) : ( - - )} +export const TimelinesPage = React.memo(() => { + return ( + + + + ); });