[Security Solution] [Sourcerer] Make use of reselect in sourcerer selectors (#176916)

## Summary
This pr should change nothing functionally, but changes the selectors
used in components for sourcerer to make use of createSelector and
benefit from memoization at all times,
This commit is contained in:
Kevin Qualters 2024-02-22 20:56:52 -05:00 committed by GitHub
parent b207ff4534
commit 7745d36703
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 633 additions and 587 deletions

View file

@ -5,13 +5,12 @@
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import { EuiButton, EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useAssistantContext } from '@kbn/elastic-assistant';
import { useDeepEqualSelector } from '../../common/hooks/use_selector';
import { sourcererSelectors } from '../../common/store';
import { sourcererActions } from '../../common/store/actions';
import { inputsActions } from '../../common/store/inputs';
@ -63,13 +62,8 @@ export const SendToTimelineButton: React.FunctionComponent<SendToTimelineButtonP
const isEsqlTabInTimelineDisabled = useIsExperimentalFeatureEnabled('timelineEsqlTabDisabled');
const getDataViewsSelector = useMemo(
() => sourcererSelectors.getSourcererDataViewsSelector(),
[]
);
const { defaultDataView, signalIndexName } = useDeepEqualSelector((state) =>
getDataViewsSelector(state)
);
const signalIndexName = useSelector(sourcererSelectors.signalIndexName);
const defaultDataView = useSelector(sourcererSelectors.defaultDataView);
const hasTemplateProviders =
dataProviders && dataProviders.find((provider) => provider.type === 'template');

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import React, { useMemo, useCallback } from 'react';
import React, { useCallback } from 'react';
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import type { IconType } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { sourcererSelectors } from '../../../store';
import { InputsModelId } from '../../../store/inputs/constants';
@ -23,7 +23,6 @@ import { TimelineId } from '../../../../../common/types/timeline';
import { TimelineType } from '../../../../../common/api/timeline';
import { useCreateTimeline } from '../../../../timelines/hooks/use_create_timeline';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
import { useDeepEqualSelector } from '../../../hooks/use_selector';
export interface InvestigateInTimelineButtonProps {
asEmptyButton: boolean;
@ -49,13 +48,8 @@ export const InvestigateInTimelineButton: React.FunctionComponent<
}) => {
const dispatch = useDispatch();
const getDataViewsSelector = useMemo(
() => sourcererSelectors.getSourcererDataViewsSelector(),
[]
);
const { defaultDataView, signalIndexName } = useDeepEqualSelector((state) =>
getDataViewsSelector(state)
);
const signalIndexName = useSelector(sourcererSelectors.signalIndexName);
const defaultDataView = useSelector(sourcererSelectors.defaultDataView);
const hasTemplateProviders =
dataProviders && dataProviders.find((provider) => provider.type === 'template');

View file

@ -15,13 +15,13 @@ import {
} from '@elastic/eui';
import type { ChangeEventHandler } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import * as i18n from './translations';
import type { sourcererModel } from '../../store/sourcerer';
import { sourcererActions, sourcererSelectors } from '../../store/sourcerer';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import type { SourcererUrlState } from '../../store/sourcerer/model';
import type { State } from '../../store';
import type { ModifiedTypes } from './use_pick_index_patterns';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { usePickIndexPatterns } from './use_pick_index_patterns';
@ -129,17 +129,18 @@ export const Sourcerer = React.memo<SourcererComponentProps>(({ scope: scopeId }
const isDefaultSourcerer = scopeId === SourcererScopeName.default;
const updateUrlParam = useUpdateUrlParam<SourcererUrlState>(URL_PARAM_KEY.sourcerer);
const sourcererScopeSelector = useMemo(() => sourcererSelectors.getSourcererScopeSelector(), []);
const {
defaultDataView,
kibanaDataViews,
signalIndexName,
sourcererScope: {
selectedDataViewId,
selectedPatterns,
missingPatterns: sourcererMissingPatterns,
},
} = useDeepEqualSelector((state) => sourcererScopeSelector(state, scopeId));
const signalIndexName = useSelector(sourcererSelectors.signalIndexName);
const defaultDataView = useSelector(sourcererSelectors.defaultDataView);
const kibanaDataViews = useSelector(sourcererSelectors.kibanaDataViews);
const selectedDataViewId = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedDataViewId(state, scopeId);
});
const selectedPatterns = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedPatterns(state, scopeId);
});
const sourcererMissingPatterns = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeMissingPatterns(state, scopeId);
});
const { pollForSignalIndex } = useSignalHelpers();
useEffect(() => {

View file

@ -6,7 +6,7 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { i18n } from '@kbn/i18n';
import { matchPath } from 'react-router-dom';
import { sourcererActions, sourcererSelectors } from '../../store/sourcerer';
@ -36,6 +36,7 @@ import { useAppToasts } from '../../hooks/use_app_toasts';
import { createSourcererDataView } from './create_sourcerer_data_view';
import { getDataViewStateFromIndexFields, useDataView } from '../source/use_data_view';
import { useFetchIndex } from '../source';
import type { State } from '../../store';
import { useInitializeUrlParam, useUpdateUrlParam } from '../../utils/global_query_string';
import { URL_PARAM_KEY } from '../../hooks/use_url_state';
import { sortWithExcludesAtEnd } from '../../../../common/utils/sourcerer';
@ -54,14 +55,8 @@ export const useInitSourcerer = (
const { loading: loadingSignalIndex, isSignalIndexExists, signalIndexName } = useUserInfo();
const updateUrlParam = useUpdateUrlParam<SourcererUrlState>(URL_PARAM_KEY.sourcerer);
const getDataViewsSelector = useMemo(
() => sourcererSelectors.getSourcererDataViewsSelector(),
[]
);
const { defaultDataView, signalIndexName: signalIndexNameSourcerer } = useDeepEqualSelector(
(state) => getDataViewsSelector(state)
);
const signalIndexNameSourcerer = useSelector(sourcererSelectors.signalIndexName);
const defaultDataView = useSelector(sourcererSelectors.defaultDataView);
const { addError, addWarning } = useAppToasts();
useEffect(() => {
@ -83,19 +78,29 @@ export const useInitSourcerer = (
getTimelineSelector(state, TimelineId.active)
);
const sourcererScopeSelector = useMemo(() => sourcererSelectors.getSourcererScopeSelector(), []);
const {
sourcererScope: { selectedDataViewId: scopeDataViewId, selectedPatterns, missingPatterns },
} = useDeepEqualSelector((state) => sourcererScopeSelector(state, scopeId));
const scopeDataViewId = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedDataViewId(state, scopeId);
});
const selectedPatterns = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedPatterns(state, scopeId);
});
const missingPatterns = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeMissingPatterns(state, scopeId);
});
const {
selectedDataView: timelineSelectedDataView,
sourcererScope: {
selectedDataViewId: timelineDataViewId,
selectedPatterns: timelineSelectedPatterns,
missingPatterns: timelineMissingPatterns,
},
} = useDeepEqualSelector((state) => sourcererScopeSelector(state, SourcererScopeName.timeline));
const kibanaDataViews = useSelector(sourcererSelectors.kibanaDataViews);
const timelineDataViewId = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedDataViewId(state, SourcererScopeName.timeline);
});
const timelineSelectedPatterns = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedPatterns(state, SourcererScopeName.timeline);
});
const timelineMissingPatterns = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeMissingPatterns(state, SourcererScopeName.timeline);
});
const timelineSelectedDataView = useMemo(() => {
return kibanaDataViews.find((dataView) => dataView.id === timelineDataViewId);
}, [kibanaDataViews, timelineDataViewId]);
const { indexFieldsSearch } = useDataView();
@ -387,26 +392,23 @@ export const useInitSourcerer = (
export const useSourcererDataView = (
scopeId: SourcererScopeName = SourcererScopeName.default
): SelectedDataView => {
const { getDataViewsSelector, getSourcererDataViewSelector, getScopeSelector } = useMemo(
() => ({
getDataViewsSelector: sourcererSelectors.getSourcererDataViewsSelector(),
getSourcererDataViewSelector: sourcererSelectors.sourcererDataViewSelector(),
getScopeSelector: sourcererSelectors.scopeIdSelector(),
}),
[]
);
const {
defaultDataView,
signalIndexName,
selectedDataView,
sourcererScope: { missingPatterns, selectedPatterns: scopeSelectedPatterns, loading },
}: sourcererSelectors.SourcererScopeSelector = useDeepEqualSelector((state) => {
const sourcererScope = getScopeSelector(state, scopeId);
return {
...getDataViewsSelector(state),
selectedDataView: getSourcererDataViewSelector(state, sourcererScope.selectedDataViewId),
sourcererScope,
};
const kibanaDataViews = useSelector(sourcererSelectors.kibanaDataViews);
const signalIndexName = useSelector(sourcererSelectors.signalIndexName);
const defaultDataView = useSelector(sourcererSelectors.defaultDataView);
const selectedDataViewId = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedDataViewId(state, scopeId);
});
const selectedDataView = useMemo(() => {
return kibanaDataViews.find((dataView) => dataView.id === selectedDataViewId);
}, [kibanaDataViews, selectedDataViewId]);
const loading = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeIsLoading(state, scopeId);
});
const scopeSelectedPatterns = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedPatterns(state, scopeId);
});
const missingPatterns = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeMissingPatterns(state, scopeId);
});
const selectedPatterns = useMemo(

View file

@ -7,9 +7,8 @@
import { useCallback, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { sourcererSelectors } from '../../store';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { useSourcererDataView } from '.';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { useDataView } from '../source/use_data_view';
@ -33,17 +32,8 @@ export const useSignalHelpers = (): {
data: { dataViews },
} = useKibana().services;
const getDefaultDataViewSelector = useMemo(
() => sourcererSelectors.defaultDataViewSelector(),
[]
);
const getSignalIndexNameSelector = useMemo(
() => sourcererSelectors.signalIndexNameSelector(),
[]
);
const signalIndexNameSourcerer = useDeepEqualSelector(getSignalIndexNameSelector);
const defaultDataView = useDeepEqualSelector(getDefaultDataViewSelector);
const signalIndexNameSourcerer = useSelector(sourcererSelectors.signalIndexName);
const defaultDataView = useSelector(sourcererSelectors.defaultDataView);
const signalIndexNeedsInit = useMemo(
() => !defaultDataView.title.includes(`${signalIndexNameSourcerer}`),
[defaultDataView.title, signalIndexNameSourcerer]

View file

@ -5,17 +5,21 @@
* 2.0.
*/
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import type { SourcererScopeName } from '../store/sourcerer/model';
import { getSelectedDataviewSelector } from '../store/sourcerer/selectors';
import { useDeepEqualSelector } from './use_selector';
// Calls it from the module scope due to non memoized selectors https://github.com/elastic/kibana/issues/159315
const selectedDataviewSelector = getSelectedDataviewSelector();
import { sourcererSelectors } from '../store/sourcerer';
import type { State } from '../store';
export const useGetFieldSpec = (scopeId: SourcererScopeName) => {
const dataView = useDeepEqualSelector((state) => selectedDataviewSelector(state, scopeId));
const kibanaDataViews = useSelector(sourcererSelectors.kibanaDataViews);
const selectedDataViewId = useSelector((state: State) =>
sourcererSelectors.sourcererScopeSelectedDataViewId(state, scopeId)
);
const dataView = useMemo(
() => kibanaDataViews.find((dv) => dv.id === selectedDataViewId),
[kibanaDataViews, selectedDataViewId]
);
return useCallback(
(fieldName: string) => {
const fields = dataView?.fields;

View file

@ -4,23 +4,39 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { parseExperimentalConfigValue } from '../../../common/experimental_features';
import type { SecuritySubPlugins } from '../../app/types';
import { createInitialState } from './reducer';
import { mockIndexPattern, mockSourcererState } from '../mock';
import { mockIndexPattern, mockSourcererState, TestProviders, createMockStore } from '../mock';
import { useSourcererDataView } from '../containers/sourcerer';
import { useDeepEqualSelector } from '../hooks/use_selector';
import { renderHook } from '@testing-library/react-hooks';
import { initialGroupingState } from './grouping/reducer';
import { initialAnalyzerState } from '../../resolver/store/helpers';
jest.mock('../hooks/use_selector');
jest.mock('../lib/kibana', () => ({
KibanaServices: {
get: jest.fn(() => ({ uiSettings: { get: () => ({ from: 'now-24h', to: 'now' }) } })),
},
}));
jest.mock('../lib/kibana', () => {
const original = jest.requireActual('../lib/kibana');
return {
...original,
useKibana: () => ({
...original.useKibana(),
services: {
...original.useKibana().services,
upselling: {
...original.useKibana().services.upselling,
featureUsage: {
...original.useKibana().services.upselling.featureUsage,
hasShown: jest.fn(),
},
},
},
}),
KibanaServices: {
get: jest.fn(() => ({ uiSettings: { get: () => ({ from: 'now-24h', to: 'now' }) } })),
},
};
});
jest.mock('../containers/source', () => ({
useFetchIndex: () => [
false,
@ -28,6 +44,7 @@ jest.mock('../containers/source', () => ({
],
}));
// TODO: this is more of a hook test, a reducer is a pure function and should not need hooks and context to test.
describe('createInitialState', () => {
describe('sourcerer -> default -> indicesExist', () => {
const mockPluginState = {} as Omit<
@ -53,15 +70,13 @@ describe('createInitialState', () => {
analyzer: initialAnalyzerState,
}
);
beforeEach(() => {
(useDeepEqualSelector as jest.Mock).mockImplementation((cb) => cb(initState));
});
afterEach(() => {
(useDeepEqualSelector as jest.Mock).mockClear();
});
test('indicesExist should be TRUE if patternList is NOT empty', async () => {
const { result } = renderHook(() => useSourcererDataView());
const { result } = renderHook(() => useSourcererDataView(), {
wrapper: ({ children }) => (
<TestProviders store={createMockStore(initState)}>{children}</TestProviders>
),
});
expect(result.current.indicesExist).toEqual(true);
});
@ -93,8 +108,11 @@ describe('createInitialState', () => {
analyzer: initialAnalyzerState,
}
);
(useDeepEqualSelector as jest.Mock).mockImplementation((cb) => cb(state));
const { result } = renderHook(() => useSourcererDataView());
const { result } = renderHook(() => useSourcererDataView(), {
wrapper: ({ children }) => (
<TestProviders store={createMockStore(state)}>{children}</TestProviders>
),
});
expect(result.current.indicesExist).toEqual(false);
});
});

View file

@ -4,108 +4,93 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createSelector } from 'reselect';
import type { DataViewSpec } from '@kbn/data-views-plugin/common';
import type { State } from '../types';
import type {
SourcererDataView,
SourcererModel,
SourcererScope,
SourcererScopeName,
} from './model';
export const sourcererKibanaDataViewsSelector = ({
sourcerer,
}: State): SourcererModel['kibanaDataViews'] => sourcerer.kibanaDataViews;
import type { SourcererModel } from './model';
import { SourcererScopeName } from './model';
export const sourcererSignalIndexNameSelector = ({ sourcerer }: State): string | null =>
sourcerer.signalIndexName;
const SOURCERER_SCOPE_MAX_SIZE = Object.keys(SourcererScopeName).length;
export const sourcererDefaultDataViewSelector = ({
sourcerer,
}: State): SourcererModel['defaultDataView'] => sourcerer.defaultDataView;
const selectSourcerer = (state: State): SourcererModel => state.sourcerer;
export const dataViewSelector = (
{ sourcerer }: State,
id: string | null
): SourcererDataView | undefined =>
sourcerer.kibanaDataViews.find((dataView) => dataView.id === id);
export const sourcererScopes = createSelector(
selectSourcerer,
(sourcerer) => sourcerer.sourcererScopes
);
export const sourcererScopeIdSelector = (
{ sourcerer }: State,
scopeId: SourcererScopeName
): SourcererScope => sourcerer.sourcererScopes[scopeId];
export const sourcererScope = createSelector(
sourcererScopes,
(state: State, scopeId: SourcererScopeName) => scopeId,
(scopes, scopeId) => scopes[scopeId],
{
memoizeOptions: {
maxSize: SOURCERER_SCOPE_MAX_SIZE,
},
}
);
export const scopeIdSelector = () => createSelector(sourcererScopeIdSelector, (scope) => scope);
export const sourcererScopeIsLoading = createSelector(sourcererScope, (scope) => scope.loading, {
memoizeOptions: {
maxSize: SOURCERER_SCOPE_MAX_SIZE,
},
});
export const kibanaDataViewsSelector = () =>
createSelector(sourcererKibanaDataViewsSelector, (dataViews) => dataViews);
export const sourcererScopeSelectedDataViewId = createSelector(
sourcererScope,
(scope) => scope.selectedDataViewId,
{
memoizeOptions: {
maxSize: SOURCERER_SCOPE_MAX_SIZE,
},
}
);
export const signalIndexNameSelector = () =>
createSelector(sourcererSignalIndexNameSelector, (signalIndexName) => signalIndexName);
export const sourcererScopeSelectedPatterns = createSelector(
sourcererScope,
(scope) => scope.selectedPatterns,
{
memoizeOptions: {
maxSize: SOURCERER_SCOPE_MAX_SIZE,
},
}
);
export const defaultDataViewSelector = () =>
createSelector(sourcererDefaultDataViewSelector, (dataViews) => dataViews);
export const sourcererScopeMissingPatterns = createSelector(
sourcererScope,
(scope) => scope.missingPatterns,
{
memoizeOptions: {
maxSize: SOURCERER_SCOPE_MAX_SIZE,
},
}
);
export const sourcererDataViewSelector = () =>
createSelector(dataViewSelector, (dataView) => dataView);
export const kibanaDataViews = createSelector(
selectSourcerer,
(sourcerer) => sourcerer.kibanaDataViews,
{
memoizeOptions: {
maxSize: SOURCERER_SCOPE_MAX_SIZE,
},
}
);
export interface SourcererScopeSelector extends Omit<SourcererModel, 'sourcererScopes'> {
selectedDataView: SourcererDataView | undefined;
sourcererScope: SourcererScope;
}
export const defaultDataView = createSelector(
selectSourcerer,
(sourcerer) => sourcerer.defaultDataView,
{
memoizeOptions: {
maxSize: SOURCERER_SCOPE_MAX_SIZE,
},
}
);
export const getSourcererDataViewsSelector = () => {
const getKibanaDataViewsSelector = kibanaDataViewsSelector();
const getDefaultDataViewSelector = defaultDataViewSelector();
const getSignalIndexNameSelector = signalIndexNameSelector();
return (state: State): Omit<SourcererModel, 'sourcererScopes'> => {
const kibanaDataViews = getKibanaDataViewsSelector(state);
const defaultDataView = getDefaultDataViewSelector(state);
const signalIndexName = getSignalIndexNameSelector(state);
return {
defaultDataView,
kibanaDataViews,
signalIndexName,
};
};
};
/**
* Attn Future Developer
* Access sourcererScope.selectedPatterns from
* hook useSourcererDataView in `common/containers/sourcerer/index`
* in order to get exclude patterns for searches
* Access sourcererScope.selectedPatterns
* from this function for display purposes only
* */
export const getSourcererScopeSelector = () => {
const getDataViewsSelector = getSourcererDataViewsSelector();
const getSourcererDataViewSelector = sourcererDataViewSelector();
const getScopeSelector = scopeIdSelector();
return (state: State, scopeId: SourcererScopeName): SourcererScopeSelector => {
const dataViews = getDataViewsSelector(state);
const scope = getScopeSelector(state, scopeId);
const selectedDataView = getSourcererDataViewSelector(state, scope.selectedDataViewId);
return {
...dataViews,
selectedDataView,
sourcererScope: scope,
};
};
};
export const getSelectedDataviewSelector = () => {
const getSourcererDataViewSelector = sourcererDataViewSelector();
const getScopeSelector = scopeIdSelector();
return (state: State, scopeId: SourcererScopeName): DataViewSpec | undefined => {
const scope = getScopeSelector(state, scopeId);
const selectedDataView = getSourcererDataViewSelector(state, scope.selectedDataViewId);
return selectedDataView?.dataView;
};
};
export const signalIndexName = createSelector(
selectSourcerer,
(sourcerer) => sourcerer.signalIndexName,
{
memoizeOptions: {
maxSize: SOURCERER_SCOPE_MAX_SIZE,
},
}
);

View file

@ -7,14 +7,11 @@
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import * as redux from 'react-redux';
import '../../../../common/mock/match_media';
import { TestProviders } from '../../../../common/mock';
import { TestProviders, mockGlobalState, createMockStore } from '../../../../common/mock';
import { EmbeddedMapComponent } from './embedded_map';
import { createEmbeddable } from './create_embeddable';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { getLayerList } from './map_config';
import { useIsFieldInIndexPattern } from '../../../containers/fields';
import { buildTimeRangeFilter } from '../../../../detections/components/alerts_table/helpers';
@ -55,17 +52,47 @@ jest.mock('@kbn/embeddable-plugin/public', () => ({
EmbeddablePanel: jest.fn().mockReturnValue(<div data-test-subj="EmbeddablePanel" />),
}));
const mockUseSourcererDataView = useSourcererDataView as jest.Mock;
const mockCreateEmbeddable = createEmbeddable as jest.Mock;
const mockUseIsFieldInIndexPattern = useIsFieldInIndexPattern as jest.Mock;
const mockGetStorage = jest.fn();
const mockSetStorage = jest.fn();
const setQuery: jest.Mock = jest.fn();
const filebeatDataView = { id: '6f1eeb50-023d-11eb-bcb6-6ba0578012a9', title: 'filebeat-*' };
const packetbeatDataView = { id: '28995490-023d-11eb-bcb6-6ba0578012a9', title: 'packetbeat-*' };
const mockSelector = {
kibanaDataViews: [filebeatDataView, packetbeatDataView],
const filebeatDataView = {
id: '6f1eeb50-023d-11eb-bcb6-6ba0578012a9',
title: 'filebeat-*',
browserFields: {},
fields: {},
loading: false,
patternList: ['filebeat-*'],
dataView: {
id: '6f1eeb50-023d-11eb-bcb6-6ba0578012a9',
fields: {},
},
runtimeMappings: {},
indexFields: [],
};
const packetbeatDataView = {
id: '28995490-023d-11eb-bcb6-6ba0578012a9',
title: 'packetbeat-*',
browserFields: {},
fields: {},
loading: false,
patternList: ['packetbeat-*'],
dataView: {
id: '28995490-023d-11eb-bcb6-6ba0578012a9',
fields: {},
},
runtimeMappings: {},
indexFields: [],
};
const mockState = {
...mockGlobalState,
sourcerer: {
...mockGlobalState.sourcerer,
kibanaDataViews: [filebeatDataView, packetbeatDataView],
},
};
const defaultMockStore = createMockStore(mockState);
const mockUpdateInput = jest.fn();
const embeddableValue = {
destroyed: false,
@ -106,8 +133,6 @@ describe('EmbeddedMapComponent', () => {
beforeEach(() => {
setQuery.mockClear();
mockGetStorage.mockReturnValue(true);
jest.spyOn(redux, 'useSelector').mockReturnValue(mockSelector);
mockUseSourcererDataView.mockReturnValue({ selectedPatterns: ['filebeat-*', 'auditbeat-*'] });
mockCreateEmbeddable.mockResolvedValue(embeddableValue);
mockUseIsFieldInIndexPattern.mockReturnValue(() => true);
@ -121,7 +146,7 @@ describe('EmbeddedMapComponent', () => {
test('renders', async () => {
const { getByTestId } = render(
<TestProviders>
<TestProviders store={defaultMockStore}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
@ -132,7 +157,7 @@ describe('EmbeddedMapComponent', () => {
test('calls updateInput with time range filter', async () => {
render(
<TestProviders>
<TestProviders store={defaultMockStore}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
@ -146,7 +171,7 @@ describe('EmbeddedMapComponent', () => {
test('renders EmbeddablePanel from embeddable plugin', async () => {
const { getByTestId, queryByTestId } = render(
<TestProviders>
<TestProviders store={defaultMockStore}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
@ -159,13 +184,17 @@ describe('EmbeddedMapComponent', () => {
});
test('renders IndexPatternsMissingPrompt', async () => {
jest.spyOn(redux, 'useSelector').mockReturnValue({
...mockSelector,
kibanaDataViews: [],
});
const state = {
...mockGlobalState,
sourcerer: {
...mockGlobalState.sourcerer,
kibanaDataViews: [],
},
};
const store = createMockStore(state);
const { getByTestId, queryByTestId } = render(
<TestProviders>
<TestProviders store={store}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
@ -180,7 +209,7 @@ describe('EmbeddedMapComponent', () => {
mockCreateEmbeddable.mockResolvedValue(null);
const { getByTestId, queryByTestId } = render(
<TestProviders>
<TestProviders store={defaultMockStore}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
@ -194,7 +223,7 @@ describe('EmbeddedMapComponent', () => {
test('map hidden on close', async () => {
mockGetStorage.mockReturnValue(false);
const { getByTestId, queryByTestId } = render(
<TestProviders>
<TestProviders store={defaultMockStore}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
@ -210,7 +239,7 @@ describe('EmbeddedMapComponent', () => {
test('map visible on open', async () => {
const { getByTestId, queryByTestId } = render(
<TestProviders>
<TestProviders store={defaultMockStore}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
@ -225,8 +254,16 @@ describe('EmbeddedMapComponent', () => {
});
test('On mount, selects existing Kibana data views that match any selected index pattern', async () => {
const state = {
...mockGlobalState,
sourcerer: {
...mockGlobalState.sourcerer,
kibanaDataViews: [filebeatDataView],
},
};
const store = createMockStore(state);
render(
<TestProviders>
<TestProviders store={store}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
@ -237,11 +274,16 @@ describe('EmbeddedMapComponent', () => {
});
test('On rerender with new selected patterns, selects existing Kibana data views that match any selected index pattern', async () => {
mockUseSourcererDataView.mockReturnValue({
selectedPatterns: ['filebeat-*', 'auditbeat-*'],
});
const state = {
...mockGlobalState,
sourcerer: {
...mockGlobalState.sourcerer,
kibanaDataViews: [filebeatDataView],
},
};
const store = createMockStore(state);
const { rerender } = render(
<TestProviders>
<TestProviders store={store}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);
@ -249,11 +291,8 @@ describe('EmbeddedMapComponent', () => {
const dataViewArg = (getLayerList as jest.Mock).mock.calls[0][0];
expect(dataViewArg).toEqual([filebeatDataView]);
});
mockUseSourcererDataView.mockReturnValue({
selectedPatterns: ['filebeat-*', 'packetbeat-*'],
});
rerender(
<TestProviders>
<TestProviders store={defaultMockStore}>
<EmbeddedMapComponent {...testProps} />
</TestProviders>
);

View file

@ -9,6 +9,7 @@
import { EuiAccordion, EuiLink, EuiText } from '@elastic/eui';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { createHtmlPortalNode, InPortal } from 'react-reverse-portal';
import styled, { css } from 'styled-components';
import type { Filter, Query } from '@kbn/es-query';
@ -32,10 +33,9 @@ import * as i18n from './translations';
import { useKibana } from '../../../../common/lib/kibana';
import { getLayerList } from './map_config';
import { sourcererSelectors } from '../../../../common/store/sourcerer';
import type { State } from '../../../../common/store';
import type { SourcererDataView } from '../../../../common/store/sourcerer/model';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
export const NETWORK_MAP_VISIBLE = 'network_map_visbile';
@ -119,12 +119,10 @@ export const EmbeddedMapComponent = ({
const { addError } = useAppToasts();
const getDataViewsSelector = useMemo(
() => sourcererSelectors.getSourcererDataViewsSelector(),
[]
);
const { kibanaDataViews } = useDeepEqualSelector((state) => getDataViewsSelector(state));
const { selectedPatterns } = useSourcererDataView(SourcererScopeName.default);
const kibanaDataViews = useSelector(sourcererSelectors.kibanaDataViews);
const selectedPatterns = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedPatterns(state, SourcererScopeName.default);
});
const isFieldInIndexPattern = useIsFieldInIndexPattern();
@ -250,7 +248,6 @@ export const EmbeddedMapComponent = ({
() => buildTimeRangeFilter(startDate, endDate),
[startDate, endDate]
);
useEffect(() => {
if (embeddable != null) {
// pass time range as filter instead of via timeRange param

View file

@ -5,11 +5,9 @@
* 2.0.
*/
import { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { v4 as uuidv4 } from 'uuid';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { sourcererActions } from '../../../../common/store/sourcerer';
import {
@ -33,13 +31,8 @@ export interface Filter {
export const useNavigateToTimeline = () => {
const dispatch = useDispatch();
const getDataViewsSelector = useMemo(
() => sourcererSelectors.getSourcererDataViewsSelector(),
[]
);
const { defaultDataView, signalIndexName } = useDeepEqualSelector((state) =>
getDataViewsSelector(state)
);
const signalIndexName = useSelector(sourcererSelectors.signalIndexName);
const defaultDataView = useSelector(sourcererSelectors.defaultDataView);
const clearTimeline = useCreateTimeline({
timelineId: TimelineId.active,

View file

@ -109,6 +109,16 @@ export class Simulator {
this.store = createMockStore(
{
...mockGlobalState,
sourcerer: {
...mockGlobalState.sourcerer,
sourcererScopes: {
...mockGlobalState.sourcerer.sourcererScopes,
analyzer: {
...mockGlobalState.sourcerer.sourcererScopes.default,
selectedPatterns: indices,
},
},
},
analyzer: {
[resolverComponentInstanceID]: EMPTY_RESOLVER,
},

View file

@ -17,6 +17,7 @@ import type { SideEffectSimulator, ResolverProps } from '../../types';
import { ResolverWithoutProviders } from '../../view/resolver_without_providers';
import { SideEffectContext } from '../../view/side_effect_context';
import type { State } from '../../../common/store/types';
import { TestProviders } from '../../../common/mock';
enableMapSet();
@ -93,23 +94,25 @@ export const MockResolver = React.memo((props: MockResolverProps) => {
}, [props.rasterWidth, props.rasterHeight, props.sideEffectSimulator.controls, resolverElement]);
return (
<I18nProvider>
<Router history={props.history}>
<KibanaContextProvider services={props.coreStart}>
<SideEffectContext.Provider value={props.sideEffectSimulator.mock}>
<Provider store={props.store}>
<ResolverWithoutProviders
ref={resolverRef}
databaseDocumentID={props.databaseDocumentID}
resolverComponentInstanceID={props.resolverComponentInstanceID}
indices={props.indices}
shouldUpdate={props.shouldUpdate}
filters={props.filters}
/>
</Provider>
</SideEffectContext.Provider>
</KibanaContextProvider>
</Router>
</I18nProvider>
<TestProviders>
<I18nProvider>
<Router history={props.history}>
<KibanaContextProvider services={props.coreStart}>
<SideEffectContext.Provider value={props.sideEffectSimulator.mock}>
<Provider store={props.store}>
<ResolverWithoutProviders
ref={resolverRef}
databaseDocumentID={props.databaseDocumentID}
resolverComponentInstanceID={props.resolverComponentInstanceID}
indices={props.indices}
shouldUpdate={props.shouldUpdate}
filters={props.filters}
/>
</Provider>
</SideEffectContext.Provider>
</KibanaContextProvider>
</Router>
</I18nProvider>
</TestProviders>
);
});

View file

@ -20,11 +20,15 @@ jest.mock('react-redux', () => {
};
});
const mockRef = {
current: null,
};
describe('TimelineBottomBar', () => {
test('should render all components for bottom bar', () => {
const { getByTestId } = render(
<TestProviders>
<TimelineBottomBar show={false} timelineId={TimelineId.test} />
<TimelineBottomBar show={false} timelineId={TimelineId.test} openToggleRef={mockRef} />
</TestProviders>
);
@ -38,7 +42,7 @@ describe('TimelineBottomBar', () => {
test('should not render the event count badge if timeline is open', () => {
const { queryByTestId } = render(
<TestProviders>
<TimelineBottomBar show={true} timelineId={TimelineId.test} />
<TimelineBottomBar show={true} timelineId={TimelineId.test} openToggleRef={mockRef} />
</TestProviders>
);
@ -50,7 +54,7 @@ describe('TimelineBottomBar', () => {
const { getByTestId } = render(
<TestProviders>
<TimelineBottomBar show={true} timelineId={TimelineId.test} />
<TimelineBottomBar show={true} timelineId={TimelineId.test} openToggleRef={mockRef} />
</TestProviders>
);

View file

@ -26,50 +26,55 @@ interface TimelineBottomBarProps {
* True if the timeline modal is open
*/
show: boolean;
openToggleRef: React.MutableRefObject<null | HTMLAnchorElement | HTMLButtonElement>;
}
/**
* This component renders the bottom bar for timeline displayed or most of the pages within Security Solution.
*/
export const TimelineBottomBar = React.memo<TimelineBottomBarProps>(({ show, timelineId }) => {
const dispatch = useDispatch();
export const TimelineBottomBar = React.memo<TimelineBottomBarProps>(
({ show, timelineId, openToggleRef }) => {
const dispatch = useDispatch();
const openTimeline = useCallback(
() => dispatch(timelineActions.showTimeline({ id: timelineId, show: true })),
[dispatch, timelineId]
);
const openTimeline = useCallback(
() => dispatch(timelineActions.showTimeline({ id: timelineId, show: true })),
[dispatch, timelineId]
);
const title = useSelector((state: State) => selectTitleByTimelineById(state, timelineId));
const title = useSelector((state: State) => selectTitleByTimelineById(state, timelineId));
return (
<EuiPanel borderRadius="none" data-test-subj="timeline-bottom-bar">
<EuiFlexGroup gutterSize="xs" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<AddTimelineButton timelineId={timelineId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddToFavoritesButton timelineId={timelineId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
aria-label={i18n.OPEN_TIMELINE_BUTTON(title)}
onClick={openTimeline}
data-test-subj="timeline-bottom-bar-title-button"
>
{title}
</EuiLink>
</EuiFlexItem>
{!show && ( // this is a hack because TimelineEventsCountBadge is using react-reverse-portal so the component which is used in multiple places cannot be visible in multiple places at the same time
<EuiFlexItem grow={false} data-test-subj="timeline-event-count-badge">
<TimelineEventsCountBadge />
return (
<EuiPanel borderRadius="none" data-test-subj="timeline-bottom-bar">
<EuiFlexGroup gutterSize="xs" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<AddTimelineButton timelineId={timelineId} />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<TimelineSaveStatus timelineId={timelineId} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
});
<EuiFlexItem grow={false}>
<AddToFavoritesButton timelineId={timelineId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
aria-label={i18n.OPEN_TIMELINE_BUTTON(title)}
onClick={openTimeline}
data-test-subj="timeline-bottom-bar-title-button"
ref={openToggleRef}
>
{title}
</EuiLink>
</EuiFlexItem>
{!show && ( // this is a hack because TimelineEventsCountBadge is using react-reverse-portal so the component which is used in multiple places cannot be visible in multiple places at the same time
<EuiFlexItem grow={false} data-test-subj="timeline-event-count-badge">
<TimelineEventsCountBadge />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<TimelineSaveStatus timelineId={timelineId} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}
);
TimelineBottomBar.displayName = 'TimelineBottomBar';

View file

@ -7,6 +7,7 @@
import React from 'react';
import { render, act } from '@testing-library/react';
import type { Store } from 'redux';
import type { UseFieldBrowserOptionsProps, UseFieldBrowserOptions, FieldEditorActionsRef } from '.';
import { useFieldBrowserOptions } from '.';
import type { Start } from '@kbn/data-view-field-editor-plugin/public/mocks';
@ -27,22 +28,6 @@ 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: () => ({
@ -57,8 +42,10 @@ const mockOnHide = jest.fn();
const runAllPromises = () => new Promise(setImmediate);
// helper function to render the hook
const renderUseFieldBrowserOptions = (props: Partial<UseFieldBrowserOptionsProps> = {}) =>
renderHook<UseFieldBrowserOptionsProps, ReturnType<UseFieldBrowserOptions>>(
const renderUseFieldBrowserOptions = (
props: Partial<UseFieldBrowserOptionsProps & { store?: Store }> = {}
) =>
renderHook<UseFieldBrowserOptionsProps & { store?: Store }, ReturnType<UseFieldBrowserOptions>>(
() =>
useFieldBrowserOptions({
sourcererScope: SourcererScopeName.default,
@ -67,7 +54,12 @@ const renderUseFieldBrowserOptions = (props: Partial<UseFieldBrowserOptionsProps
...props,
}),
{
wrapper: TestProviders,
wrapper: ({ children, store }) => {
if (store) {
return <TestProviders store={store}>{children}</TestProviders>;
}
return <TestProviders>{children}</TestProviders>;
},
}
);
@ -104,7 +96,6 @@ describe('useFieldBrowserOptions', () => {
...useKibanaMock().services.application.capabilities,
indexPatterns: { save: true },
};
mockScopeIdSelector.mockReturnValue(defaultDataviewState);
jest.clearAllMocks();
});
@ -137,13 +128,6 @@ describe('useFieldBrowserOptions', () => {
);
});
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

@ -7,6 +7,7 @@
import type { MutableRefObject } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import type { DataViewField, DataView } from '@kbn/data-views-plugin/common';
import type {
CreateFieldComponent,
@ -14,9 +15,9 @@ import type {
} from '@kbn/triggers-actions-ui-plugin/public/types';
import type { ColumnHeaderOptions } from '../../../../common/types';
import { useDataView } from '../../../common/containers/source/use_data_view';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useKibana } from '../../../common/lib/kibana';
import { sourcererSelectors } from '../../../common/store';
import type { State } from '../../../common/store';
import type { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants';
@ -57,11 +58,12 @@ export const useFieldBrowserOptions: UseFieldBrowserOptions = ({
dataViewFieldEditor,
data: { dataViews },
} = useKibana().services;
const scopeIdSelector = useMemo(() => sourcererSelectors.scopeIdSelector(), []);
const { missingPatterns, selectedDataViewId } = useDeepEqualSelector((state) =>
scopeIdSelector(state, sourcererScope)
);
const missingPatterns = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeMissingPatterns(state, sourcererScope);
});
const selectedDataViewId = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedDataViewId(state, sourcererScope);
});
useEffect(() => {
let ignore = false;
const fetchAndSetDataView = async (dataViewId: string) => {

View file

@ -10,9 +10,9 @@ import React from 'react';
import { NewTimelineButton } from './new_timeline_button';
import { TimelineId } from '../../../../../common/types';
import { timelineActions } from '../../../store';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { useDiscoverInTimelineContext } from '../../../../common/components/discover_in_timeline/use_discover_in_timeline_context';
import { defaultHeaders } from '../../timeline/body/column_headers/default_headers';
import { TestProviders } from '../../../../common/mock';
jest.mock('../../../../common/components/discover_in_timeline/use_discover_in_timeline_context');
jest.mock('../../../../common/hooks/use_selector');
@ -26,11 +26,11 @@ jest.mock('react-redux', () => {
};
});
const renderNewTimelineButton = () => render(<NewTimelineButton timelineId={TimelineId.test} />);
const renderNewTimelineButton = () =>
render(<NewTimelineButton timelineId={TimelineId.test} />, { wrapper: TestProviders });
describe('NewTimelineButton', () => {
it('should render 2 options in the popover when clicking on the button', async () => {
(useDeepEqualSelector as jest.Mock).mockReturnValue({});
(useDiscoverInTimelineContext as jest.Mock).mockReturnValue({});
const { getByTestId, getByText } = renderNewTimelineButton();
@ -52,12 +52,8 @@ describe('NewTimelineButton', () => {
});
it('should call the correct action with clicking on the new timeline button', () => {
const dataViewId = 'dataViewId';
const selectedPatterns = ['selectedPatterns'];
(useDeepEqualSelector as jest.Mock).mockReturnValue({
id: dataViewId,
patternList: selectedPatterns,
});
const dataViewId = '';
const selectedPatterns: string[] = [];
(useDiscoverInTimelineContext as jest.Mock).mockReturnValue({
resetDiscoverAppState: jest.fn(),
});

View file

@ -41,10 +41,13 @@ jest.mock('react-redux', () => {
});
const timelineId = 'timeline-1';
const mockRef = {
current: null,
};
const renderTimelineModalHeader = () =>
render(
<TestProviders>
<TimelineModalHeader timelineId={timelineId} />
<TimelineModalHeader timelineId={timelineId} openToggleRef={mockRef} />
</TestProviders>
);

View file

@ -62,130 +62,136 @@ interface FlyoutHeaderPanelProps {
* Id of the timeline to be displayed within the modal
*/
timelineId: string;
openToggleRef: React.MutableRefObject<null | HTMLAnchorElement | HTMLButtonElement>;
}
/**
* Component rendered at the top of the timeline modal. It contains the timeline title, all the action buttons (save, open, favorite...) and the close button
*/
export const TimelineModalHeader = React.memo<FlyoutHeaderPanelProps>(({ timelineId }) => {
const dispatch = useDispatch();
const { browserFields, indexPattern } = useSourcererDataView(SourcererScopeName.timeline);
const { cases, uiSettings } = useKibana().services;
const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]);
const userCasesPermissions = cases.helpers.canUseCases([APP_ID]);
export const TimelineModalHeader = React.memo<FlyoutHeaderPanelProps>(
({ timelineId, openToggleRef }) => {
const dispatch = useDispatch();
const { browserFields, indexPattern } = useSourcererDataView(SourcererScopeName.timeline);
const { cases, uiSettings } = useKibana().services;
const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]);
const userCasesPermissions = cases.helpers.canUseCases([APP_ID]);
const title = useSelector((state: State) => selectTitleByTimelineById(state, timelineId));
const isDataInTimeline = useSelector((state: State) => selectDataInTimeline(state, timelineId));
const kqlQueryObj = useSelector((state: State) => selectKqlQuery(state, timelineId));
const title = useSelector((state: State) => selectTitleByTimelineById(state, timelineId));
const isDataInTimeline = useSelector((state: State) => selectDataInTimeline(state, timelineId));
const kqlQueryObj = useSelector((state: State) => selectKqlQuery(state, timelineId));
const { activeTab, dataProviders, timelineType, filters, kqlMode } = useSelector((state: State) =>
selectTimelineById(state, timelineId)
);
const { activeTab, dataProviders, timelineType, filters, kqlMode } = useSelector(
(state: State) => selectTimelineById(state, timelineId)
);
const combinedQueries = useMemo(
() =>
combineQueries({
config: esQueryConfig,
dataProviders,
indexPattern,
browserFields,
filters: filters ? filters : [],
kqlQuery: kqlQueryObj,
kqlMode,
}),
[browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQueryObj]
);
const isInspectDisabled = !isDataInTimeline || combinedQueries?.filterQuery === undefined;
const combinedQueries = useMemo(
() =>
combineQueries({
config: esQueryConfig,
dataProviders,
indexPattern,
browserFields,
filters: filters ? filters : [],
kqlQuery: kqlQueryObj,
kqlMode,
}),
[browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQueryObj]
);
const isInspectDisabled = !isDataInTimeline || combinedQueries?.filterQuery === undefined;
const closeTimeline = useCallback(() => {
createHistoryEntry();
dispatch(timelineActions.showTimeline({ id: timelineId, show: false }));
}, [dispatch, timelineId]);
const closeTimeline = useCallback(() => {
if (openToggleRef.current != null) {
openToggleRef.current.focus();
}
createHistoryEntry();
dispatch(timelineActions.showTimeline({ id: timelineId, show: false }));
}, [dispatch, timelineId, openToggleRef]);
return (
<TimelinePanel
grow={false}
paddingSize="s"
hasShadow={false}
data-test-subj="timeline-modal-header-panel"
>
<EuiFlexGroup
className="eui-scrollBar"
alignItems="center"
gutterSize="s"
responsive={false}
justifyContent="spaceBetween"
css={autoOverflowXCSS}
return (
<TimelinePanel
grow={false}
paddingSize="s"
hasShadow={false}
data-test-subj="timeline-modal-header-panel"
>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<AddToFavoritesButton timelineId={timelineId} isPartOfGuidedTour />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText
grow={false}
data-test-subj="timeline-modal-header-title"
css={whiteSpaceNoWrapCSS}
>
<h3>{title}</h3>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TimelineSaveStatus timelineId={timelineId} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup
id={TIMELINE_TOUR_CONFIG_ANCHORS.ACTION_MENU}
justifyContent="flexEnd"
alignItems="center"
gutterSize="xs"
responsive={false}
data-test-subj="timeline-modal-header-actions"
>
<EuiFlexItem>
<NewTimelineButton timelineId={timelineId} />
</EuiFlexItem>
<EuiFlexItem>
<OpenTimelineButton />
</EuiFlexItem>
<EuiFlexItem>
<InspectButton
queryId={`${timelineId}-${activeTab}`}
inputId={InputsModelId.timeline}
isDisabled={isInspectDisabled}
/>
</EuiFlexItem>
{userCasesPermissions.create && userCasesPermissions.read ? (
<>
<EuiFlexItem>
<VerticalDivider />
</EuiFlexItem>
<EuiFlexItem>
<AttachToCaseButton timelineId={timelineId} />
</EuiFlexItem>
</>
) : null}
<EuiFlexItem>
<SaveTimelineButton timelineId={timelineId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={i18n.CLOSE_TIMELINE_OR_TEMPLATE(timelineType === 'default')}>
<EuiButtonIcon
aria-label={i18n.CLOSE_TIMELINE_OR_TEMPLATE(timelineType === 'default')}
iconType="cross"
data-test-subj="timeline-modal-header-close-button"
onClick={closeTimeline}
<EuiFlexGroup
className="eui-scrollBar"
alignItems="center"
gutterSize="s"
responsive={false}
justifyContent="spaceBetween"
css={autoOverflowXCSS}
>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<AddToFavoritesButton timelineId={timelineId} isPartOfGuidedTour />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText
grow={false}
data-test-subj="timeline-modal-header-title"
css={whiteSpaceNoWrapCSS}
>
<h3>{title}</h3>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TimelineSaveStatus timelineId={timelineId} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup
id={TIMELINE_TOUR_CONFIG_ANCHORS.ACTION_MENU}
justifyContent="flexEnd"
alignItems="center"
gutterSize="xs"
responsive={false}
data-test-subj="timeline-modal-header-actions"
>
<EuiFlexItem>
<NewTimelineButton timelineId={timelineId} />
</EuiFlexItem>
<EuiFlexItem>
<OpenTimelineButton />
</EuiFlexItem>
<EuiFlexItem>
<InspectButton
queryId={`${timelineId}-${activeTab}`}
inputId={InputsModelId.timeline}
isDisabled={isInspectDisabled}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</TimelinePanel>
);
});
</EuiFlexItem>
{userCasesPermissions.create && userCasesPermissions.read ? (
<>
<EuiFlexItem>
<VerticalDivider />
</EuiFlexItem>
<EuiFlexItem>
<AttachToCaseButton timelineId={timelineId} />
</EuiFlexItem>
</>
) : null}
<EuiFlexItem>
<SaveTimelineButton timelineId={timelineId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={i18n.CLOSE_TIMELINE_OR_TEMPLATE(timelineType === 'default')}>
<EuiButtonIcon
aria-label={i18n.CLOSE_TIMELINE_OR_TEMPLATE(timelineType === 'default')}
iconType="cross"
data-test-subj="timeline-modal-header-close-button"
onClick={closeTimeline}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</TimelinePanel>
);
}
);
TimelineModalHeader.displayName = 'TimelineModalHeader';

View file

@ -21,10 +21,14 @@ jest.mock('../../../common/store/selectors', () => ({
inputsSelectors: { timelineFullScreenSelector: () => mockIsFullScreen() },
}));
const mockRef = {
current: null,
};
const renderTimelineModal = () =>
render(
<TestProviders>
<TimelineModal timelineId={TimelineId.test} />
<TimelineModal timelineId={TimelineId.test} openToggleRef={mockRef} />
</TestProviders>
);

View file

@ -33,43 +33,51 @@ interface TimelineModalProps {
* If true the timeline modal will be visible
*/
visible?: boolean;
openToggleRef: React.MutableRefObject<null | HTMLAnchorElement | HTMLButtonElement>;
}
/**
* Renders the timeline modal. Internally this is using an EuiPortal.
*/
export const TimelineModal = React.memo<TimelineModalProps>(({ timelineId, visible = true }) => {
const ref = useRef<HTMLDivElement>(null);
const isFullScreen = useShallowEqualSelector(inputsSelectors.timelineFullScreenSelector) ?? false;
export const TimelineModal = React.memo<TimelineModalProps>(
({ timelineId, openToggleRef, visible = true }) => {
const ref = useRef<HTMLDivElement>(null);
const isFullScreen =
useShallowEqualSelector(inputsSelectors.timelineFullScreenSelector) ?? false;
const styles = usePaneStyles();
const wrapperClassName = classNames('timeline-portal-overlay-mask', styles, {
'timeline-portal-overlay-mask--full-screen': isFullScreen,
'timeline-portal-overlay-mask--hidden': !visible,
});
const styles = usePaneStyles();
const wrapperClassName = classNames('timeline-portal-overlay-mask', styles, {
'timeline-portal-overlay-mask--full-screen': isFullScreen,
'timeline-portal-overlay-mask--hidden': !visible,
});
const sibling: HTMLDivElement | null = useMemo(() => (!visible ? ref?.current : null), [visible]);
const sibling: HTMLDivElement | null = useMemo(
() => (!visible ? ref?.current : null),
[visible]
);
return (
<div data-test-subj="timeline-portal-ref" ref={ref}>
<CustomEuiPortal sibling={sibling}>
<div data-test-subj="timeline-portal-overlay-mask" className={wrapperClassName}>
<div
aria-label={TIMELINE_DESCRIPTION}
data-test-subj="timeline-container"
className="timeline-container"
>
<StatefulTimeline
renderCellValue={DefaultCellRenderer}
rowRenderers={defaultRowRenderers}
timelineId={timelineId}
/>
return (
<div data-test-subj="timeline-portal-ref" ref={ref}>
<CustomEuiPortal sibling={sibling}>
<div data-test-subj="timeline-portal-overlay-mask" className={wrapperClassName}>
<div
aria-label={TIMELINE_DESCRIPTION}
data-test-subj="timeline-container"
className="timeline-container"
>
<StatefulTimeline
renderCellValue={DefaultCellRenderer}
rowRenderers={defaultRowRenderers}
timelineId={timelineId}
openToggleRef={openToggleRef}
/>
</div>
</div>
</div>
</CustomEuiPortal>
{visible && <OverflowHiddenGlobalStyles />}
</div>
);
});
</CustomEuiPortal>
{visible && <OverflowHiddenGlobalStyles />}
</div>
);
}
);
TimelineModal.displayName = 'TimelineModal';

View file

@ -10,10 +10,10 @@ import React from 'react';
import { NewTimelineButton } from '.';
import { TimelineId } from '../../../../common/types';
import { timelineActions } from '../../store';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useDiscoverInTimelineContext } from '../../../common/components/discover_in_timeline/use_discover_in_timeline_context';
import { defaultHeaders } from '../timeline/body/column_headers/default_headers';
import { TimelineType } from '../../../../common/api/timeline';
import { TestProviders } from '../../../common/mock';
jest.mock('../../../common/components/discover_in_timeline/use_discover_in_timeline_context');
jest.mock('../../../common/hooks/use_selector');
@ -27,15 +27,12 @@ jest.mock('react-redux', () => {
};
});
const renderNewTimelineButton = (type: TimelineType) => render(<NewTimelineButton type={type} />);
const renderNewTimelineButton = (type: TimelineType) =>
render(<NewTimelineButton type={type} />, { wrapper: TestProviders });
describe('NewTimelineButton', () => {
const dataViewId = 'dataViewId';
const selectedPatterns = ['selectedPatterns'];
(useDeepEqualSelector as jest.Mock).mockReturnValue({
id: dataViewId,
patternList: selectedPatterns,
});
const dataViewId = '';
const selectedPatterns: string[] = [];
(useDiscoverInTimelineContext as jest.Mock).mockReturnValue({
resetDiscoverAppState: jest.fn(),
});

View file

@ -70,6 +70,9 @@ jest.mock('react-router-dom', () => {
});
const mockDispatch = jest.fn();
const mockRef = {
current: null,
};
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
@ -95,6 +98,7 @@ describe('StatefulTimeline', () => {
renderCellValue: DefaultCellRenderer,
rowRenderers: defaultRowRenderers,
timelineId: TimelineId.test,
openToggleRef: mockRef,
};
beforeEach(() => {

View file

@ -8,7 +8,7 @@
import { pick } from 'lodash/fp';
import { EuiProgress } from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useRef, createContext } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { isTab } from '@kbn/timelines-plugin/public';
@ -22,6 +22,7 @@ import { TimelineModalHeader } from '../modal/header';
import type { TimelineId, RowRenderer, TimelineTabs } from '../../../../common/types/timeline';
import { TimelineType } from '../../../../common/api/timeline';
import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector';
import type { State } from '../../../common/store';
import { EVENTS_COUNT_BUTTON_CLASS_NAME, onTimelineTabKeyPressed } from './helpers';
import * as i18n from './translations';
import { TabsContent } from './tabs_content';
@ -50,6 +51,7 @@ export interface Props {
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
timelineId: TimelineId;
openToggleRef: React.MutableRefObject<null | HTMLAnchorElement | HTMLButtonElement>;
}
const TimelineSavingProgressComponent: React.FC<{ timelineId: TimelineId }> = ({ timelineId }) => {
@ -67,15 +69,17 @@ const StatefulTimelineComponent: React.FC<Props> = ({
renderCellValue,
rowRenderers,
timelineId,
openToggleRef,
}) => {
const dispatch = useDispatch();
const containerElement = useRef<HTMLDivElement | null>(null);
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const scopeIdSelector = useMemo(() => sourcererSelectors.scopeIdSelector(), []);
const {
selectedPatterns: selectedPatternsSourcerer,
selectedDataViewId: selectedDataViewIdSourcerer,
} = useDeepEqualSelector((state) => scopeIdSelector(state, SourcererScopeName.timeline));
const selectedPatternsSourcerer = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedPatterns(state, SourcererScopeName.timeline);
});
const selectedDataViewIdSourcerer = useSelector((state: State) => {
return sourcererSelectors.sourcererScopeSelectedDataViewId(state, SourcererScopeName.timeline);
});
const {
dataViewId: selectedDataViewIdTimeline,
indexNames: selectedPatternsTimeline,
@ -233,7 +237,7 @@ const StatefulTimelineComponent: React.FC<Props> = ({
$isVisible={!timelineFullScreen}
data-test-subj="timeline-hide-show-container"
>
<TimelineModalHeader timelineId={timelineId} />
<TimelineModalHeader timelineId={timelineId} openToggleRef={openToggleRef} />
</HideShowContainer>
<TabsContent

View file

@ -6,6 +6,7 @@
*/
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { useDeepEqualSelector } from '../../common/hooks/use_selector';
import {
@ -47,11 +48,7 @@ export function useTimelineDataFilters(isActiveTimelines: boolean) {
return getEndSelector(state.inputs.global);
}
});
const getDefaultDataViewSelector = useMemo(
() => sourcererSelectors.defaultDataViewSelector(),
[]
);
const defaultDataView = useDeepEqualSelector(getDefaultDataViewSelector);
const defaultDataView = useSelector(sourcererSelectors.defaultDataView);
const { pathname } = useLocation();
const { selectedPatterns: nonTimelinePatterns } = useSourcererDataView(
getScopeFromPath(pathname)

View file

@ -4,15 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RenderHookResult } from '@testing-library/react-hooks';
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import type { UseCreateTimelineParams } from './use_create_timeline';
import { useCreateTimeline } from './use_create_timeline';
import type { TimeRange } from '../../common/store/inputs/model';
import { TimelineType } from '../../../common/api/timeline';
import { TimelineId } from '../../../common/types';
import { useDeepEqualSelector } from '../../common/hooks/use_selector';
import { useDiscoverInTimelineContext } from '../../common/components/discover_in_timeline/use_discover_in_timeline_context';
import { timelineActions } from '../store';
import { inputsActions } from '../../common/store/inputs';
@ -21,87 +18,90 @@ import { appActions } from '../../common/store/app';
import { defaultHeaders } from '../components/timeline/body/column_headers/default_headers';
import { SourcererScopeName } from '../../common/store/sourcerer/model';
import { InputsModelId } from '../../common/store/inputs/constants';
import { TestProviders, mockGlobalState } from '../../common/mock';
jest.mock('../../common/components/discover_in_timeline/use_discover_in_timeline_context');
jest.mock('../../common/hooks/use_selector');
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
jest.mock('../../common/containers/use_global_time', () => {
return {
...original,
useSelector: jest.fn(),
useDispatch: () => jest.fn(),
useGlobalTime: jest.fn().mockReturnValue({
from: '2022-04-05T12:00:00.000Z',
to: '2022-04-08T12:00:00.000Z',
setQuery: () => jest.fn(),
deleteQuery: () => jest.fn(),
}),
};
});
jest.mock('../../common/lib/kibana');
describe('useCreateTimeline', () => {
let hookResult: RenderHookResult<
UseCreateTimelineParams,
(options?: { timeRange?: TimeRange }) => void
>;
const resetDiscoverAppState = jest.fn();
(useDiscoverInTimelineContext as jest.Mock).mockReturnValue({ resetDiscoverAppState });
it('should return a function', () => {
(useDeepEqualSelector as jest.Mock).mockReturnValue({});
hookResult = renderHook(() =>
useCreateTimeline({ timelineId: TimelineId.test, timelineType: TimelineType.default })
const hookResult = renderHook(
() => useCreateTimeline({ timelineId: TimelineId.test, timelineType: TimelineType.default }),
{
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
expect(hookResult.result.current).toEqual(expect.any(Function));
});
it('should dispatch correct actions when calling the returned function', () => {
const dataViewId = 'dataViewId';
const selectedPatterns = ['selectedPatterns'];
(useDeepEqualSelector as jest.Mock).mockReturnValue({
id: dataViewId,
patternList: selectedPatterns,
});
const createTimeline = jest.spyOn(timelineActions, 'createTimeline');
const setSelectedDataView = jest.spyOn(sourcererActions, 'setSelectedDataView');
const addLinkTo = jest.spyOn(inputsActions, 'addLinkTo');
const addNotes = jest.spyOn(appActions, 'addNotes');
hookResult = renderHook(() =>
useCreateTimeline({ timelineId: TimelineId.test, timelineType: TimelineType.default })
const hookResult = renderHook(
() => useCreateTimeline({ timelineId: TimelineId.test, timelineType: TimelineType.default }),
{
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
expect(hookResult.result.current).toEqual(expect.any(Function));
hookResult.result.current();
expect(createTimeline).toHaveBeenCalledWith({
columns: defaultHeaders,
dataViewId,
id: TimelineId.test,
indexNames: selectedPatterns,
show: true,
timelineType: 'default',
updated: undefined,
});
expect(setSelectedDataView).toHaveBeenCalledWith({
id: SourcererScopeName.timeline,
selectedDataViewId: dataViewId,
selectedPatterns,
});
expect(createTimeline.mock.calls[0][0].id).toEqual(TimelineId.test);
expect(createTimeline.mock.calls[0][0].timelineType).toEqual(TimelineType.default);
expect(createTimeline.mock.calls[0][0].columns).toEqual(defaultHeaders);
expect(createTimeline.mock.calls[0][0].dataViewId).toEqual(
mockGlobalState.sourcerer.defaultDataView.id
);
expect(createTimeline.mock.calls[0][0].indexNames).toEqual(
expect.arrayContaining(
mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline].selectedPatterns
)
);
expect(createTimeline.mock.calls[0][0].show).toEqual(true);
expect(createTimeline.mock.calls[0][0].updated).toEqual(undefined);
expect(setSelectedDataView.mock.calls[0][0].id).toEqual(SourcererScopeName.timeline);
expect(setSelectedDataView.mock.calls[0][0].selectedDataViewId).toEqual(
mockGlobalState.sourcerer.defaultDataView.id
);
expect(setSelectedDataView.mock.calls[0][0].selectedPatterns).toEqual(
expect.arrayContaining(
mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline].selectedPatterns
)
);
expect(addLinkTo).toHaveBeenCalledWith([InputsModelId.global, InputsModelId.timeline]);
expect(addNotes).toHaveBeenCalledWith({ notes: [] });
});
it('should run the onClick method if provided', () => {
(useDeepEqualSelector as jest.Mock).mockReturnValue({});
const onClick = jest.fn();
hookResult = renderHook(() =>
useCreateTimeline({
timelineId: TimelineId.test,
timelineType: TimelineType.default,
onClick,
})
const hookResult = renderHook(
() =>
useCreateTimeline({
timelineId: TimelineId.test,
timelineType: TimelineType.default,
onClick,
}),
{
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
hookResult.result.current();
@ -111,13 +111,14 @@ describe('useCreateTimeline', () => {
});
it('should dispatch removeLinkTo action if absolute timeRange is passed to callback', () => {
(useDeepEqualSelector as jest.Mock).mockReturnValue({});
const removeLinkTo = jest.spyOn(inputsActions, 'removeLinkTo');
const setAbsoluteRangeDatePicker = jest.spyOn(inputsActions, 'setAbsoluteRangeDatePicker');
hookResult = renderHook(() =>
useCreateTimeline({ timelineId: TimelineId.test, timelineType: TimelineType.default })
const hookResult = renderHook(
() => useCreateTimeline({ timelineId: TimelineId.test, timelineType: TimelineType.default }),
{
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
const timeRange: TimeRange = { kind: 'absolute', from: '', to: '' };
@ -131,12 +132,13 @@ describe('useCreateTimeline', () => {
});
it('should dispatch removeLinkTo action if relative timeRange is passed to callback', () => {
(useDeepEqualSelector as jest.Mock).mockReturnValue({});
const setRelativeRangeDatePicker = jest.spyOn(inputsActions, 'setRelativeRangeDatePicker');
hookResult = renderHook(() =>
useCreateTimeline({ timelineId: TimelineId.test, timelineType: TimelineType.default })
const hookResult = renderHook(
() => useCreateTimeline({ timelineId: TimelineId.test, timelineType: TimelineType.default }),
{
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
const timeRange: TimeRange = { kind: 'relative', fromStr: '', toStr: '', from: '', to: '' };

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { InputsModelId } from '../../common/store/inputs/constants';
import { defaultHeaders } from '../components/timeline/body/column_headers/default_headers';
import { timelineActions } from '../store';
@ -47,9 +47,9 @@ export const useCreateTimeline = ({
onClick,
}: UseCreateTimelineParams): ((options?: { timeRange?: TimeRange }) => void) => {
const dispatch = useDispatch();
const defaultDataViewSelector = useMemo(() => sourcererSelectors.defaultDataViewSelector(), []);
const { id: dataViewId, patternList: selectedPatterns } =
useDeepEqualSelector(defaultDataViewSelector);
const { id: dataViewId, patternList: selectedPatterns } = useSelector(
sourcererSelectors.defaultDataView
) ?? { id: '', patternList: [] };
const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen();
const globalTimeRange = useDeepEqualSelector(inputsSelectors.globalTimeRangeSelector);

View file

@ -6,7 +6,7 @@
*/
import { EuiFocusTrap, EuiWindowEvent, keys } from '@elastic/eui';
import React, { useMemo, useCallback } from 'react';
import React, { useMemo, useCallback, useRef } from 'react';
import type { AppLeaveHandler } from '@kbn/core/public';
import { useDispatch } from 'react-redux';
import { TimelineModal } from '../components/modal';
@ -38,7 +38,7 @@ export const TimelineWrapper: React.FC<TimelineWrapperProps> = React.memo(
const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []);
const { show } = useDeepEqualSelector((state) => getTimelineShowStatus(state, timelineId));
const dispatch = useDispatch();
const openToggleRef = useRef(null);
const handleClose = useCallback(() => {
dispatch(timelineActions.showTimeline({ id: timelineId, show: false }));
}, [dispatch, timelineId]);
@ -58,9 +58,9 @@ export const TimelineWrapper: React.FC<TimelineWrapperProps> = React.memo(
return (
<>
<EuiFocusTrap disabled={!show}>
<TimelineModal timelineId={timelineId} visible={show} />
<TimelineModal timelineId={timelineId} visible={show} openToggleRef={openToggleRef} />
</EuiFocusTrap>
<TimelineBottomBar show={show} timelineId={timelineId} />
<TimelineBottomBar show={show} timelineId={timelineId} openToggleRef={openToggleRef} />
<EuiWindowEvent event="keydown" handler={onKeyDown} />
</>
);