[SECURITY SOLUTIONS] Keep context of timeline when switching tabs in security solutions (#82237)

* try to keep timeline context when switching tabs

* fix popover

* simpler solution to keep timelien context between tabs

* fix timeline context with relative date

* allow update on the kql bar when opening new timeline

* keep detail view in context when savedObjectId of the timeline does not chnage

* remove redux solution and just KISS it

* add unit test for the popover

* add test on timeline context cache

* final commit -> to fix context of timeline between tabs

* keep timerange kind to absolute when refreshing

* fix bug today/thiw week to be absolute and not relative

* add unit test for absolute date for today and this week

* fix absolute today/this week on timeline

* fix refresh between page and timeline when link

* clean up

* remove nit

Co-authored-by: Patryk Kopycinski <contact@patrykkopycinski.com>
This commit is contained in:
Xavier Mouligneau 2020-11-05 19:45:10 -05:00 committed by GitHub
parent 8cdf56636a
commit f3599fec4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 749 additions and 312 deletions

View file

@ -62,7 +62,10 @@ export const QueryBar = memo<QueryBarComponentProps>(
const [draftQuery, setDraftQuery] = useState(filterQuery);
useEffect(() => {
// Reset draftQuery when `Create new timeline` is clicked
setDraftQuery(filterQuery);
}, [filterQuery]);
useEffect(() => {
if (filterQueryDraft == null) {
setDraftQuery(filterQuery);
}

View file

@ -132,7 +132,7 @@ export const SearchBarComponent = memo<SiemSearchBarProps & PropsFromRedux>(
if (!isStateUpdated) {
// That mean we are doing a refresh!
if (isQuickSelection) {
if (isQuickSelection && payload.dateRange.to !== payload.dateRange.from) {
updateSearchBar.updateTime = true;
updateSearchBar.end = payload.dateRange.to;
updateSearchBar.start = payload.dateRange.from;
@ -313,7 +313,7 @@ const makeMapStateToProps = () => {
fromStr: getFromStrSelector(inputsRange),
filterQuery: getFilterQuerySelector(inputsRange),
isLoading: getIsLoadingSelector(inputsRange),
queries: getQueriesSelector(inputsRange),
queries: getQueriesSelector(state, id),
savedQuery: getSavedQuerySelector(inputsRange),
start: getStartSelector(inputsRange),
toStr: getToStrSelector(inputsRange),
@ -351,15 +351,27 @@ export const dispatchUpdateSearch = (dispatch: Dispatch) => ({
const fromDate = formatDate(start);
let toDate = formatDate(end, { roundUp: true });
if (isQuickSelection) {
dispatch(
inputsActions.setRelativeRangeDatePicker({
id,
fromStr: start,
toStr: end,
from: fromDate,
to: toDate,
})
);
if (end === start) {
dispatch(
inputsActions.setAbsoluteRangeDatePicker({
id,
fromStr: start,
toStr: end,
from: fromDate,
to: toDate,
})
);
} else {
dispatch(
inputsActions.setRelativeRangeDatePicker({
id,
fromStr: start,
toStr: end,
from: fromDate,
to: toDate,
})
);
}
} else {
toDate = formatDate(end);
dispatch(

View file

@ -139,7 +139,7 @@ describe('SIEM Super Date Picker', () => {
expect(store.getState().inputs.global.timerange.toStr).toBe('now');
});
test('Make Sure it is Today date', () => {
test('Make Sure it is Today date is an absolute date', () => {
wrapper
.find('[data-test-subj="superDatePickerToggleQuickMenuButton"]')
.first()
@ -151,8 +151,22 @@ describe('SIEM Super Date Picker', () => {
.first()
.simulate('click');
wrapper.update();
expect(store.getState().inputs.global.timerange.fromStr).toBe('now/d');
expect(store.getState().inputs.global.timerange.toStr).toBe('now/d');
expect(store.getState().inputs.global.timerange.kind).toBe('absolute');
});
test('Make Sure it is This Week date is an absolute date', () => {
wrapper
.find('[data-test-subj="superDatePickerToggleQuickMenuButton"]')
.first()
.simulate('click');
wrapper.update();
wrapper
.find('[data-test-subj="superDatePickerCommonlyUsed_This_week"]')
.first()
.simulate('click');
wrapper.update();
expect(store.getState().inputs.global.timerange.kind).toBe('absolute');
});
test('Make Sure to (end date) is superior than from (start date)', () => {

View file

@ -91,12 +91,12 @@ export const SuperDatePickerComponent = React.memo<SuperDatePickerProps>(
toStr,
updateReduxTime,
}) => {
const [isQuickSelection, setIsQuickSelection] = useState(true);
const [recentlyUsedRanges, setRecentlyUsedRanges] = useState<EuiSuperDatePickerRecentRange[]>(
[]
);
const onRefresh = useCallback(
({ start: newStart, end: newEnd }: OnRefreshProps): void => {
const isQuickSelection = newStart.includes('now') || newEnd.includes('now');
const { kqlHaveBeenUpdated } = updateReduxTime({
end: newEnd,
id,
@ -117,12 +117,13 @@ export const SuperDatePickerComponent = React.memo<SuperDatePickerProps>(
refetchQuery(queries);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[end, id, isQuickSelection, kqlQuery, start, timelineId]
[end, id, kqlQuery, queries, start, timelineId, updateReduxTime]
);
const onRefreshChange = useCallback(
({ isPaused, refreshInterval }: OnRefreshChangeProps): void => {
const isQuickSelection =
(fromStr != null && fromStr.includes('now')) || (toStr != null && toStr.includes('now'));
if (duration !== refreshInterval) {
setDuration({ id, duration: refreshInterval });
}
@ -137,27 +138,22 @@ export const SuperDatePickerComponent = React.memo<SuperDatePickerProps>(
refetchQuery(queries);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[id, isQuickSelection, duration, policy, toStr]
[fromStr, toStr, duration, policy, setDuration, id, stopAutoReload, startAutoReload, queries]
);
const refetchQuery = (newQueries: inputsModel.GlobalGraphqlQuery[]) => {
const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => {
newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)());
};
const onTimeChange = useCallback(
({
start: newStart,
end: newEnd,
isQuickSelection: newIsQuickSelection,
isInvalid,
}: OnTimeChangeProps) => {
({ start: newStart, end: newEnd, isInvalid }: OnTimeChangeProps) => {
const isQuickSelection = newStart.includes('now') || newEnd.includes('now');
if (!isInvalid) {
updateReduxTime({
end: newEnd,
id,
isInvalid,
isQuickSelection: newIsQuickSelection,
isQuickSelection,
kql: kqlQuery,
start: newStart,
timelineId,
@ -174,15 +170,13 @@ export const SuperDatePickerComponent = React.memo<SuperDatePickerProps>(
];
setRecentlyUsedRanges(newRecentlyUsedRanges);
setIsQuickSelection(newIsQuickSelection);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[recentlyUsedRanges, kqlQuery]
[updateReduxTime, id, kqlQuery, timelineId, recentlyUsedRanges]
);
const endDate = kind === 'relative' ? toStr : new Date(end).toISOString();
const startDate = kind === 'relative' ? fromStr : new Date(start).toISOString();
const endDate = toStr != null ? toStr : new Date(end).toISOString();
const startDate = fromStr != null ? fromStr : new Date(start).toISOString();
const [quickRanges] = useUiSetting$<Range[]>(DEFAULT_TIMEPICKER_QUICK_RANGES);
const commonlyUsedRanges = isEmpty(quickRanges)
@ -232,15 +226,27 @@ export const dispatchUpdateReduxTime = (dispatch: Dispatch) => ({
const fromDate = formatDate(start);
let toDate = formatDate(end, { roundUp: true });
if (isQuickSelection) {
dispatch(
inputsActions.setRelativeRangeDatePicker({
id,
fromStr: start,
toStr: end,
from: fromDate,
to: toDate,
})
);
if (end === start) {
dispatch(
inputsActions.setAbsoluteRangeDatePicker({
id,
fromStr: start,
toStr: end,
from: fromDate,
to: toDate,
})
);
} else {
dispatch(
inputsActions.setRelativeRangeDatePicker({
id,
fromStr: start,
toStr: end,
from: fromDate,
to: toDate,
})
);
}
} else {
toDate = formatDate(end);
dispatch(
@ -284,6 +290,7 @@ export const makeMapStateToProps = () => {
const getToStrSelector = toStrSelector();
return (state: State, { id }: OwnProps) => {
const inputsRange: InputsRange = getOr({}, `inputs.${id}`, state);
return {
duration: getDurationSelector(inputsRange),
end: getEndSelector(inputsRange),
@ -292,7 +299,7 @@ export const makeMapStateToProps = () => {
kind: getKindSelector(inputsRange),
kqlQuery: getKqlQuerySelector(inputsRange) as inputsModel.GlobalKqlQuery,
policy: getPolicySelector(inputsRange),
queries: getQueriesSelector(inputsRange) as inputsModel.GlobalGraphqlQuery[],
queries: getQueriesSelector(state, id),
start: getStartSelector(inputsRange),
toStr: getToStrSelector(inputsRange),
};

View file

@ -17,6 +17,8 @@ import {
} from './selectors';
import { InputsRange, AbsoluteTimeRange, RelativeTimeRange } from '../../store/inputs/model';
import { cloneDeep } from 'lodash/fp';
import { mockGlobalState } from '../../mock';
import { State } from '../../store';
describe('selectors', () => {
let absoluteTime: AbsoluteTimeRange = {
@ -42,6 +44,8 @@ describe('selectors', () => {
filters: [],
};
let mockState: State = mockGlobalState;
const getPolicySelector = policySelector();
const getDurationSelector = durationSelector();
const getKindSelector = kindSelector();
@ -75,6 +79,8 @@ describe('selectors', () => {
},
filters: [],
};
mockState = mockGlobalState;
});
describe('#policySelector', () => {
@ -375,34 +381,61 @@ describe('selectors', () => {
describe('#queriesSelector', () => {
test('returns the same reference given the same identical input twice', () => {
const result1 = getQueriesSelector(inputState);
const result2 = getQueriesSelector(inputState);
const myMock = {
...mockState,
inputs: {
...mockState.inputs,
global: inputState,
},
};
const result1 = getQueriesSelector(myMock, 'global');
const result2 = getQueriesSelector(myMock, 'global');
expect(result1).toBe(result2);
});
test('DOES NOT return the same reference given different input twice but with different deep copies since the query is not a primitive', () => {
const clone = cloneDeep(inputState);
const result1 = getQueriesSelector(inputState);
const result2 = getQueriesSelector(clone);
const myMock = {
...mockState,
inputs: {
...mockState.inputs,
global: inputState,
},
};
const clone = cloneDeep(myMock);
const result1 = getQueriesSelector(myMock, 'global');
const result2 = getQueriesSelector(clone, 'global');
expect(result1).not.toBe(result2);
});
test('returns a different reference even if the contents are the same since query is an array and not a primitive', () => {
const result1 = getQueriesSelector(inputState);
const change: InputsRange = {
...inputState,
queries: [
{
loading: false,
id: '1',
inspect: { dsl: [], response: [] },
isInspected: false,
refetch: null,
selectedInspectIndex: 0,
},
],
const myMock = {
...mockState,
inputs: {
...mockState.inputs,
global: inputState,
},
};
const result2 = getQueriesSelector(change);
const result1 = getQueriesSelector(myMock, 'global');
const myMockChange: State = {
...myMock,
inputs: {
...mockState.inputs,
global: {
...mockState.inputs.global,
queries: [
{
loading: false,
id: '1',
inspect: { dsl: [], response: [] },
isInspected: false,
refetch: null,
selectedInspectIndex: 0,
},
],
},
},
};
const result2 = getQueriesSelector(myMockChange, 'global');
expect(result1).not.toBe(result2);
});
});

View file

@ -4,7 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { isEmpty } from 'lodash';
import { createSelector } from 'reselect';
import { State } from '../../store';
import { InputsModelId } from '../../store/inputs/constants';
import { Policy, InputsRange, TimeRange, GlobalQuery } from '../../store/inputs/model';
export const getPolicy = (inputState: InputsRange): Policy => inputState.policy;
@ -13,6 +16,16 @@ export const getTimerange = (inputState: InputsRange): TimeRange => inputState.t
export const getQueries = (inputState: InputsRange): GlobalQuery[] => inputState.queries;
export const getGlobalQueries = (state: State, id: InputsModelId): GlobalQuery[] => {
const inputsRange = state.inputs[id];
return !isEmpty(inputsRange.linkTo)
? inputsRange.linkTo.reduce<GlobalQuery[]>((acc, linkToId) => {
const linkToIdInputsRange: InputsRange = state.inputs[linkToId];
return [...acc, ...linkToIdInputsRange.queries];
}, inputsRange.queries)
: inputsRange.queries;
};
export const policySelector = () => createSelector(getPolicy, (policy) => policy.kind);
export const durationSelector = () => createSelector(getPolicy, (policy) => policy.duration);
@ -31,7 +44,7 @@ export const isLoadingSelector = () =>
createSelector(getQueries, (queries) => queries.some((i) => i.loading === true));
export const queriesSelector = () =>
createSelector(getQueries, (queries) => queries.filter((q) => q.id !== 'kql'));
createSelector(getGlobalQueries, (queries) => queries.filter((q) => q.id !== 'kql'));
export const kqlQuerySelector = () =>
createSelector(getQueries, (queries) => queries.find((q) => q.id === 'kql'));

View file

@ -16,6 +16,8 @@ export const setAbsoluteRangeDatePicker = actionCreator<{
id: InputsModelId;
from: string;
to: string;
fromStr?: string;
toStr?: string;
}>('SET_ABSOLUTE_RANGE_DATE_PICKER');
export const setTimelineRangeDatePicker = actionCreator<{

View file

@ -11,8 +11,8 @@ import { Query, Filter, SavedQuery } from '../../../../../../../src/plugins/data
export interface AbsoluteTimeRange {
kind: 'absolute';
fromStr: undefined;
toStr: undefined;
fromStr?: string;
toStr?: string;
from: string;
to: string;
}

View file

@ -149,16 +149,19 @@ export const inputsReducer = reducerWithInitialState(initialInputsState)
},
};
})
.case(setAbsoluteRangeDatePicker, (state, { id, from, to }) => {
const timerange: TimeRange = {
kind: 'absolute',
fromStr: undefined,
toStr: undefined,
from,
to,
};
return updateInputTimerange(id, timerange, state);
})
.case(
setAbsoluteRangeDatePicker,
(state, { id, from, to, fromStr = undefined, toStr = undefined }) => {
const timerange: TimeRange = {
kind: 'absolute',
fromStr,
toStr,
from,
to,
};
return updateInputTimerange(id, timerange, state);
}
)
.case(setRelativeRangeDatePicker, (state, { id, fromStr, from, to, toStr }) => {
const timerange: TimeRange = {
kind: 'relative',

View file

@ -86,18 +86,25 @@ export const defaultIndexNamesSelector = () => {
return mapStateToProps;
};
const EXLCUDE_ELASTIC_CLOUD_INDEX = '-*elastic-cloud-logs-*';
const EXCLUDE_ELASTIC_CLOUD_INDEX = '-*elastic-cloud-logs-*';
export const getSourcererScopeSelector = () => {
const getScopesSelector = scopesSelector();
const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => ({
...getScopesSelector(state)[scopeId],
selectedPatterns: getScopesSelector(state)[scopeId].selectedPatterns.some(
const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => {
const selectedPatterns = getScopesSelector(state)[scopeId].selectedPatterns.some(
(index) => index === 'logs-*'
)
? [...getScopesSelector(state)[scopeId].selectedPatterns, EXLCUDE_ELASTIC_CLOUD_INDEX]
: getScopesSelector(state)[scopeId].selectedPatterns,
});
? [...getScopesSelector(state)[scopeId].selectedPatterns, EXCLUDE_ELASTIC_CLOUD_INDEX]
: getScopesSelector(state)[scopeId].selectedPatterns;
return {
...getScopesSelector(state)[scopeId],
selectedPatterns,
indexPattern: {
...getScopesSelector(state)[scopeId].indexPattern,
title: selectedPatterns.join(),
},
};
};
return mapStateToProps;
};

View file

@ -20,6 +20,7 @@ import {
reputationRenderer,
DefaultFieldRenderer,
DEFAULT_MORE_MAX_HEIGHT,
DefaultFieldRendererOverflow,
MoreContainer,
} from './field_renderers';
import { mockData } from '../../../network/components/details/mock';
@ -330,4 +331,45 @@ describe('Field Renderers', () => {
expect(render).toHaveBeenCalledTimes(2);
});
});
describe('DefaultFieldRendererOverflow', () => {
const idPrefix = 'prefix-1';
const rowItems = ['item1', 'item2', 'item3', 'item4', 'item5', 'item6', 'item7'];
test('it should render the length of items after the overflowIndexStart', () => {
const wrapper = mount(
<TestProviders>
<DefaultFieldRendererOverflow
idPrefix={idPrefix}
rowItems={rowItems}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
/>
</TestProviders>
);
expect(wrapper.text()).toEqual(' ,+2 More');
expect(wrapper.find('[data-test-subj="more-container"]').first().exists()).toBe(false);
});
test('it should render the items after overflowIndexStart in the popover', () => {
const wrapper = mount(
<TestProviders>
<DefaultFieldRendererOverflow
idPrefix={idPrefix}
rowItems={rowItems}
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
overflowIndexStart={5}
/>
</TestProviders>
);
wrapper.find('button').first().simulate('click');
wrapper.update();
expect(wrapper.find('.euiPopover').first().exists()).toBe(true);
expect(wrapper.find('[data-test-subj="more-container"]').first().text()).toEqual(
'item6item7'
);
});
});
});

View file

@ -260,12 +260,12 @@ MoreContainer.displayName = 'MoreContainer';
export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverflowProps>(
({ idPrefix, moreMaxHeight, overflowIndexStart = 5, render, rowItems }) => {
const [isOpen, setIsOpen] = useState(false);
const handleClose = useCallback(() => setIsOpen(false), []);
const togglePopover = useCallback(() => setIsOpen((currentIsOpen) => !currentIsOpen), []);
const button = useMemo(
() => (
<>
{' ,'}
<EuiButtonEmpty size="xs" onClick={handleClose}>
<EuiButtonEmpty size="xs" onClick={togglePopover}>
{`+${rowItems.length - overflowIndexStart} `}
<FormattedMessage
id="xpack.securitySolution.fieldRenderers.moreLabel"
@ -274,7 +274,7 @@ export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverf
</EuiButtonEmpty>
</>
),
[handleClose, overflowIndexStart, rowItems.length]
[togglePopover, overflowIndexStart, rowItems.length]
);
return (
@ -284,7 +284,7 @@ export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverf
id="popover"
button={button}
isOpen={isOpen}
closePopover={handleClose}
closePopover={togglePopover}
repositionOnScroll
>
<MoreContainer

View file

@ -905,6 +905,7 @@ In other use cases the message field can be used to concatenate different values
start="2018-03-23T18:49:23.132Z"
status="active"
timelineType="default"
timerangeKind="absolute"
toggleColumn={[MockFunction]}
usersViewing={
Array [

View file

@ -94,7 +94,6 @@ const EventsComponent: React.FC<Props> = ({
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
containerElementRef={containerElementRef}
disableSensorVisibility={data != null && data.length < 101}
docValueFields={docValueFields}
event={event}
eventIdToNoteIds={eventIdToNoteIds}

View file

@ -6,7 +6,6 @@
import React, { useRef, useState, useCallback } from 'react';
import uuid from 'uuid';
import VisibilitySensor from 'react-visibility-sensor';
import { BrowserFields, DocValueFields } from '../../../../../common/containers/source';
import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector';
@ -19,7 +18,6 @@ import {
import { Note } from '../../../../../common/lib/note';
import { ColumnHeaderOptions, TimelineModel } from '../../../../../timelines/store/timeline/model';
import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers';
import { SkeletonRow } from '../../skeleton_row';
import {
OnColumnResized,
OnPinEvent,
@ -38,6 +36,8 @@ import { NoteCards } from '../../../notes/note_cards';
import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context';
import { EventColumnView } from './event_column_view';
import { inputsModel } from '../../../../../common/store';
import { TimelineId } from '../../../../../../common/types/timeline';
import { activeTimeline } from '../../../../containers/active_timeline_context';
interface Props {
actionsColumnWidth: number;
@ -46,7 +46,6 @@ interface Props {
browserFields: BrowserFields;
columnHeaders: ColumnHeaderOptions[];
columnRenderers: ColumnRenderer[];
disableSensorVisibility: boolean;
docValueFields: DocValueFields[];
event: TimelineItem;
eventIdToNoteIds: Readonly<Record<string, string[]>>;
@ -73,33 +72,6 @@ export const getNewNoteId = (): string => uuid.v4();
const emptyDetails: TimelineEventsDetailsItem[] = [];
/**
* This is the default row height whenever it is a plain row renderer and not a custom row height.
* We use this value when we do not know the height of a particular row.
*/
const DEFAULT_ROW_HEIGHT = '32px';
/**
* This is the top offset in pixels of the top part of the timeline. The UI area where you do your
* drag and drop and filtering. It is a positive number in pixels of _PART_ of the header but not
* the entire header. We leave room for some rows to render behind the drag and drop so they might be
* visible by the time the user scrolls upwards. All other DOM elements are replaced with their "blank"
* rows.
*/
const TOP_OFFSET = 50;
/**
* This is the bottom offset in pixels of the bottom part of the timeline. The UI area right below the
* timeline which is the footer. Since the footer is so incredibly small we don't have enough room to
* render around 5 rows below the timeline to get the user the best chance of always scrolling without seeing
* "blank rows". The negative number is to give the bottom of the browser window a bit of invisible space to
* keep around 5 rows rendering below it. All other DOM elements are replaced with their "blank"
* rows.
*/
const BOTTOM_OFFSET = -500;
const VISIBILITY_SENSOR_OFFSET = { top: TOP_OFFSET, bottom: BOTTOM_OFFSET };
const emptyNotes: string[] = [];
const EventsTrSupplementContainerWrapper = React.memo(({ children }) => {
@ -116,7 +88,6 @@ const StatefulEventComponent: React.FC<Props> = ({
containerElementRef,
columnHeaders,
columnRenderers,
disableSensorVisibility = true,
docValueFields,
event,
eventIdToNoteIds,
@ -138,7 +109,9 @@ const StatefulEventComponent: React.FC<Props> = ({
toggleColumn,
updateNote,
}) => {
const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({});
const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>(
timelineId === TimelineId.active ? activeTimeline.getExpandedEventIds() : {}
);
const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({});
const { status: timelineStatus } = useShallowEqualSelector<TimelineModel>(
(state) => state.timeline.timelineById[timelineId]
@ -148,21 +121,21 @@ const StatefulEventComponent: React.FC<Props> = ({
docValueFields,
indexName: event._index!,
eventId: event._id,
skip: !expanded[event._id],
skip: !expanded || !expanded[event._id],
});
const onToggleShowNotes = useCallback(() => {
const eventId = event._id;
setShowNotes({ ...showNotes, [eventId]: !showNotes[eventId] });
}, [event, showNotes]);
setShowNotes((prevShowNotes) => ({ ...prevShowNotes, [eventId]: !prevShowNotes[eventId] }));
}, [event]);
const onToggleExpanded = useCallback(() => {
const eventId = event._id;
setExpanded({
...expanded,
[eventId]: !expanded[eventId],
});
}, [event, expanded]);
setExpanded((prevExpanded) => ({ ...prevExpanded, [eventId]: !prevExpanded[eventId] }));
if (timelineId === TimelineId.active) {
activeTimeline.toggleExpandedEvent(eventId);
}
}, [event._id, timelineId]);
const associateNote = useCallback(
(noteId: string) => {
@ -174,152 +147,87 @@ const StatefulEventComponent: React.FC<Props> = ({
[addNoteToEvent, event, isEventPinned, onPinEvent]
);
// Number of current columns plus one for actions.
const columnCount = columnHeaders.length + 1;
const VisibilitySensorContent = useCallback(
({ isVisible }) => {
if (isVisible || disableSensorVisibility) {
return (
<EventsTrGroup
className={STATEFUL_EVENT_CSS_CLASS_NAME}
data-test-subj="event"
eventType={getEventType(event.ecs)}
isBuildingBlockType={isEventBuildingBlockType(event.ecs)}
showLeftBorder={!isEventViewer}
ref={divElement}
>
<EventColumnView
id={event._id}
actionsColumnWidth={actionsColumnWidth}
associateNote={associateNote}
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
data={event.data}
ecsData={event.ecs}
expanded={!!expanded[event._id]}
eventIdToNoteIds={eventIdToNoteIds}
getNotesByIds={getNotesByIds}
isEventPinned={isEventPinned}
isEventViewer={isEventViewer}
loading={loading}
loadingEventIds={loadingEventIds}
onColumnResized={onColumnResized}
onEventToggled={onToggleExpanded}
onPinEvent={onPinEvent}
onRowSelected={onRowSelected}
onUnPinEvent={onUnPinEvent}
refetch={refetch}
onRuleChange={onRuleChange}
selectedEventIds={selectedEventIds}
showCheckboxes={showCheckboxes}
showNotes={!!showNotes[event._id]}
timelineId={timelineId}
toggleShowNotes={onToggleShowNotes}
updateNote={updateNote}
/>
<EventsTrSupplementContainerWrapper>
<EventsTrSupplement
className="siemEventsTable__trSupplement--notes"
data-test-subj="event-notes-flex-item"
>
<NoteCards
associateNote={associateNote}
data-test-subj="note-cards"
getNewNoteId={getNewNoteId}
getNotesByIds={getNotesByIds}
noteIds={eventIdToNoteIds[event._id] || emptyNotes}
showAddNote={!!showNotes[event._id]}
status={timelineStatus}
toggleShowAddNote={onToggleShowNotes}
updateNote={updateNote}
/>
</EventsTrSupplement>
{getRowRenderer(event.ecs, rowRenderers).renderRow({
browserFields,
data: event.ecs,
timelineId,
})}
<EventsTrSupplement
className="siemEventsTable__trSupplement--attributes"
data-test-subj="event-details"
>
<ExpandableEvent
browserFields={browserFields}
columnHeaders={columnHeaders}
event={detailsData || emptyDetails}
forceExpand={!!expanded[event._id] && !loading}
id={event._id}
onEventToggled={onToggleExpanded}
onUpdateColumns={onUpdateColumns}
timelineId={timelineId}
toggleColumn={toggleColumn}
/>
</EventsTrSupplement>
</EventsTrSupplementContainerWrapper>
</EventsTrGroup>
);
} else {
// Height place holder for visibility detection as well as re-rendering sections.
const height =
divElement.current != null && divElement.current!.clientHeight
? `${divElement.current!.clientHeight}px`
: DEFAULT_ROW_HEIGHT;
return <SkeletonRow cellCount={columnCount} rowHeight={height} />;
}
},
[
actionsColumnWidth,
associateNote,
browserFields,
columnCount,
columnHeaders,
columnRenderers,
detailsData,
disableSensorVisibility,
event._id,
event.data,
event.ecs,
eventIdToNoteIds,
expanded,
getNotesByIds,
isEventPinned,
isEventViewer,
loading,
loadingEventIds,
onColumnResized,
onPinEvent,
onRowSelected,
onToggleExpanded,
onToggleShowNotes,
onUnPinEvent,
onUpdateColumns,
refetch,
onRuleChange,
rowRenderers,
selectedEventIds,
showCheckboxes,
showNotes,
timelineId,
timelineStatus,
toggleColumn,
updateNote,
]
);
return (
<VisibilitySensor
partialVisibility={true}
scrollCheck={true}
containment={containerElementRef}
offset={VISIBILITY_SENSOR_OFFSET}
<EventsTrGroup
className={STATEFUL_EVENT_CSS_CLASS_NAME}
data-test-subj="event"
eventType={getEventType(event.ecs)}
isBuildingBlockType={isEventBuildingBlockType(event.ecs)}
showLeftBorder={!isEventViewer}
ref={divElement}
>
{VisibilitySensorContent}
</VisibilitySensor>
<EventColumnView
id={event._id}
actionsColumnWidth={actionsColumnWidth}
associateNote={associateNote}
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
data={event.data}
ecsData={event.ecs}
expanded={!!expanded[event._id]}
eventIdToNoteIds={eventIdToNoteIds}
getNotesByIds={getNotesByIds}
isEventPinned={isEventPinned}
isEventViewer={isEventViewer}
loading={loading}
loadingEventIds={loadingEventIds}
onColumnResized={onColumnResized}
onEventToggled={onToggleExpanded}
onPinEvent={onPinEvent}
onRowSelected={onRowSelected}
onUnPinEvent={onUnPinEvent}
refetch={refetch}
onRuleChange={onRuleChange}
selectedEventIds={selectedEventIds}
showCheckboxes={showCheckboxes}
showNotes={!!showNotes[event._id]}
timelineId={timelineId}
toggleShowNotes={onToggleShowNotes}
updateNote={updateNote}
/>
<EventsTrSupplementContainerWrapper>
<EventsTrSupplement
className="siemEventsTable__trSupplement--notes"
data-test-subj="event-notes-flex-item"
>
<NoteCards
associateNote={associateNote}
data-test-subj="note-cards"
getNewNoteId={getNewNoteId}
getNotesByIds={getNotesByIds}
noteIds={eventIdToNoteIds[event._id] || emptyNotes}
showAddNote={!!showNotes[event._id]}
status={timelineStatus}
toggleShowAddNote={onToggleShowNotes}
updateNote={updateNote}
/>
</EventsTrSupplement>
{getRowRenderer(event.ecs, rowRenderers).renderRow({
browserFields,
data: event.ecs,
timelineId,
})}
<EventsTrSupplement
className="siemEventsTable__trSupplement--attributes"
data-test-subj="event-details"
>
<ExpandableEvent
browserFields={browserFields}
columnHeaders={columnHeaders}
event={detailsData || emptyDetails}
forceExpand={!!expanded[event._id] && !loading}
id={event._id}
onEventToggled={onToggleExpanded}
onUpdateColumns={onUpdateColumns}
timelineId={timelineId}
toggleColumn={toggleColumn}
/>
</EventsTrSupplement>
</EventsTrSupplementContainerWrapper>
</EventsTrGroup>
);
};

View file

@ -27,6 +27,11 @@ export interface OwnProps {
export type Props = OwnProps & PropsFromRedux;
const isTimerangeSame = (prevProps: Props, nextProps: Props) =>
prevProps.end === nextProps.end &&
prevProps.start === nextProps.start &&
prevProps.timerangeKind === nextProps.timerangeKind;
const StatefulTimelineComponent = React.memo<Props>(
({
columns,
@ -51,6 +56,7 @@ const StatefulTimelineComponent = React.memo<Props>(
start,
status,
timelineType,
timerangeKind,
updateItemsPerPage,
upsertColumn,
usersViewing,
@ -125,13 +131,14 @@ const StatefulTimelineComponent = React.memo<Props>(
status={status}
toggleColumn={toggleColumn}
timelineType={timelineType}
timerangeKind={timerangeKind}
usersViewing={usersViewing}
/>
);
},
(prevProps, nextProps) => {
return (
prevProps.end === nextProps.end &&
isTimerangeSame(prevProps, nextProps) &&
prevProps.graphEventId === nextProps.graphEventId &&
prevProps.id === nextProps.id &&
prevProps.isLive === nextProps.isLive &&
@ -142,7 +149,6 @@ const StatefulTimelineComponent = React.memo<Props>(
prevProps.kqlQueryExpression === nextProps.kqlQueryExpression &&
prevProps.show === nextProps.show &&
prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg &&
prevProps.start === nextProps.start &&
prevProps.timelineType === nextProps.timelineType &&
prevProps.status === nextProps.status &&
deepEqual(prevProps.columns, nextProps.columns) &&
@ -209,6 +215,7 @@ const makeMapStateToProps = () => {
start: input.timerange.from,
status,
timelineType,
timerangeKind: input.timerange.kind,
};
};
return mapStateToProps;

View file

@ -116,6 +116,7 @@ describe('Timeline', () => {
start: startDate,
status: TimelineStatus.active,
timelineType: TimelineType.default,
timerangeKind: 'absolute',
toggleColumn: jest.fn(),
usersViewing: ['elastic'],
};

View file

@ -112,6 +112,7 @@ export interface Props {
start: string;
status: TimelineStatusLiteral;
timelineType: TimelineType;
timerangeKind: 'absolute' | 'relative';
toggleColumn: (column: ColumnHeaderOptions) => void;
usersViewing: string[];
}
@ -143,6 +144,7 @@ export const TimelineComponent: React.FC<Props> = ({
status,
sort,
timelineType,
timerangeKind,
toggleColumn,
usersViewing,
}) => {
@ -212,6 +214,7 @@ export const TimelineComponent: React.FC<Props> = ({
startDate: start,
skip: !canQueryTimeline,
sort: timelineQuerySortField,
timerangeKind,
});
useEffect(() => {

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { TimelineArgs } from '.';
import { TimelineEventsAllRequestOptions } from '../../../common/search_strategy/timeline';
/*
* Future Engineer
* This class is just there to manage temporarily the reload of the active timeline when switching tabs
* because of the bootstrap of the security solution app, we will always trigger the query
* to avoid it we will cache its request and response so we can go back where the user was before switching tabs
*
* !!! Important !!! this is just there until, we will have a better way to bootstrap the app
* I did not want to put in the store because I was feeling it will feel less temporarily and I did not want other engineer using it
*
*/
class ActiveTimelineEvents {
private _activePage: number = 0;
private _expandedEventIds: Record<string, boolean> = {};
private _pageName: string = '';
private _request: TimelineEventsAllRequestOptions | null = null;
private _response: TimelineArgs | null = null;
getActivePage() {
return this._activePage;
}
setActivePage(activePage: number) {
this._activePage = activePage;
}
getExpandedEventIds() {
return this._expandedEventIds;
}
toggleExpandedEvent(eventId: string) {
this._expandedEventIds = {
...this._expandedEventIds,
[eventId]: !this._expandedEventIds[eventId],
};
}
setExpandedEventIds(expandedEventIds: Record<string, boolean>) {
this._expandedEventIds = expandedEventIds;
}
getPageName() {
return this._pageName;
}
setPageName(pageName: string) {
this._pageName = pageName;
}
getRequest() {
return this._request;
}
setRequest(req: TimelineEventsAllRequestOptions) {
this._request = req;
}
getResponse() {
return this._response;
}
setResponse(resp: TimelineArgs | null) {
this._response = resp;
}
}
export const activeTimeline = new ActiveTimelineEvents();

View file

@ -0,0 +1,210 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { initSortDefault, TimelineArgs, useTimelineEvents, UseTimelineEventsProps } from '.';
import { SecurityPageName } from '../../../common/constants';
import { TimelineId } from '../../../common/types/timeline';
import { mockTimelineData } from '../../common/mock';
import { useRouteSpy } from '../../common/utils/route/use_route_spy';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
const mockEvents = mockTimelineData.filter((i, index) => index <= 11);
const mockSearch = jest.fn();
jest.mock('../../common/lib/kibana', () => ({
useKibana: jest.fn().mockReturnValue({
services: {
application: {
capabilities: {
siem: {
crud: true,
},
},
},
data: {
search: {
search: jest.fn().mockImplementation((args) => {
mockSearch();
return {
subscribe: jest.fn().mockImplementation(({ next }) => {
next({
isRunning: false,
isPartial: false,
inspect: {
dsl: [],
response: [],
},
edges: mockEvents.map((item) => ({ node: item })),
pageInfo: {
activePage: 0,
totalPages: 10,
},
rawResponse: {},
totalCount: mockTimelineData.length,
});
return { unsubscribe: jest.fn() };
}),
};
}),
},
},
notifications: {
toasts: {
addWarning: jest.fn(),
},
},
},
}),
}));
const mockUseRouteSpy: jest.Mock = useRouteSpy as jest.Mock;
jest.mock('../../common/utils/route/use_route_spy', () => ({
useRouteSpy: jest.fn(),
}));
mockUseRouteSpy.mockReturnValue([
{
pageName: SecurityPageName.overview,
detailName: undefined,
tabName: undefined,
search: '',
pathName: '/overview',
},
]);
describe('useTimelineEvents', () => {
beforeEach(() => {
mockSearch.mockReset();
});
const startDate: string = '2020-07-07T08:20:18.966Z';
const endDate: string = '3000-01-01T00:00:00.000Z';
const props: UseTimelineEventsProps = {
docValueFields: [],
endDate: '',
id: TimelineId.active,
indexNames: ['filebeat-*'],
fields: [],
filterQuery: '',
startDate: '',
limit: 25,
sort: initSortDefault,
skip: false,
};
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
UseTimelineEventsProps,
[boolean, TimelineArgs]
>((args) => useTimelineEvents(args), {
initialProps: { ...props },
});
// useEffect on params request
await waitForNextUpdate();
expect(result.current).toEqual([
false,
{
events: [],
id: TimelineId.active,
inspect: result.current[1].inspect,
loadPage: result.current[1].loadPage,
pageInfo: result.current[1].pageInfo,
refetch: result.current[1].refetch,
totalCount: -1,
updatedAt: 0,
},
]);
});
});
test('happy path query', async () => {
await act(async () => {
const { result, waitForNextUpdate, rerender } = renderHook<
UseTimelineEventsProps,
[boolean, TimelineArgs]
>((args) => useTimelineEvents(args), {
initialProps: { ...props },
});
// useEffect on params request
await waitForNextUpdate();
rerender({ ...props, startDate, endDate });
// useEffect on params request
await waitForNextUpdate();
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(result.current).toEqual([
false,
{
events: mockEvents,
id: TimelineId.active,
inspect: result.current[1].inspect,
loadPage: result.current[1].loadPage,
pageInfo: result.current[1].pageInfo,
refetch: result.current[1].refetch,
totalCount: 31,
updatedAt: result.current[1].updatedAt,
},
]);
});
});
test('Mock cache for active timeline when switching page', async () => {
await act(async () => {
const { result, waitForNextUpdate, rerender } = renderHook<
UseTimelineEventsProps,
[boolean, TimelineArgs]
>((args) => useTimelineEvents(args), {
initialProps: { ...props },
});
// useEffect on params request
await waitForNextUpdate();
rerender({ ...props, startDate, endDate });
// useEffect on params request
await waitForNextUpdate();
mockUseRouteSpy.mockReturnValue([
{
pageName: SecurityPageName.timelines,
detailName: undefined,
tabName: undefined,
search: '',
pathName: '/timelines',
},
]);
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(result.current).toEqual([
false,
{
events: mockEvents,
id: TimelineId.active,
inspect: result.current[1].inspect,
loadPage: result.current[1].loadPage,
pageInfo: result.current[1].pageInfo,
refetch: result.current[1].refetch,
totalCount: 31,
updatedAt: result.current[1].updatedAt,
},
]);
});
});
});

View file

@ -30,6 +30,9 @@ import {
} from '../../../common/search_strategy';
import { InspectResponse } from '../../types';
import * as i18n from './translations';
import { TimelineId } from '../../../common/types/timeline';
import { useRouteSpy } from '../../common/utils/route/use_route_spy';
import { activeTimeline } from './active_timeline_context';
export interface TimelineArgs {
events: TimelineItem[];
@ -44,7 +47,7 @@ export interface TimelineArgs {
type LoadPage = (newActivePage: number) => void;
interface UseTimelineEventsProps {
export interface UseTimelineEventsProps {
docValueFields?: DocValueFields[];
filterQuery?: ESQuery | string;
skip?: boolean;
@ -55,17 +58,26 @@ interface UseTimelineEventsProps {
limit: number;
sort: SortField;
startDate: string;
timerangeKind?: 'absolute' | 'relative';
}
const getTimelineEvents = (timelineEdges: TimelineEdges[]): TimelineItem[] =>
timelineEdges.map((e: TimelineEdges) => e.node);
const ID = 'timelineEventsQuery';
const initSortDefault = {
export const initSortDefault = {
field: '@timestamp',
direction: Direction.asc,
};
function usePreviousRequest(value: TimelineEventsAllRequestOptions | null) {
const ref = useRef<TimelineEventsAllRequestOptions | null>(value);
useEffect(() => {
ref.current = value;
});
return ref.current;
}
export const useTimelineEvents = ({
docValueFields,
endDate,
@ -77,13 +89,17 @@ export const useTimelineEvents = ({
limit,
sort = initSortDefault,
skip = false,
timerangeKind,
}: UseTimelineEventsProps): [boolean, TimelineArgs] => {
const [{ pageName }] = useRouteSpy();
const dispatch = useDispatch();
const { data, notifications } = useKibana().services;
const refetch = useRef<inputsModel.Refetch>(noop);
const abortCtrl = useRef(new AbortController());
const [loading, setLoading] = useState(false);
const [activePage, setActivePage] = useState(0);
const [activePage, setActivePage] = useState(
id === TimelineId.active ? activeTimeline.getActivePage() : 0
);
const [timelineRequest, setTimelineRequest] = useState<TimelineEventsAllRequestOptions | null>(
!skip
? {
@ -106,6 +122,7 @@ export const useTimelineEvents = ({
}
: null
);
const prevTimelineRequest = usePreviousRequest(timelineRequest);
const clearSignalsState = useCallback(() => {
if (id != null && detectionsTimelineIds.some((timelineId) => timelineId === id)) {
@ -117,18 +134,31 @@ export const useTimelineEvents = ({
const wrappedLoadPage = useCallback(
(newActivePage: number) => {
clearSignalsState();
if (id === TimelineId.active) {
activeTimeline.setExpandedEventIds({});
activeTimeline.setActivePage(newActivePage);
}
setActivePage(newActivePage);
},
[clearSignalsState]
[clearSignalsState, id]
);
const refetchGrid = useCallback(() => {
if (refetch.current != null) {
refetch.current();
}
wrappedLoadPage(0);
}, [wrappedLoadPage]);
const [timelineResponse, setTimelineResponse] = useState<TimelineArgs>({
id: ID,
id,
inspect: {
dsl: [],
response: [],
},
refetch: refetch.current,
refetch: refetchGrid,
totalCount: -1,
pageInfo: {
activePage: 0,
@ -141,15 +171,13 @@ export const useTimelineEvents = ({
const timelineSearch = useCallback(
(request: TimelineEventsAllRequestOptions | null) => {
if (request == null) {
if (request == null || pageName === '') {
return;
}
let didCancel = false;
const asyncSearch = async () => {
abortCtrl.current = new AbortController();
setLoading(true);
const searchSubscription$ = data.search
.search<TimelineEventsAllRequestOptions, TimelineEventsAllStrategyResponse>(request, {
strategy: 'securitySolutionTimelineSearchStrategy',
@ -157,26 +185,39 @@ export const useTimelineEvents = ({
})
.subscribe({
next: (response) => {
if (isCompleteResponse(response)) {
if (!didCancel) {
setLoading(false);
setTimelineResponse((prevResponse) => ({
...prevResponse,
events: getTimelineEvents(response.edges),
inspect: getInspectResponse(response, prevResponse.inspect),
pageInfo: response.pageInfo,
refetch: refetch.current,
totalCount: response.totalCount,
updatedAt: Date.now(),
}));
}
searchSubscription$.unsubscribe();
} else if (isErrorResponse(response)) {
if (!didCancel) {
setLoading(false);
try {
if (isCompleteResponse(response)) {
if (!didCancel) {
setLoading(false);
setTimelineResponse((prevResponse) => {
const newTimelineResponse = {
...prevResponse,
events: getTimelineEvents(response.edges),
inspect: getInspectResponse(response, prevResponse.inspect),
pageInfo: response.pageInfo,
totalCount: response.totalCount,
updatedAt: Date.now(),
};
if (id === TimelineId.active) {
activeTimeline.setExpandedEventIds({});
activeTimeline.setPageName(pageName);
activeTimeline.setRequest(request);
activeTimeline.setResponse(newTimelineResponse);
}
return newTimelineResponse;
});
}
searchSubscription$.unsubscribe();
} else if (isErrorResponse(response)) {
if (!didCancel) {
setLoading(false);
}
notifications.toasts.addWarning(i18n.ERROR_TIMELINE_EVENTS);
searchSubscription$.unsubscribe();
}
} catch {
notifications.toasts.addWarning(i18n.ERROR_TIMELINE_EVENTS);
searchSubscription$.unsubscribe();
}
},
error: (msg) => {
@ -189,15 +230,43 @@ export const useTimelineEvents = ({
},
});
};
if (
id === TimelineId.active &&
activeTimeline.getPageName() !== '' &&
pageName !== activeTimeline.getPageName()
) {
activeTimeline.setPageName(pageName);
abortCtrl.current.abort();
setLoading(false);
refetch.current = asyncSearch.bind(null, activeTimeline.getRequest());
setTimelineResponse((prevResp) => {
const resp = activeTimeline.getResponse();
if (resp != null) {
return {
...resp,
refetch: refetchGrid,
loadPage: wrappedLoadPage,
};
}
return prevResp;
});
if (activeTimeline.getResponse() != null) {
return;
}
}
abortCtrl.current.abort();
asyncSearch();
refetch.current = asyncSearch;
return () => {
didCancel = true;
abortCtrl.current.abort();
};
},
[data.search, notifications.toasts]
[data.search, id, notifications.toasts, pageName, refetchGrid, wrappedLoadPage]
);
useEffect(() => {
@ -251,8 +320,10 @@ export const useTimelineEvents = ({
if (activePage !== newActivePage) {
setActivePage(newActivePage);
if (id === TimelineId.active) {
activeTimeline.setActivePage(newActivePage);
}
}
if (
!skip &&
!skipQueryForDetectionsPage(id, indexNames) &&
@ -263,12 +334,13 @@ export const useTimelineEvents = ({
return prevRequest;
});
}, [
dispatch,
indexNames,
activePage,
docValueFields,
endDate,
filterQuery,
id,
indexNames,
limit,
startDate,
sort,
@ -277,8 +349,13 @@ export const useTimelineEvents = ({
]);
useEffect(() => {
timelineSearch(timelineRequest);
}, [timelineRequest, timelineSearch]);
if (
id !== TimelineId.active ||
timerangeKind === 'absolute' ||
!deepEqual(prevTimelineRequest, timelineRequest)
)
timelineSearch(timelineRequest);
}, [id, prevTimelineRequest, timelineRequest, timelineSearch, timerangeKind]);
return [loading, timelineResponse];
};

View file

@ -102,6 +102,7 @@ describe('epicLocalStorage', () => {
status: TimelineStatus.active,
sort,
timelineType: TimelineType.default,
timerangeKind: 'absolute',
toggleColumn: jest.fn(),
usersViewing: ['elastic'],
};

View file

@ -26,12 +26,14 @@ import {
TimelineTypeLiteral,
TimelineType,
RowRendererId,
TimelineId,
} from '../../../../common/types/timeline';
import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range';
import { timelineDefaults } from './defaults';
import { ColumnHeaderOptions, KqlMode, TimelineModel } from './model';
import { TimelineById } from './types';
import { activeTimeline } from '../../containers/active_timeline_context';
export const isNotNull = <T>(value: T | null): value is T => value !== null;
@ -113,6 +115,17 @@ interface AddTimelineParams {
timelineById: TimelineById;
}
export const shouldResetActiveTimelineContext = (
id: string,
oldTimeline: TimelineModel,
newTimeline: TimelineModel
) => {
if (id === TimelineId.active && oldTimeline.savedObjectId !== newTimeline.savedObjectId) {
return true;
}
return false;
};
/**
* Add a saved object timeline to the store
* and default the value to what need to be if values are null
@ -121,13 +134,19 @@ export const addTimelineToStore = ({
id,
timeline,
timelineById,
}: AddTimelineParams): TimelineById => ({
...timelineById,
[id]: {
...timeline,
isLoading: timelineById[id].isLoading,
},
});
}: AddTimelineParams): TimelineById => {
if (shouldResetActiveTimelineContext(id, timelineById[id], timeline)) {
activeTimeline.setActivePage(0);
activeTimeline.setExpandedEventIds({});
}
return {
...timelineById,
[id]: {
...timeline,
isLoading: timelineById[id].isLoading,
},
};
};
interface AddNewTimelineParams {
columns: ColumnHeaderOptions[];