mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[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:
parent
b207ff4534
commit
7745d36703
29 changed files with 633 additions and 587 deletions
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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: '' };
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue