mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solution][Timelines] - Resolve UI (#114350)
This commit is contained in:
parent
0c9e4b2dec
commit
4f1e07117c
25 changed files with 868 additions and 103 deletions
|
@ -16,6 +16,7 @@ import { useSourcererScope, getScopeFromPath } from '../../../../common/containe
|
|||
import { TimelineId } from '../../../../../common/types/timeline';
|
||||
import { AutoSaveWarningMsg } from '../../../../timelines/components/timeline/auto_save_warning';
|
||||
import { Flyout } from '../../../../timelines/components/flyout';
|
||||
import { useResolveRedirect } from '../../../../common/hooks/use_resolve_redirect';
|
||||
|
||||
export const BOTTOM_BAR_CLASSNAME = 'timeline-bottom-bar';
|
||||
|
||||
|
@ -26,6 +27,7 @@ export const SecuritySolutionBottomBar = React.memo(
|
|||
const [showTimeline] = useShowTimeline();
|
||||
|
||||
const { indicesExist } = useSourcererScope(getScopeFromPath(pathname));
|
||||
useResolveRedirect();
|
||||
|
||||
return indicesExist && showTimeline ? (
|
||||
<>
|
||||
|
|
|
@ -25,6 +25,7 @@ export const UseUrlStateMemo = React.memo(
|
|||
prevProps.pathName === nextProps.pathName &&
|
||||
deepEqual(prevProps.urlState, nextProps.urlState) &&
|
||||
deepEqual(prevProps.indexPattern, nextProps.indexPattern) &&
|
||||
prevProps.search === nextProps.search &&
|
||||
deepEqual(prevProps.navTabs, nextProps.navTabs)
|
||||
);
|
||||
|
||||
|
|
|
@ -124,7 +124,7 @@ export const useSetInitialStateFromUrl = () => {
|
|||
[dispatch, updateTimeline, updateTimelineIsLoading]
|
||||
);
|
||||
|
||||
return setInitialStateFromUrl;
|
||||
return Object.freeze({ setInitialStateFromUrl, updateTimeline, updateTimelineIsLoading });
|
||||
};
|
||||
|
||||
const updateTimerange = (newUrlStateString: string, dispatch: Dispatch) => {
|
||||
|
|
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { queryTimelineById } from '../../../timelines/components/open_timeline/helpers';
|
||||
import { queryTimelineByIdOnUrlChange } from './query_timeline_by_id_on_url_change';
|
||||
import * as urlHelpers from './helpers';
|
||||
|
||||
jest.mock('../../../timelines/components/open_timeline/helpers');
|
||||
|
||||
describe('queryTimelineByIdOnUrlChange', () => {
|
||||
const oldTestTimelineId = '04e8ffb0-2c2a-11ec-949c-39005af91f70';
|
||||
const newTestTimelineId = `${oldTestTimelineId}-newId`;
|
||||
const oldTimelineRisonSearchString = `?timeline=(activeTab:query,graphEventId:%27%27,id:%27${oldTestTimelineId}%27,isOpen:!t)`;
|
||||
const newTimelineRisonSearchString = `?timeline=(activeTab:query,graphEventId:%27%27,id:%27${newTestTimelineId}%27,isOpen:!t)`;
|
||||
const mockUpdateTimeline = jest.fn();
|
||||
const mockUpdateTimelineIsLoading = jest.fn();
|
||||
const mockQueryTimelineById = jest.fn();
|
||||
beforeEach(() => {
|
||||
(queryTimelineById as jest.Mock).mockImplementation(mockQueryTimelineById);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when search strings are empty', () => {
|
||||
it('should not call queryTimelineById', () => {
|
||||
queryTimelineByIdOnUrlChange({
|
||||
oldSearch: '',
|
||||
search: '',
|
||||
timelineIdFromReduxStore: 'current-timeline-id',
|
||||
updateTimeline: mockUpdateTimeline,
|
||||
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
|
||||
});
|
||||
|
||||
expect(queryTimelineById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when search string has not changed', () => {
|
||||
it('should not call queryTimelineById', () => {
|
||||
queryTimelineByIdOnUrlChange({
|
||||
oldSearch: oldTimelineRisonSearchString,
|
||||
search: oldTimelineRisonSearchString,
|
||||
timelineIdFromReduxStore: 'timeline-id',
|
||||
updateTimeline: mockUpdateTimeline,
|
||||
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
|
||||
});
|
||||
|
||||
expect(queryTimelineById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when decode rison fails', () => {
|
||||
it('should not call queryTimelineById', () => {
|
||||
jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementationOnce(() => {
|
||||
throw new Error('Unable to decode');
|
||||
});
|
||||
|
||||
queryTimelineByIdOnUrlChange({
|
||||
oldSearch: oldTimelineRisonSearchString,
|
||||
search: newTimelineRisonSearchString,
|
||||
timelineIdFromReduxStore: '',
|
||||
updateTimeline: mockUpdateTimeline,
|
||||
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
|
||||
});
|
||||
|
||||
expect(queryTimelineById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when new id is not provided', () => {
|
||||
it('should not call queryTimelineById', () => {
|
||||
queryTimelineByIdOnUrlChange({
|
||||
oldSearch: oldTimelineRisonSearchString,
|
||||
search: '?timeline=(activeTab:query)', // no id
|
||||
timelineIdFromReduxStore: newTestTimelineId,
|
||||
updateTimeline: mockUpdateTimeline,
|
||||
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
|
||||
});
|
||||
|
||||
expect(queryTimelineById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when new id matches the data in redux', () => {
|
||||
it('should not call queryTimelineById', () => {
|
||||
queryTimelineByIdOnUrlChange({
|
||||
oldSearch: oldTimelineRisonSearchString,
|
||||
search: newTimelineRisonSearchString,
|
||||
timelineIdFromReduxStore: newTestTimelineId,
|
||||
updateTimeline: mockUpdateTimeline,
|
||||
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
|
||||
});
|
||||
|
||||
expect(queryTimelineById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// You can only redirect or run into conflict scenarios when already viewing a timeline
|
||||
describe('when not actively on a page with timeline in the search field', () => {
|
||||
it('should not call queryTimelineById', () => {
|
||||
queryTimelineByIdOnUrlChange({
|
||||
oldSearch: '?random=foo',
|
||||
search: newTimelineRisonSearchString,
|
||||
timelineIdFromReduxStore: oldTestTimelineId,
|
||||
updateTimeline: mockUpdateTimeline,
|
||||
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
|
||||
});
|
||||
|
||||
expect(queryTimelineById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when an old timeline id exists, but a new id is given', () => {
|
||||
it('should call queryTimelineById', () => {
|
||||
queryTimelineByIdOnUrlChange({
|
||||
oldSearch: oldTimelineRisonSearchString,
|
||||
search: newTimelineRisonSearchString,
|
||||
timelineIdFromReduxStore: oldTestTimelineId,
|
||||
updateTimeline: mockUpdateTimeline,
|
||||
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
|
||||
});
|
||||
|
||||
expect(queryTimelineById).toBeCalledWith({
|
||||
activeTimelineTab: 'query',
|
||||
duplicate: false,
|
||||
graphEventId: '',
|
||||
timelineId: newTestTimelineId,
|
||||
openTimeline: true,
|
||||
updateIsLoading: mockUpdateTimelineIsLoading,
|
||||
updateTimeline: mockUpdateTimeline,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Action } from 'typescript-fsa';
|
||||
import { DispatchUpdateTimeline } from '../../../timelines/components/open_timeline/types';
|
||||
import { queryTimelineById } from '../../../timelines/components/open_timeline/helpers';
|
||||
import { TimelineTabs } from '../../../../common/types/timeline';
|
||||
import {
|
||||
decodeRisonUrlState,
|
||||
getQueryStringFromLocation,
|
||||
getParamFromQueryString,
|
||||
} from './helpers';
|
||||
import { TimelineUrl } from '../../../timelines/store/timeline/model';
|
||||
import { CONSTANTS } from './constants';
|
||||
|
||||
const getQueryStringKeyValue = ({ search, urlKey }: { search: string; urlKey: string }) =>
|
||||
getParamFromQueryString(getQueryStringFromLocation(search), urlKey);
|
||||
|
||||
interface QueryTimelineIdOnUrlChange {
|
||||
oldSearch?: string;
|
||||
search: string;
|
||||
timelineIdFromReduxStore: string;
|
||||
updateTimeline: DispatchUpdateTimeline;
|
||||
updateTimelineIsLoading: (status: { id: string; isLoading: boolean }) => Action<{
|
||||
id: string;
|
||||
isLoading: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* After the initial load of the security solution, timeline is not updated when the timeline url search value is changed
|
||||
* This is because those state changes happen in place and doesn't lead to a requerying of data for the new id.
|
||||
* To circumvent this for the sake of the redirects needed for the saved object Id changes happening in 8.0
|
||||
* We are actively pulling the id changes that take place for timeline in the url and calling the query below
|
||||
* to request the new data.
|
||||
*/
|
||||
export const queryTimelineByIdOnUrlChange = ({
|
||||
oldSearch,
|
||||
search,
|
||||
timelineIdFromReduxStore,
|
||||
updateTimeline,
|
||||
updateTimelineIsLoading,
|
||||
}: QueryTimelineIdOnUrlChange) => {
|
||||
const oldUrlStateString = getQueryStringKeyValue({
|
||||
urlKey: CONSTANTS.timeline,
|
||||
search: oldSearch ?? '',
|
||||
});
|
||||
|
||||
const newUrlStateString = getQueryStringKeyValue({ urlKey: CONSTANTS.timeline, search });
|
||||
|
||||
if (oldUrlStateString != null && newUrlStateString != null) {
|
||||
let newTimeline = null;
|
||||
let oldTimeline = null;
|
||||
try {
|
||||
newTimeline = decodeRisonUrlState<TimelineUrl>(newUrlStateString);
|
||||
} catch (error) {
|
||||
// do nothing as timeline is defaulted to null
|
||||
}
|
||||
|
||||
try {
|
||||
oldTimeline = decodeRisonUrlState<TimelineUrl>(oldUrlStateString);
|
||||
} catch (error) {
|
||||
// do nothing as timeline is defaulted to null
|
||||
}
|
||||
const newId = newTimeline?.id;
|
||||
const oldId = oldTimeline?.id;
|
||||
|
||||
if (newId && newId !== oldId && newId !== timelineIdFromReduxStore) {
|
||||
queryTimelineById({
|
||||
activeTimelineTab: newTimeline?.activeTab ?? TimelineTabs.query,
|
||||
duplicate: false,
|
||||
graphEventId: newTimeline?.graphEventId,
|
||||
timelineId: newId,
|
||||
openTimeline: true,
|
||||
updateIsLoading: updateTimelineIsLoading,
|
||||
updateTimeline,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
|
@ -79,6 +79,7 @@ export interface PreviousLocationUrlState {
|
|||
pathName: string | undefined;
|
||||
pageName: string | undefined;
|
||||
urlState: UrlState;
|
||||
search: string | undefined;
|
||||
}
|
||||
|
||||
export interface UrlStateToRedux {
|
||||
|
|
|
@ -39,6 +39,7 @@ import {
|
|||
} from './types';
|
||||
import { TimelineUrl } from '../../../timelines/store/timeline/model';
|
||||
import { UrlInputsModel } from '../../store/inputs/model';
|
||||
import { queryTimelineByIdOnUrlChange } from './query_timeline_by_id_on_url_change';
|
||||
|
||||
function usePrevious(value: PreviousLocationUrlState) {
|
||||
const ref = useRef<PreviousLocationUrlState>(value);
|
||||
|
@ -60,9 +61,10 @@ export const useUrlStateHooks = ({
|
|||
const [isFirstPageLoad, setIsFirstPageLoad] = useState(true);
|
||||
const { filterManager, savedQueries } = useKibana().services.data.query;
|
||||
const { pathname: browserPathName } = useLocation();
|
||||
const prevProps = usePrevious({ pathName, pageName, urlState });
|
||||
const prevProps = usePrevious({ pathName, pageName, urlState, search });
|
||||
|
||||
const setInitialStateFromUrl = useSetInitialStateFromUrl();
|
||||
const { setInitialStateFromUrl, updateTimeline, updateTimelineIsLoading } =
|
||||
useSetInitialStateFromUrl();
|
||||
|
||||
const handleInitialize = useCallback(
|
||||
(type: UrlStateType) => {
|
||||
|
@ -190,6 +192,16 @@ export const useUrlStateHooks = ({
|
|||
document.title = `${getTitle(pageName, navTabs)} - Kibana`;
|
||||
}, [pageName, navTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
queryTimelineByIdOnUrlChange({
|
||||
oldSearch: prevProps.search,
|
||||
search,
|
||||
timelineIdFromReduxStore: urlState.timeline.id,
|
||||
updateTimeline,
|
||||
updateTimelineIsLoading,
|
||||
});
|
||||
}, [search, prevProps.search, urlState.timeline.id, updateTimeline, updateTimelineIsLoading]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useDeepEqualSelector } from './use_selector';
|
||||
import { useKibana } from '../lib/kibana';
|
||||
import { useResolveConflict } from './use_resolve_conflict';
|
||||
import * as urlHelpers from '../components/url_state/helpers';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const original = jest.requireActual('react-router-dom');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useLocation: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../lib/kibana');
|
||||
jest.mock('./use_selector');
|
||||
jest.mock('../../timelines/store/timeline/', () => ({
|
||||
timelineSelectors: {
|
||||
getTimelineByIdSelector: () => jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useResolveConflict', () => {
|
||||
const mockGetLegacyUrlConflict = jest.fn().mockReturnValue('Test!');
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
// Mock rison format in actual url
|
||||
(useLocation as jest.Mock).mockReturnValue({
|
||||
pathname: 'my/cool/path',
|
||||
search:
|
||||
'timeline=(activeTab:query,graphEventId:%27%27,id:%2704e8ffb0-2c2a-11ec-949c-39005af91f70%27,isOpen:!t)',
|
||||
});
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
services: {
|
||||
spaces: {
|
||||
ui: {
|
||||
components: {
|
||||
getLegacyUrlConflict: mockGetLegacyUrlConflict,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('resolve object is not provided', () => {
|
||||
it('should not show the conflict message', async () => {
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
|
||||
savedObjectId: 'current-saved-object-id',
|
||||
activeTab: 'some-tab',
|
||||
graphEventId: 'current-graph-event-id',
|
||||
show: false,
|
||||
}));
|
||||
const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict());
|
||||
expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcome is exactMatch', () => {
|
||||
it('should not show the conflict message', async () => {
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
|
||||
resolveTimelineConfig: {
|
||||
outcome: 'exactMatch',
|
||||
},
|
||||
savedObjectId: 'current-saved-object-id',
|
||||
activeTab: 'some-tab',
|
||||
graphEventId: 'current-graph-event-id',
|
||||
show: false,
|
||||
}));
|
||||
const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict());
|
||||
expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcome is aliasMatch', () => {
|
||||
it('should not show the conflict message', async () => {
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
|
||||
resolveTimelineConfig: {
|
||||
outcome: 'aliasMatch',
|
||||
alias_target_id: 'new-id',
|
||||
},
|
||||
}));
|
||||
const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict());
|
||||
expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcome is conflict', () => {
|
||||
const mockTextContent = 'I am the visible conflict message';
|
||||
it('should show the conflict message', async () => {
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
|
||||
resolveTimelineConfig: {
|
||||
outcome: 'conflict',
|
||||
alias_target_id: 'new-id',
|
||||
},
|
||||
}));
|
||||
mockGetLegacyUrlConflict.mockImplementation(() => mockTextContent);
|
||||
const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict());
|
||||
expect(mockGetLegacyUrlConflict).toHaveBeenCalledWith({
|
||||
objectNoun: 'timeline',
|
||||
currentObjectId: '04e8ffb0-2c2a-11ec-949c-39005af91f70',
|
||||
otherObjectId: 'new-id',
|
||||
otherObjectPath:
|
||||
'my/cool/path?timeline=%28activeTab%3Aquery%2CgraphEventId%3A%27%27%2Cid%3Anew-id%2CisOpen%3A%21t%29',
|
||||
});
|
||||
expect(result.current).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
I am the visible conflict message
|
||||
<EuiSpacer />
|
||||
</React.Fragment>
|
||||
`);
|
||||
});
|
||||
|
||||
describe('rison is unable to be decoded', () => {
|
||||
it('should use timeline values from redux to create the otherObjectPath', async () => {
|
||||
jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementation(() => {
|
||||
throw new Error('Unable to decode');
|
||||
});
|
||||
(useLocation as jest.Mock).mockReturnValue({
|
||||
pathname: 'my/cool/path',
|
||||
search: '?foo=bar',
|
||||
});
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
|
||||
resolveTimelineConfig: {
|
||||
outcome: 'conflict',
|
||||
alias_target_id: 'new-id',
|
||||
},
|
||||
savedObjectId: 'current-saved-object-id',
|
||||
activeTab: 'some-tab',
|
||||
graphEventId: 'current-graph-event-id',
|
||||
show: false,
|
||||
}));
|
||||
mockGetLegacyUrlConflict.mockImplementation(() => mockTextContent);
|
||||
renderHook(() => useResolveConflict());
|
||||
const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict());
|
||||
expect(mockGetLegacyUrlConflict).toHaveBeenCalledWith({
|
||||
objectNoun: 'timeline',
|
||||
currentObjectId: 'current-saved-object-id',
|
||||
otherObjectId: 'new-id',
|
||||
otherObjectPath:
|
||||
'my/cool/path?foo=bar&timeline=%28activeTab%3Asome-tab%2CgraphEventId%3Acurrent-graph-event-id%2Cid%3Anew-id%2CisOpen%3A%21f%29',
|
||||
});
|
||||
expect(result.current).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
I am the visible conflict message
|
||||
<EuiSpacer />
|
||||
</React.Fragment>
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import { useDeepEqualSelector } from './use_selector';
|
||||
import { TimelineId } from '../../../common/types/timeline';
|
||||
import { timelineSelectors } from '../../timelines/store/timeline';
|
||||
import { TimelineUrl } from '../../timelines/store/timeline/model';
|
||||
import { timelineDefaults } from '../../timelines/store/timeline/defaults';
|
||||
import { decodeRisonUrlState, encodeRisonUrlState } from '../components/url_state/helpers';
|
||||
import { useKibana } from '../lib/kibana';
|
||||
import { CONSTANTS } from '../components/url_state/constants';
|
||||
|
||||
/**
|
||||
* Unfortunately the url change initiated when clicking the button to otherObjectPath doesn't seem to be
|
||||
* respected by the useSetInitialStateFromUrl here: x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx
|
||||
*
|
||||
* FYI: It looks like the routing causes replaceStateInLocation to be called instead:
|
||||
* x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts
|
||||
*
|
||||
* Potentially why the markdown component needs a click handler as well for timeline?
|
||||
* see: /x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx
|
||||
*/
|
||||
export const useResolveConflict = () => {
|
||||
const { search, pathname } = useLocation();
|
||||
const { spaces } = useKibana().services;
|
||||
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
|
||||
const { resolveTimelineConfig, savedObjectId, show, graphEventId, activeTab } =
|
||||
useDeepEqualSelector((state) => getTimeline(state, TimelineId.active) ?? timelineDefaults);
|
||||
|
||||
const getLegacyUrlConflictCallout = useCallback(() => {
|
||||
// This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario
|
||||
if (
|
||||
!spaces ||
|
||||
resolveTimelineConfig?.outcome !== 'conflict' ||
|
||||
resolveTimelineConfig?.alias_target_id == null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchQuery = new URLSearchParams(search);
|
||||
const timelineRison = searchQuery.get(CONSTANTS.timeline) ?? undefined;
|
||||
// Try to get state on URL, but default to what's in Redux in case of decodeRisonFailure
|
||||
const currentTimelineState = {
|
||||
id: savedObjectId ?? '',
|
||||
isOpen: !!show,
|
||||
activeTab,
|
||||
graphEventId,
|
||||
};
|
||||
let timelineSearch: TimelineUrl = currentTimelineState;
|
||||
try {
|
||||
timelineSearch = decodeRisonUrlState(timelineRison) ?? currentTimelineState;
|
||||
} catch (error) {
|
||||
// do nothing as it's already defaulted on line 77
|
||||
}
|
||||
// We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a
|
||||
// callout with a warning for the user, and provide a way for them to navigate to the other object.
|
||||
const currentObjectId = timelineSearch?.id;
|
||||
const newSavedObjectId = resolveTimelineConfig?.alias_target_id ?? ''; // This is always defined if outcome === 'conflict'
|
||||
|
||||
const newTimelineSearch: TimelineUrl = {
|
||||
...timelineSearch,
|
||||
id: newSavedObjectId,
|
||||
};
|
||||
const newTimelineRison = encodeRisonUrlState(newTimelineSearch);
|
||||
searchQuery.set(CONSTANTS.timeline, newTimelineRison);
|
||||
|
||||
const newPath = `${pathname}?${searchQuery.toString()}${window.location.hash}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{spaces.ui.components.getLegacyUrlConflict({
|
||||
objectNoun: CONSTANTS.timeline,
|
||||
currentObjectId,
|
||||
otherObjectId: newSavedObjectId,
|
||||
otherObjectPath: newPath,
|
||||
})}
|
||||
<EuiSpacer />
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
activeTab,
|
||||
graphEventId,
|
||||
pathname,
|
||||
resolveTimelineConfig?.alias_target_id,
|
||||
resolveTimelineConfig?.outcome,
|
||||
savedObjectId,
|
||||
search,
|
||||
show,
|
||||
spaces,
|
||||
]);
|
||||
|
||||
return useMemo(() => getLegacyUrlConflictCallout(), [getLegacyUrlConflictCallout]);
|
||||
};
|
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useDeepEqualSelector } from './use_selector';
|
||||
import { useKibana } from '../lib/kibana';
|
||||
import { useResolveRedirect } from './use_resolve_redirect';
|
||||
import * as urlHelpers from '../components/url_state/helpers';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const original = jest.requireActual('react-router-dom');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useLocation: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../lib/kibana');
|
||||
jest.mock('./use_selector');
|
||||
jest.mock('../../timelines/store/timeline/', () => ({
|
||||
timelineSelectors: {
|
||||
getTimelineByIdSelector: () => jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useResolveRedirect', () => {
|
||||
const mockRedirectLegacyUrl = jest.fn();
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
// Mock rison format in actual url
|
||||
(useLocation as jest.Mock).mockReturnValue({
|
||||
pathname: 'my/cool/path',
|
||||
search:
|
||||
'timeline=(activeTab:query,graphEventId:%27%27,id:%2704e8ffb0-2c2a-11ec-949c-39005af91f70%27,isOpen:!t)',
|
||||
});
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
services: {
|
||||
spaces: {
|
||||
ui: {
|
||||
redirectLegacyUrl: mockRedirectLegacyUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('resolve object is not provided', () => {
|
||||
it('should not redirect', async () => {
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
|
||||
savedObjectId: 'current-saved-object-id',
|
||||
activeTab: 'some-tab',
|
||||
graphEventId: 'current-graph-event-id',
|
||||
show: false,
|
||||
}));
|
||||
renderHook(() => useResolveRedirect());
|
||||
expect(mockRedirectLegacyUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcome is exactMatch', () => {
|
||||
it('should not redirect', async () => {
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
|
||||
resolveTimelineConfig: {
|
||||
outcome: 'exactMatch',
|
||||
},
|
||||
savedObjectId: 'current-saved-object-id',
|
||||
activeTab: 'some-tab',
|
||||
graphEventId: 'current-graph-event-id',
|
||||
show: false,
|
||||
}));
|
||||
renderHook(() => useResolveRedirect());
|
||||
expect(mockRedirectLegacyUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcome is aliasMatch', () => {
|
||||
it('should redirect to url with id:new-id if outcome is aliasMatch', async () => {
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
|
||||
resolveTimelineConfig: {
|
||||
outcome: 'aliasMatch',
|
||||
alias_target_id: 'new-id',
|
||||
},
|
||||
}));
|
||||
renderHook(() => useResolveRedirect());
|
||||
expect(mockRedirectLegacyUrl).toHaveBeenCalledWith(
|
||||
'my/cool/path?timeline=%28activeTab%3Aquery%2CgraphEventId%3A%27%27%2Cid%3Anew-id%2CisOpen%3A%21t%29',
|
||||
'timeline'
|
||||
);
|
||||
});
|
||||
|
||||
describe('rison is unable to be decoded', () => {
|
||||
it('should use timeline values from redux to create the redirect path', async () => {
|
||||
jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementation(() => {
|
||||
throw new Error('Unable to decode');
|
||||
});
|
||||
(useLocation as jest.Mock).mockReturnValue({
|
||||
pathname: 'my/cool/path',
|
||||
search: '?foo=bar',
|
||||
});
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
|
||||
resolveTimelineConfig: {
|
||||
outcome: 'aliasMatch',
|
||||
alias_target_id: 'new-id',
|
||||
},
|
||||
savedObjectId: 'current-saved-object-id',
|
||||
activeTab: 'some-tab',
|
||||
graphEventId: 'current-graph-event-id',
|
||||
show: false,
|
||||
}));
|
||||
renderHook(() => useResolveRedirect());
|
||||
expect(mockRedirectLegacyUrl).toHaveBeenCalledWith(
|
||||
'my/cool/path?foo=bar&timeline=%28activeTab%3Asome-tab%2CgraphEventId%3Acurrent-graph-event-id%2Cid%3Anew-id%2CisOpen%3A%21f%29',
|
||||
'timeline'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcome is conflict', () => {
|
||||
it('should not redirect', async () => {
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
|
||||
resolveTimelineConfig: {
|
||||
outcome: 'conflict',
|
||||
alias_target_id: 'new-id',
|
||||
},
|
||||
}));
|
||||
renderHook(() => useResolveRedirect());
|
||||
expect(mockRedirectLegacyUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useDeepEqualSelector } from './use_selector';
|
||||
import { TimelineId } from '../../../common/types/timeline';
|
||||
import { timelineSelectors } from '../../timelines/store/timeline/';
|
||||
import { timelineDefaults } from '../../timelines/store/timeline/defaults';
|
||||
import { decodeRisonUrlState, encodeRisonUrlState } from '../components/url_state/helpers';
|
||||
import { useKibana } from '../lib/kibana';
|
||||
import { TimelineUrl } from '../../timelines/store/timeline/model';
|
||||
import { CONSTANTS } from '../components/url_state/constants';
|
||||
|
||||
/**
|
||||
* This hooks is specifically for use with the resolve api that was introduced as part of 7.16
|
||||
* If a deep link id has been migrated to a new id, this hook will cause a redirect to a url with
|
||||
* the new ID.
|
||||
*/
|
||||
|
||||
export const useResolveRedirect = () => {
|
||||
const { search, pathname } = useLocation();
|
||||
const [hasRedirected, updateHasRedirected] = useState(false);
|
||||
const { spaces } = useKibana().services;
|
||||
|
||||
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
|
||||
const { resolveTimelineConfig, savedObjectId, show, activeTab, graphEventId } =
|
||||
useDeepEqualSelector((state) => getTimeline(state, TimelineId.active) ?? timelineDefaults);
|
||||
|
||||
const redirect = useCallback(() => {
|
||||
const searchQuery = new URLSearchParams(search);
|
||||
const timelineRison = searchQuery.get(CONSTANTS.timeline) ?? undefined;
|
||||
|
||||
// Try to get state on URL, but default to what's in Redux in case of decodeRisonFailure
|
||||
const currentTimelineState = {
|
||||
id: savedObjectId ?? '',
|
||||
isOpen: !!show,
|
||||
activeTab,
|
||||
graphEventId,
|
||||
};
|
||||
let timelineSearch: TimelineUrl = currentTimelineState;
|
||||
try {
|
||||
timelineSearch = decodeRisonUrlState(timelineRison) ?? currentTimelineState;
|
||||
} catch (error) {
|
||||
// do nothing as it's already defaulted on line 77
|
||||
}
|
||||
|
||||
if (
|
||||
hasRedirected ||
|
||||
!spaces ||
|
||||
resolveTimelineConfig?.outcome !== 'aliasMatch' ||
|
||||
resolveTimelineConfig?.alias_target_id == null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash
|
||||
const newObjectId = resolveTimelineConfig?.alias_target_id ?? ''; // This is always defined if outcome === 'aliasMatch'
|
||||
const newTimelineSearch = {
|
||||
...timelineSearch,
|
||||
id: newObjectId,
|
||||
};
|
||||
const newTimelineRison = encodeRisonUrlState(newTimelineSearch);
|
||||
searchQuery.set(CONSTANTS.timeline, newTimelineRison);
|
||||
const newPath = `${pathname}?${searchQuery.toString()}`;
|
||||
spaces.ui.redirectLegacyUrl(newPath, CONSTANTS.timeline);
|
||||
// Prevent the effect from being called again as the url change takes place in location rather than a true redirect
|
||||
updateHasRedirected(true);
|
||||
}, [
|
||||
activeTab,
|
||||
graphEventId,
|
||||
hasRedirected,
|
||||
pathname,
|
||||
resolveTimelineConfig?.outcome,
|
||||
resolveTimelineConfig?.alias_target_id,
|
||||
savedObjectId,
|
||||
search,
|
||||
show,
|
||||
spaces,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
redirect();
|
||||
}, [redirect]);
|
||||
};
|
|
@ -9,7 +9,7 @@ import { TimelineStatus, TimelineType } from '../../../../../common/types/timeli
|
|||
|
||||
export const mockTimeline = {
|
||||
data: {
|
||||
getOneTimeline: {
|
||||
timeline: {
|
||||
savedObjectId: 'eb2781c0-1df5-11eb-8589-2f13958b79f7',
|
||||
columns: [
|
||||
{
|
||||
|
@ -163,6 +163,7 @@ export const mockTimeline = {
|
|||
version: 'WzQ4NSwxXQ==',
|
||||
__typename: 'TimelineResult',
|
||||
},
|
||||
outcome: 'exactMatch',
|
||||
},
|
||||
loading: false,
|
||||
networkStatus: 7,
|
||||
|
@ -171,7 +172,7 @@ export const mockTimeline = {
|
|||
|
||||
export const mockTemplate = {
|
||||
data: {
|
||||
getOneTimeline: {
|
||||
timeline: {
|
||||
savedObjectId: '0c70a200-1de0-11eb-885c-6fc13fca1850',
|
||||
columns: [
|
||||
{
|
||||
|
@ -416,6 +417,7 @@ export const mockTemplate = {
|
|||
version: 'WzQwMywxXQ==',
|
||||
__typename: 'TimelineResult',
|
||||
},
|
||||
outcome: 'exactMatch',
|
||||
},
|
||||
loading: false,
|
||||
networkStatus: 7,
|
||||
|
|
|
@ -50,7 +50,7 @@ import {
|
|||
mockTimeline as mockSelectedTimeline,
|
||||
mockTemplate as mockSelectedTemplate,
|
||||
} from './__mocks__';
|
||||
import { getTimeline } from '../../containers/api';
|
||||
import { resolveTimeline } from '../../containers/api';
|
||||
import { defaultHeaders } from '../timeline/body/column_headers/default_headers';
|
||||
|
||||
jest.mock('../../../common/store/inputs/actions');
|
||||
|
@ -951,7 +951,7 @@ describe('helpers', () => {
|
|||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
(getTimeline as jest.Mock).mockRejectedValue(mockError);
|
||||
(resolveTimeline as jest.Mock).mockRejectedValue(mockError);
|
||||
queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>);
|
||||
});
|
||||
|
||||
|
@ -986,7 +986,7 @@ describe('helpers', () => {
|
|||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
(getTimeline as jest.Mock).mockResolvedValue(selectedTimeline);
|
||||
(resolveTimeline as jest.Mock).mockResolvedValue(selectedTimeline);
|
||||
await queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>);
|
||||
});
|
||||
|
||||
|
@ -1002,7 +1002,7 @@ describe('helpers', () => {
|
|||
});
|
||||
|
||||
test('get timeline by Id', () => {
|
||||
expect(getTimeline).toHaveBeenCalled();
|
||||
expect(resolveTimeline).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('it does not call onError when an error does not occur', () => {
|
||||
|
@ -1011,7 +1011,7 @@ describe('helpers', () => {
|
|||
|
||||
test('Do not override daterange if TimelineStatus is active', () => {
|
||||
const { timeline } = formatTimelineResultToModel(
|
||||
omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', selectedTimeline)),
|
||||
omitTypenameInTimeline(getOr({}, 'data.timeline', selectedTimeline)),
|
||||
args.duplicate,
|
||||
args.timelineType
|
||||
);
|
||||
|
@ -1044,7 +1044,7 @@ describe('helpers', () => {
|
|||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
(getTimeline as jest.Mock).mockResolvedValue(selectedTimeline);
|
||||
(resolveTimeline as jest.Mock).mockResolvedValue(selectedTimeline);
|
||||
await queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>);
|
||||
});
|
||||
|
||||
|
@ -1060,12 +1060,12 @@ describe('helpers', () => {
|
|||
});
|
||||
|
||||
test('get timeline by Id', () => {
|
||||
expect(getTimeline).toHaveBeenCalled();
|
||||
expect(resolveTimeline).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not override daterange if TimelineStatus is active', () => {
|
||||
const { timeline } = formatTimelineResultToModel(
|
||||
omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', selectedTimeline)),
|
||||
omitTypenameInTimeline(getOr({}, 'data.timeline', selectedTimeline)),
|
||||
args.duplicate,
|
||||
args.timelineType
|
||||
);
|
||||
|
@ -1085,6 +1085,10 @@ describe('helpers', () => {
|
|||
to: '2020-07-08T08:20:18.966Z',
|
||||
notes: [],
|
||||
id: TimelineId.active,
|
||||
resolveTimelineConfig: {
|
||||
outcome: 'exactMatch',
|
||||
alias_target_id: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1112,12 +1116,12 @@ describe('helpers', () => {
|
|||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
(getTimeline as jest.Mock).mockResolvedValue(template);
|
||||
(resolveTimeline as jest.Mock).mockResolvedValue(template);
|
||||
await queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
(getTimeline as jest.Mock).mockReset();
|
||||
(resolveTimeline as jest.Mock).mockReset();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
|
@ -1129,12 +1133,12 @@ describe('helpers', () => {
|
|||
});
|
||||
|
||||
test('get timeline by Id', () => {
|
||||
expect(getTimeline).toHaveBeenCalled();
|
||||
expect(resolveTimeline).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('override daterange if TimelineStatus is immutable', () => {
|
||||
const { timeline } = formatTimelineResultToModel(
|
||||
omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', template)),
|
||||
omitTypenameInTimeline(getOr({}, 'data.timeline', template)),
|
||||
args.duplicate,
|
||||
args.timelineType
|
||||
);
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
TimelineType,
|
||||
TimelineTabs,
|
||||
TimelineResult,
|
||||
SingleTimelineResolveResponse,
|
||||
ColumnHeaderResult,
|
||||
FilterTimelineResult,
|
||||
DataProviderResult,
|
||||
|
@ -65,7 +66,7 @@ import {
|
|||
DEFAULT_FROM_MOMENT,
|
||||
DEFAULT_TO_MOMENT,
|
||||
} from '../../../common/utils/default_date_settings';
|
||||
import { getTimeline } from '../../containers/api';
|
||||
import { resolveTimeline } from '../../containers/api';
|
||||
import { PinnedEvent } from '../../../../common/types/timeline/pinned_event';
|
||||
import { NoteResult } from '../../../../common/types/timeline/note';
|
||||
|
||||
|
@ -346,11 +347,12 @@ export const queryTimelineById = <TCache>({
|
|||
updateTimeline,
|
||||
}: QueryTimelineById<TCache>) => {
|
||||
updateIsLoading({ id: TimelineId.active, isLoading: true });
|
||||
Promise.resolve(getTimeline(timelineId))
|
||||
Promise.resolve(resolveTimeline(timelineId))
|
||||
.then((result) => {
|
||||
const timelineToOpen: TimelineResult = omitTypenameInTimeline(
|
||||
getOr({}, 'data.getOneTimeline', result)
|
||||
);
|
||||
const data: SingleTimelineResolveResponse['data'] | null = getOr(null, 'data', result);
|
||||
if (!data) return;
|
||||
|
||||
const timelineToOpen = omitTypenameInTimeline(data.timeline);
|
||||
|
||||
const { timeline, notes } = formatTimelineResultToModel(
|
||||
timelineToOpen,
|
||||
|
@ -370,6 +372,10 @@ export const queryTimelineById = <TCache>({
|
|||
from,
|
||||
id: TimelineId.active,
|
||||
notes,
|
||||
resolveTimelineConfig: {
|
||||
outcome: data.outcome,
|
||||
alias_target_id: data.alias_target_id,
|
||||
},
|
||||
timeline: {
|
||||
...timeline,
|
||||
activeTab: activeTimelineTab,
|
||||
|
@ -399,6 +405,7 @@ export const dispatchUpdateTimeline =
|
|||
forceNotes = false,
|
||||
from,
|
||||
notes,
|
||||
resolveTimelineConfig,
|
||||
timeline,
|
||||
to,
|
||||
ruleNote,
|
||||
|
@ -429,7 +436,9 @@ export const dispatchUpdateTimeline =
|
|||
} else {
|
||||
dispatch(dispatchSetTimelineRangeDatePicker({ from, to }));
|
||||
}
|
||||
dispatch(dispatchAddTimeline({ id, timeline, savedTimeline: duplicate }));
|
||||
dispatch(
|
||||
dispatchAddTimeline({ id, timeline, resolveTimelineConfig, savedTimeline: duplicate })
|
||||
);
|
||||
if (
|
||||
timeline.kqlQuery != null &&
|
||||
timeline.kqlQuery.filterQuery != null &&
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
TemplateTimelineTypeLiteral,
|
||||
RowRendererId,
|
||||
TimelineStatusLiteralWithNull,
|
||||
SingleTimelineResolveResponse,
|
||||
} from '../../../../common/types/timeline';
|
||||
|
||||
/** The users who added a timeline to favorites */
|
||||
|
@ -194,12 +195,17 @@ export interface OpenTimelineProps {
|
|||
hideActions?: ActionTimelineToShow[];
|
||||
}
|
||||
|
||||
export interface ResolveTimelineConfig {
|
||||
alias_target_id: SingleTimelineResolveResponse['data']['alias_target_id'];
|
||||
outcome: SingleTimelineResolveResponse['data']['outcome'];
|
||||
}
|
||||
export interface UpdateTimeline {
|
||||
duplicate: boolean;
|
||||
id: string;
|
||||
forceNotes?: boolean;
|
||||
from: string;
|
||||
notes: NoteResult[] | null | undefined;
|
||||
resolveTimelineConfig?: ResolveTimelineConfig;
|
||||
timeline: TimelineModel;
|
||||
to: string;
|
||||
ruleNote?: string;
|
||||
|
@ -210,6 +216,7 @@ export type DispatchUpdateTimeline = ({
|
|||
id,
|
||||
from,
|
||||
notes,
|
||||
resolveTimelineConfig,
|
||||
timeline,
|
||||
to,
|
||||
ruleNote,
|
||||
|
|
|
@ -40,6 +40,12 @@ const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
|
|||
jest.mock('use-resize-observer/polyfilled');
|
||||
mockUseResizeObserver.mockImplementation(() => ({}));
|
||||
|
||||
jest.mock('../../../common/hooks/use_resolve_conflict', () => {
|
||||
return {
|
||||
useResolveConflict: jest.fn().mockImplementation(() => null),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const original = jest.requireActual('react-router-dom');
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ import { TabsContent } from './tabs_content';
|
|||
import { HideShowContainer, TimelineContainer } from './styles';
|
||||
import { useTimelineFullScreen } from '../../../common/containers/use_full_screen';
|
||||
import { EXIT_FULL_SCREEN_CLASS_NAME } from '../../../common/components/exit_full_screen';
|
||||
import { useResolveConflict } from '../../../common/hooks/use_resolve_conflict';
|
||||
|
||||
const TimelineTemplateBadge = styled.div`
|
||||
background: ${({ theme }) => theme.eui.euiColorVis3_behindText};
|
||||
|
@ -119,6 +120,7 @@ const StatefulTimelineComponent: React.FC<Props> = ({
|
|||
[containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable]
|
||||
);
|
||||
const timelineContext = useMemo(() => ({ timelineId }), [timelineId]);
|
||||
const resolveConflictComponent = useResolveConflict();
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider value={timelineContext}>
|
||||
|
@ -132,7 +134,7 @@ const StatefulTimelineComponent: React.FC<Props> = ({
|
|||
{timelineType === TimelineType.template && (
|
||||
<TimelineTemplateBadge>{i18n.TIMELINE_TEMPLATE}</TimelineTemplateBadge>
|
||||
)}
|
||||
|
||||
{resolveConflictComponent}
|
||||
<HideShowContainer
|
||||
$isVisible={!timelineFullScreen}
|
||||
data-test-subj="timeline-hide-show-container"
|
||||
|
|
|
@ -338,19 +338,6 @@ export const getTimelineTemplate = async (templateTimelineId: string) => {
|
|||
return decodeSingleTimelineResponse(response);
|
||||
};
|
||||
|
||||
export const getResolvedTimelineTemplate = async (templateTimelineId: string) => {
|
||||
const response = await KibanaServices.get().http.get<SingleTimelineResolveResponse>(
|
||||
TIMELINE_RESOLVE_URL,
|
||||
{
|
||||
query: {
|
||||
template_timeline_id: templateTimelineId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return decodeResolvedSingleTimelineResponse(response);
|
||||
};
|
||||
|
||||
export const getAllTimelines = async (args: GetTimelinesArgs, abortSignal: AbortSignal) => {
|
||||
const response = await KibanaServices.get().http.fetch<AllTimelinesResponse>(TIMELINES_URL, {
|
||||
method: 'GET',
|
||||
|
|
|
@ -25,6 +25,7 @@ import type {
|
|||
SerializedFilterQuery,
|
||||
} from '../../../../common/types/timeline';
|
||||
import { tGridActions } from '../../../../../timelines/public';
|
||||
import { ResolveTimelineConfig } from '../../components/open_timeline/types';
|
||||
export const {
|
||||
applyDeltaToColumnWidth,
|
||||
clearEventsDeleted,
|
||||
|
@ -91,6 +92,7 @@ export const updateTimeline = actionCreator<{
|
|||
export const addTimeline = actionCreator<{
|
||||
id: string;
|
||||
timeline: TimelineModel;
|
||||
resolveTimelineConfig?: ResolveTimelineConfig;
|
||||
savedTimeline?: boolean;
|
||||
}>('ADD_TIMELINE');
|
||||
|
||||
|
|
|
@ -14,64 +14,65 @@ import { SubsetTimelineModel, TimelineModel } from './model';
|
|||
// normalizeTimeRange uses getTimeRangeSettings which cannot be used outside Kibana context if the uiSettings is not false
|
||||
const { from: start, to: end } = normalizeTimeRange({ from: '', to: '' }, false);
|
||||
|
||||
export const timelineDefaults: SubsetTimelineModel & Pick<TimelineModel, 'filters' | 'eqlOptions'> =
|
||||
{
|
||||
activeTab: TimelineTabs.query,
|
||||
prevActiveTab: TimelineTabs.query,
|
||||
columns: defaultHeaders,
|
||||
documentType: '',
|
||||
defaultColumns: defaultHeaders,
|
||||
dataProviders: [],
|
||||
dateRange: { start, end },
|
||||
deletedEventIds: [],
|
||||
description: '',
|
||||
eqlOptions: {
|
||||
eventCategoryField: 'event.category',
|
||||
tiebreakerField: '',
|
||||
timestampField: '@timestamp',
|
||||
query: '',
|
||||
size: 100,
|
||||
export const timelineDefaults: SubsetTimelineModel &
|
||||
Pick<TimelineModel, 'filters' | 'eqlOptions' | 'resolveTimelineConfig'> = {
|
||||
activeTab: TimelineTabs.query,
|
||||
prevActiveTab: TimelineTabs.query,
|
||||
columns: defaultHeaders,
|
||||
documentType: '',
|
||||
defaultColumns: defaultHeaders,
|
||||
dataProviders: [],
|
||||
dateRange: { start, end },
|
||||
deletedEventIds: [],
|
||||
description: '',
|
||||
eqlOptions: {
|
||||
eventCategoryField: 'event.category',
|
||||
tiebreakerField: '',
|
||||
timestampField: '@timestamp',
|
||||
query: '',
|
||||
size: 100,
|
||||
},
|
||||
eventType: 'all',
|
||||
eventIdToNoteIds: {},
|
||||
excludedRowRendererIds: [],
|
||||
expandedDetail: {},
|
||||
highlightedDropAndProviderId: '',
|
||||
historyIds: [],
|
||||
filters: [],
|
||||
indexNames: [],
|
||||
isFavorite: false,
|
||||
isLive: false,
|
||||
isSelectAllChecked: false,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
itemsPerPage: 25,
|
||||
itemsPerPageOptions: [10, 25, 50, 100],
|
||||
kqlMode: 'filter',
|
||||
kqlQuery: {
|
||||
filterQuery: null,
|
||||
},
|
||||
loadingEventIds: [],
|
||||
resolveTimelineConfig: undefined,
|
||||
queryFields: [],
|
||||
title: '',
|
||||
timelineType: TimelineType.default,
|
||||
templateTimelineId: null,
|
||||
templateTimelineVersion: null,
|
||||
noteIds: [],
|
||||
pinnedEventIds: {},
|
||||
pinnedEventsSaveObject: {},
|
||||
savedObjectId: null,
|
||||
selectAll: false,
|
||||
selectedEventIds: {},
|
||||
show: false,
|
||||
showCheckboxes: false,
|
||||
sort: [
|
||||
{
|
||||
columnId: '@timestamp',
|
||||
columnType: 'number',
|
||||
sortDirection: 'desc',
|
||||
},
|
||||
eventType: 'all',
|
||||
eventIdToNoteIds: {},
|
||||
excludedRowRendererIds: [],
|
||||
expandedDetail: {},
|
||||
highlightedDropAndProviderId: '',
|
||||
historyIds: [],
|
||||
filters: [],
|
||||
indexNames: [],
|
||||
isFavorite: false,
|
||||
isLive: false,
|
||||
isSelectAllChecked: false,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
itemsPerPage: 25,
|
||||
itemsPerPageOptions: [10, 25, 50, 100],
|
||||
kqlMode: 'filter',
|
||||
kqlQuery: {
|
||||
filterQuery: null,
|
||||
},
|
||||
loadingEventIds: [],
|
||||
queryFields: [],
|
||||
title: '',
|
||||
timelineType: TimelineType.default,
|
||||
templateTimelineId: null,
|
||||
templateTimelineVersion: null,
|
||||
noteIds: [],
|
||||
pinnedEventIds: {},
|
||||
pinnedEventsSaveObject: {},
|
||||
savedObjectId: null,
|
||||
selectAll: false,
|
||||
selectedEventIds: {},
|
||||
show: false,
|
||||
showCheckboxes: false,
|
||||
sort: [
|
||||
{
|
||||
columnId: '@timestamp',
|
||||
columnType: 'number',
|
||||
sortDirection: 'desc',
|
||||
},
|
||||
],
|
||||
status: TimelineStatus.draft,
|
||||
version: null,
|
||||
};
|
||||
],
|
||||
status: TimelineStatus.draft,
|
||||
version: null,
|
||||
};
|
||||
|
|
|
@ -47,6 +47,7 @@ import {
|
|||
RESIZED_COLUMN_MIN_WITH,
|
||||
} from '../../components/timeline/body/constants';
|
||||
import { activeTimeline } from '../../containers/active_timeline_context';
|
||||
import { ResolveTimelineConfig } from '../../components/open_timeline/types';
|
||||
|
||||
export const isNotNull = <T>(value: T | null): value is T => value !== null;
|
||||
|
||||
|
@ -124,6 +125,7 @@ export const addTimelineNoteToEvent = ({
|
|||
|
||||
interface AddTimelineParams {
|
||||
id: string;
|
||||
resolveTimelineConfig?: ResolveTimelineConfig;
|
||||
timeline: TimelineModel;
|
||||
timelineById: TimelineById;
|
||||
}
|
||||
|
@ -145,6 +147,7 @@ export const shouldResetActiveTimelineContext = (
|
|||
*/
|
||||
export const addTimelineToStore = ({
|
||||
id,
|
||||
resolveTimelineConfig,
|
||||
timeline,
|
||||
timelineById,
|
||||
}: AddTimelineParams): TimelineById => {
|
||||
|
@ -159,6 +162,7 @@ export const addTimelineToStore = ({
|
|||
filterManager: timelineById[id].filterManager,
|
||||
isLoading: timelineById[id].isLoading,
|
||||
initialized: timelineById[id].initialized,
|
||||
resolveTimelineConfig,
|
||||
dateRange:
|
||||
timeline.status === TimelineStatus.immutable &&
|
||||
timeline.timelineType === TimelineType.template
|
||||
|
|
|
@ -15,6 +15,7 @@ import type {
|
|||
} from '../../../../common/types/timeline';
|
||||
import { PinnedEvent } from '../../../../common/types/timeline/pinned_event';
|
||||
import type { TGridModelForTimeline } from '../../../../../timelines/public';
|
||||
import { ResolveTimelineConfig } from '../../components/open_timeline/types';
|
||||
|
||||
export const DEFAULT_PAGE_COUNT = 2; // Eui Pager will not render unless this is a minimum of 2 pages
|
||||
export type KqlMode = 'filter' | 'search';
|
||||
|
@ -59,6 +60,7 @@ export type TimelineModel = TGridModelForTimeline & {
|
|||
/** Events pinned to this timeline */
|
||||
pinnedEventIds: Record<string, boolean>;
|
||||
pinnedEventsSaveObject: Record<string, PinnedEvent>;
|
||||
resolveTimelineConfig?: ResolveTimelineConfig;
|
||||
showSaveModal?: boolean;
|
||||
savedQueryId?: string | null;
|
||||
/** When true, show the timeline flyover */
|
||||
|
|
|
@ -94,9 +94,14 @@ export const initialTimelineState: TimelineState = {
|
|||
|
||||
/** The reducer for all timeline actions */
|
||||
export const timelineReducer = reducerWithInitialState(initialTimelineState)
|
||||
.case(addTimeline, (state, { id, timeline }) => ({
|
||||
.case(addTimeline, (state, { id, timeline, resolveTimelineConfig }) => ({
|
||||
...state,
|
||||
timelineById: addTimelineToStore({ id, timeline, timelineById: state.timelineById }),
|
||||
timelineById: addTimelineToStore({
|
||||
id,
|
||||
timeline,
|
||||
resolveTimelineConfig,
|
||||
timelineById: state.timelineById,
|
||||
}),
|
||||
}))
|
||||
.case(createTimeline, (state, { id, timelineType = TimelineType.default, ...timelineProps }) => {
|
||||
return {
|
||||
|
|
|
@ -9,7 +9,6 @@ import { CoreStart } from '../../../../src/core/public';
|
|||
import { HomePublicPluginSetup } from '../../../../src/plugins/home/public';
|
||||
import { DataPublicPluginStart } from '../../../../src/plugins/data/public';
|
||||
import { EmbeddableStart } from '../../../../src/plugins/embeddable/public';
|
||||
import { SpacesPluginStart } from '../../../plugins/spaces/public';
|
||||
import { LensPublicStart } from '../../../plugins/lens/public';
|
||||
import { NewsfeedPublicPluginStart } from '../../../../src/plugins/newsfeed/public';
|
||||
import { Start as InspectorStart } from '../../../../src/plugins/inspector/public';
|
||||
|
@ -18,6 +17,7 @@ import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/p
|
|||
import { Storage } from '../../../../src/plugins/kibana_utils/public';
|
||||
import { FleetStart } from '../../fleet/public';
|
||||
import { PluginStart as ListsPluginStart } from '../../lists/public';
|
||||
import { SpacesPluginStart } from '../../spaces/public';
|
||||
import {
|
||||
TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup,
|
||||
TriggersAndActionsUIPublicPluginStart as TriggersActionsStart,
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
|
@ -40,7 +39,7 @@
|
|||
{ "path": "../maps/tsconfig.json" },
|
||||
{ "path": "../ml/tsconfig.json" },
|
||||
{ "path": "../spaces/tsconfig.json" },
|
||||
{ "path": "../security/tsconfig.json"},
|
||||
{ "path": "../timelines/tsconfig.json"},
|
||||
{ "path": "../security/tsconfig.json" },
|
||||
{ "path": "../timelines/tsconfig.json" }
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue