[Security Solution] Fix flow/draggable in details event (#86834)

* fix details event

* fix types + add unit test

* review with angela

* fix lint error

Co-authored-by: Angela Chuang <yi-chun.chuang@elastic.co>
This commit is contained in:
Xavier Mouligneau 2020-12-23 21:13:20 -05:00 committed by GitHub
parent 0ffb9e72ed
commit deae756756
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 535 additions and 227 deletions

View file

@ -408,12 +408,23 @@ export type ImportTimelineResultSchema = runtimeTypes.TypeOf<typeof importTimeli
export type TimelineEventsType = 'all' | 'raw' | 'alert' | 'signal' | 'custom';
export interface TimelineExpandedEventType {
eventId: string;
indexName: string;
export enum TimelineTabs {
query = 'query',
graph = 'graph',
notes = 'notes',
pinned = 'pinned',
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type EmptyObject = Record<any, never>;
export type TimelineExpandedEvent = TimelineExpandedEventType | EmptyObject;
export type TimelineExpandedEventType =
| {
eventId: string;
indexName: string;
}
| EmptyObject;
export type TimelineExpandedEvent = {
[tab in TimelineTabs]?: TimelineExpandedEventType;
};

View file

@ -577,6 +577,7 @@ exports[`EventDetails rendering should match snapshot 1`] = `
}
eventId="Y-6TfmcB0WOhS6qyMv3s"
timelineId="test"
timelineTabType="query"
/>
</React.Fragment>,
"id": "table-view",
@ -1157,6 +1158,7 @@ exports[`EventDetails rendering should match snapshot 1`] = `
}
eventId="Y-6TfmcB0WOhS6qyMv3s"
timelineId="test"
timelineTabType="query"
/>
</React.Fragment>,
"id": "table-view",

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { waitFor } from '@testing-library/dom';
import { ReactWrapper, shallow } from 'enzyme';
import React from 'react';
@ -16,7 +17,7 @@ import { mockBrowserFields } from '../../containers/source/mock';
import { useMountAppended } from '../../utils/use_mount_appended';
import { mockAlertDetailsData } from './__mocks__';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { waitFor } from '@testing-library/dom';
import { TimelineTabs } from '../../../../common/types/timeline';
jest.mock('../link_to');
describe('EventDetails', () => {
@ -27,6 +28,7 @@ describe('EventDetails', () => {
id: mockDetailItemDataId,
isAlert: false,
onViewSelected: jest.fn(),
timelineTabType: TimelineTabs.query,
timelineId: 'test',
view: EventsViewType.summaryView,
};

View file

@ -14,6 +14,7 @@ import { EventFieldsBrowser } from './event_fields_browser';
import { JsonView } from './json_view';
import * as i18n from './translations';
import { SummaryView } from './summary_view';
import { TimelineTabs } from '../../../../common/types/timeline';
export type View = EventsViewType.tableView | EventsViewType.jsonView | EventsViewType.summaryView;
export enum EventsViewType {
@ -29,6 +30,7 @@ interface Props {
isAlert: boolean;
view: EventsViewType;
onViewSelected: (selected: EventsViewType) => void;
timelineTabType: TimelineTabs | 'flyout';
timelineId: string;
}
@ -52,6 +54,7 @@ const EventDetailsComponent: React.FC<Props> = ({
id,
view,
onViewSelected,
timelineTabType,
timelineId,
isAlert,
}) => {
@ -91,6 +94,7 @@ const EventDetailsComponent: React.FC<Props> = ({
data={data}
eventId={id}
timelineId={timelineId}
timelineTabType={timelineTabType}
/>
</>
),
@ -106,7 +110,7 @@ const EventDetailsComponent: React.FC<Props> = ({
),
},
],
[alerts, browserFields, data, id, isAlert, timelineId]
[alerts, browserFields, data, id, isAlert, timelineId, timelineTabType]
);
const selectedTab = useMemo(() => tabs.find((t) => t.id === view) ?? tabs[0], [tabs, view]);

View file

@ -13,6 +13,7 @@ import { timelineActions } from '../../../timelines/store/timeline';
import { EventFieldsBrowser } from './event_fields_browser';
import { mockBrowserFields } from '../../containers/source/mock';
import { useMountAppended } from '../../utils/use_mount_appended';
import { TimelineTabs } from '../../../../common/types/timeline';
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
@ -48,6 +49,7 @@ describe('EventFieldsBrowser', () => {
data={mockDetailItemData}
eventId={mockDetailItemDataId}
timelineId="test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
@ -66,6 +68,7 @@ describe('EventFieldsBrowser', () => {
data={mockDetailItemData}
eventId={mockDetailItemDataId}
timelineId="test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
@ -89,6 +92,7 @@ describe('EventFieldsBrowser', () => {
data={mockDetailItemData}
eventId={eventId}
timelineId="test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
@ -108,6 +112,7 @@ describe('EventFieldsBrowser', () => {
data={mockDetailItemData}
eventId={eventId}
timelineId="test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
@ -127,6 +132,7 @@ describe('EventFieldsBrowser', () => {
data={mockDetailItemData}
eventId={eventId}
timelineId="test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
@ -158,6 +164,7 @@ describe('EventFieldsBrowser', () => {
data={mockDetailItemData}
eventId={mockDetailItemDataId}
timelineId="test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
@ -182,6 +189,7 @@ describe('EventFieldsBrowser', () => {
data={mockDetailItemData}
eventId={mockDetailItemDataId}
timelineId="test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
@ -196,6 +204,7 @@ describe('EventFieldsBrowser', () => {
data={mockDetailItemData}
eventId={mockDetailItemDataId}
timelineId="test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
@ -220,6 +229,7 @@ describe('EventFieldsBrowser', () => {
data={mockDetailItemData}
eventId={mockDetailItemDataId}
timelineId="test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
@ -238,6 +248,7 @@ describe('EventFieldsBrowser', () => {
data={mockDetailItemData}
eventId={mockDetailItemDataId}
timelineId="test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);

View file

@ -29,12 +29,14 @@ import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
import { getColumns } from './columns';
import { EVENT_FIELDS_TABLE_CLASS_NAME, onEventDetailsTabKeyPressed, search } from './helpers';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { TimelineTabs } from '../../../../common/types/timeline';
interface Props {
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
eventId: string;
timelineId: string;
timelineTabType: TimelineTabs | 'flyout';
}
const TableWrapper = styled.div`
@ -87,7 +89,7 @@ const getAriaRowindex = (timelineEventsDetailsItem: TimelineEventsDetailsItem) =
/** Renders a table view or JSON view of the `ECS` `data` */
export const EventFieldsBrowser = React.memo<Props>(
({ browserFields, data, eventId, timelineId }) => {
({ browserFields, data, eventId, timelineTabType, timelineId }) => {
const containerElement = useRef<HTMLDivElement | null>(null);
const dispatch = useDispatch();
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
@ -156,7 +158,7 @@ export const EventFieldsBrowser = React.memo<Props>(
columnHeaders,
eventId,
onUpdateColumns,
contextId: `event-fields-browser-for-${timelineId}`,
contextId: `event-fields-browser-for-${timelineId}-${timelineTabType}`,
timelineId,
toggleColumn,
getLinkValue,
@ -167,6 +169,7 @@ export const EventFieldsBrowser = React.memo<Props>(
eventId,
onUpdateColumns,
timelineId,
timelineTabType,
toggleColumn,
getLinkValue,
]

View file

@ -39,7 +39,7 @@ const EventDetailsFlyoutComponent: React.FC<EventDetailsFlyoutProps> = ({
const dispatch = useDispatch();
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const expandedEvent = useDeepEqualSelector(
(state) => (getTimeline(state, timelineId) ?? timelineDefaults)?.expandedEvent ?? {}
(state) => (getTimeline(state, timelineId) ?? timelineDefaults)?.expandedEvent?.query ?? {}
);
const handleClearSelection = useCallback(() => {
@ -75,6 +75,7 @@ const EventDetailsFlyoutComponent: React.FC<EventDetailsFlyoutProps> = ({
isAlert={isAlert}
loading={loading}
timelineId={timelineId}
timelineTabType="flyout"
/>
</EuiFlyoutBody>
</StyledEuiFlyout>

View file

@ -5,12 +5,13 @@
*/
import React from 'react';
import { waitFor, act } from '@testing-library/react';
import useResizeObserver from 'use-resize-observer/polyfilled';
import '../../mock/match_media';
import { mockIndexNames, mockIndexPattern, TestProviders } from '../../mock';
import { mockEventViewerResponse } from './mock';
import { mockEventViewerResponse, mockEventViewerResponseWithEvents } from './mock';
import { StatefulEventsViewer } from '.';
import { EventsViewer } from './events_viewer';
import { defaultHeaders } from './default_headers';
@ -30,6 +31,15 @@ jest.mock('../../../timelines/components/graph_overlay', () => ({
GraphOverlay: jest.fn(() => <div />),
}));
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
@ -50,6 +60,9 @@ const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
jest.mock('use-resize-observer/polyfilled');
mockUseResizeObserver.mockImplementation(() => ({}));
const mockUseTimelineEvents: jest.Mock = useTimelineEvents as jest.Mock;
jest.mock('../../../timelines/containers');
const from = '2019-08-26T22:10:56.791Z';
const to = '2019-08-27T22:10:56.794Z';
@ -108,14 +121,51 @@ describe('EventsViewer', () => {
start: from,
scopeId: SourcererScopeName.timeline,
};
beforeEach(() => {
(useTimelineEvents as jest.Mock).mockReturnValue([false, mockEventViewerResponse]);
mockUseTimelineEvents.mockReset();
});
beforeAll(() => {
mockUseSourcererScope.mockImplementation(() => defaultMocks);
});
describe('event details', () => {
beforeEach(() => {
mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponseWithEvents]);
});
test('call the right reduce action to show event details', async () => {
const wrapper = mount(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
await act(async () => {
wrapper.find(`[data-test-subj="expand-event"]`).first().simulate('click');
});
await waitFor(() => {
expect(mockDispatch).toBeCalledTimes(2);
expect(mockDispatch.mock.calls[1][0]).toEqual({
payload: {
event: {
eventId: 'yb8TkHYBRgU82_bJu_rY',
indexName: 'auditbeat-7.10.1-2020.12.18-000001',
},
tabType: 'query',
timelineId: 'test-stateful-events-viewer',
},
type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT',
});
});
});
});
describe('rendering', () => {
beforeEach(() => {
mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]);
});
test('it renders the "Showing..." subtitle with the expected event count', () => {
const wrapper = mount(
<TestProviders>
@ -160,57 +210,66 @@ describe('EventsViewer', () => {
);
});
});
describe('loading', () => {
beforeAll(() => {
mockUseSourcererScope.mockImplementation(() => ({ ...defaultMocks, loading: true }));
});
test('it does NOT render fetch index pattern is loading', () => {
const wrapper = mount(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
});
expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe(
false
);
});
describe('loading', () => {
beforeAll(() => {
mockUseSourcererScope.mockImplementation(() => ({ ...defaultMocks, loading: true }));
});
beforeEach(() => {
mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]);
});
test('it does NOT render when start is empty', () => {
testProps = {
...testProps,
start: '',
};
const wrapper = mount(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
test('it does NOT render fetch index pattern is loading', () => {
const wrapper = mount(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe(
false
);
});
expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe(
false
);
});
test('it does NOT render when end is empty', () => {
testProps = {
...testProps,
end: '',
};
const wrapper = mount(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
test('it does NOT render when start is empty', () => {
testProps = {
...testProps,
start: '',
};
const wrapper = mount(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe(
false
);
});
expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe(
false
);
});
test('it does NOT render when end is empty', () => {
testProps = {
...testProps,
end: '',
};
const wrapper = mount(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe(
false
);
});
});
describe('headerFilterGroup', () => {
beforeEach(() => {
mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]);
});
test('it renders the provided headerFilterGroup', () => {
const wrapper = mount(
<TestProviders>
@ -284,6 +343,10 @@ describe('EventsViewer', () => {
});
describe('utilityBar', () => {
beforeEach(() => {
mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]);
});
test('it renders the provided utilityBar when Resolver is NOT showing, because graphEventId is undefined', () => {
const wrapper = mount(
<TestProviders>
@ -313,6 +376,10 @@ describe('EventsViewer', () => {
});
describe('header inspect button', () => {
beforeEach(() => {
mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]);
});
test('it renders the inspect button when Resolver is NOT showing, because graphEventId is undefined', () => {
const wrapper = mount(
<TestProviders>

View file

@ -6,21 +6,15 @@
import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useEffect, useMemo, useState, useRef } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import { useDispatch } from 'react-redux';
import { Direction } from '../../../../common/search_strategy';
import { BrowserFields, DocValueFields } from '../../containers/source';
import { useTimelineEvents } from '../../../timelines/containers';
import { timelineActions } from '../../../timelines/store/timeline';
import { useKibana } from '../../lib/kibana';
import {
ColumnHeaderOptions,
KqlMode,
TimelineTabs,
} from '../../../timelines/store/timeline/model';
import { ColumnHeaderOptions, KqlMode } from '../../../timelines/store/timeline/model';
import { HeaderSection } from '../header_section';
import { defaultHeaders } from '../../../timelines/components/timeline/body/column_headers/default_headers';
import { Sort } from '../../../timelines/components/timeline/body/sort';
@ -45,7 +39,11 @@ import { inputsModel } from '../../store';
import { useManageTimeline } from '../../../timelines/components/manage_timeline';
import { ExitFullScreen } from '../exit_full_screen';
import { useGlobalFullScreen } from '../../containers/use_full_screen';
import { TimelineExpandedEvent, TimelineId } from '../../../../common/types/timeline';
import {
TimelineExpandedEventType,
TimelineId,
TimelineTabs,
} from '../../../../common/types/timeline';
import { GraphOverlay } from '../../../timelines/components/graph_overlay';
import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../../../timelines/components/timeline/styles';
@ -114,7 +112,7 @@ interface Props {
deletedEventIds: Readonly<string[]>;
docValueFields: DocValueFields[];
end: string;
expandedEvent: TimelineExpandedEvent;
expandedEvent: TimelineExpandedEventType;
filters: Filter[];
headerFilterGroup?: React.ReactNode;
height?: number;
@ -160,7 +158,6 @@ const EventsViewerComponent: React.FC<Props> = ({
utilityBar,
graphEventId,
}) => {
const dispatch = useDispatch();
const { globalFullScreen } = useGlobalFullScreen();
const columnsHeader = isEmpty(columns) ? defaultHeaders : columns;
const kibana = useKibana();
@ -191,9 +188,6 @@ const EventsViewerComponent: React.FC<Props> = ({
[justTitle]
);
const prevCombinedQueries = useRef<{
filterQuery: string;
} | null>(null);
const combinedQueries = combineQueries({
config: esQuery.getEsQueryConfig(kibana.services.uiSettings),
dataProviders,
@ -220,12 +214,6 @@ const EventsViewerComponent: React.FC<Props> = ({
queryFields,
]);
const prevSortField = useRef<
Array<{
field: string;
direction: Direction;
}>
>([]);
const sortField = useMemo(
() =>
sort.map(({ columnId, sortDirection }) => ({
@ -251,17 +239,6 @@ const EventsViewerComponent: React.FC<Props> = ({
skip: !canQueryTimeline,
});
useEffect(() => {
if (!deepEqual(prevCombinedQueries.current, combinedQueries)) {
prevCombinedQueries.current = combinedQueries;
dispatch(timelineActions.toggleExpandedEvent({ timelineId: id }));
}
if (!deepEqual(prevSortField.current, sortField)) {
prevSortField.current = sortField;
dispatch(timelineActions.toggleExpandedEvent({ timelineId: id }));
}
}, [combinedQueries, dispatch, id, sortField]);
const totalCountMinusDeleted = useMemo(
() => (totalCount > 0 ? totalCount - deletedEventIds.length : 0),
[deletedEventIds.length, totalCount]

View file

@ -166,7 +166,7 @@ const makeMapStateToProps = () => {
columns,
dataProviders,
deletedEventIds,
expandedEvent,
expandedEvent: expandedEvent?.query ?? {},
excludedRowRendererIds,
filters: getGlobalFiltersQuerySelector(state),
id,

View file

@ -12,3 +12,91 @@ export const mockEventViewerResponse = {
},
events: [],
};
export const mockEventViewerResponseWithEvents = {
totalCount: 1,
pageInfo: {
activePage: 0,
fakeTotalCount: 100,
},
events: [
{
ecs: {
_id: 'yb8TkHYBRgU82_bJu_rY',
timestamp: '2020-12-23T14:49:39.957Z',
_index: 'auditbeat-7.10.1-2020.12.18-000001',
'@timestamp': ['2020-12-23T14:49:39.957Z'],
event: {
module: ['system'],
action: ['process_started'],
category: ['process'],
dataset: ['process'],
kind: ['event'],
type: ['start'],
},
host: {
name: ['handsome'],
os: {
family: ['darwin'],
},
id: ['33'],
ip: ['0.0.0.0'],
},
user: {
name: ['handsome'],
},
message: ['Process node (PID: 77895) by user handsome STARTED'],
agent: {
type: ['auditbeat'],
},
process: {
hash: {
sha1: ['`12345678987654323456Y7U87654`'],
},
pid: ['77895'],
name: ['node'],
ppid: ['73537'],
args: [
'/Users/handsome/.nvm/versions/node/v14.15.3/bin/node',
'/Users/handsome/Documents/workspace/kibana/node_modules/jest-worker/build/workers/processChild.js',
],
entity_id: ['3arNfOyR9NwR2u03'],
executable: ['/Users/handsome/.nvm/versions/node/v14.15.3/bin/node'],
working_directory: ['/Users/handsome/Documents/workspace/kibana/x-pack'],
},
},
data: [
{
field: '@timestamp',
value: ['2020-12-23T14:49:39.957Z'],
},
{
field: 'event.module',
value: ['system'],
},
{
field: 'event.action',
value: ['process_started'],
},
{
field: 'host.name',
value: ['handsome'],
},
{
field: 'user.name',
value: ['handsome'],
},
{
field: 'message',
value: ['Process node (PID: 77895) by user handsome STARTED'],
},
{
field: 'event.dataset',
value: ['process'],
},
],
_id: 'yb8TkHYBRgU82_bJu_rY',
_index: 'auditbeat-7.10.1-2020.12.18-000001',
},
],
};

View file

@ -11,7 +11,7 @@ import { HostsTableType } from '../../../../hosts/store/model';
import { RouteSpyState, SiemRouteType } from '../../../utils/route/types';
import { TabNavigationProps } from '../tab_navigation/types';
import { NetworkRouteType } from '../../../../network/pages/navigation/types';
import { TimelineTabs } from '../../../../timelines/store/timeline/model';
import { TimelineTabs } from '../../../../../common/types/timeline';
const setBreadcrumbsMock = jest.fn();
const chromeMock = {

View file

@ -14,7 +14,7 @@ import { navTabs } from '../../../app/home/home_navigations';
import { HostsTableType } from '../../../hosts/store/model';
import { RouteSpyState } from '../../utils/route/types';
import { SiemNavigationProps, SiemNavigationComponentProps } from './types';
import { TimelineTabs } from '../../../timelines/store/timeline/model';
import { TimelineTabs } from '../../../../common/types/timeline';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');

View file

@ -6,12 +6,12 @@
import { mount } from 'enzyme';
import React from 'react';
import { TimelineTabs } from '../../../../../common/types/timeline';
import { navTabs } from '../../../../app/home/home_navigations';
import { SecurityPageName } from '../../../../app/types';
import { navTabsHostDetails } from '../../../../hosts/pages/details/nav_tabs';
import { HostsTableType } from '../../../../hosts/store/model';
import { TimelineTabs } from '../../../../timelines/store/timeline/model';
import { RouteSpyState } from '../../../utils/route/types';
import { CONSTANTS } from '../../url_state/constants';
import { TabNavigationComponent } from './';

View file

@ -12,11 +12,11 @@ import * as H from 'history';
import { Query, Filter } from '../../../../../../../src/plugins/data/public';
import { url } from '../../../../../../../src/plugins/kibana_utils/public';
import { TimelineId } from '../../../../common/types/timeline';
import { TimelineId, TimelineTabs } from '../../../../common/types/timeline';
import { SecurityPageName } from '../../../app/types';
import { inputsSelectors, State } from '../../store';
import { UrlInputsModel } from '../../store/inputs/model';
import { TimelineTabs, TimelineUrl } from '../../../timelines/store/timeline/model';
import { TimelineUrl } from '../../../timelines/store/timeline/model';
import { timelineSelectors } from '../../../timelines/store/timeline';
import { formatDate } from '../super_date_picker';
import { NavTab } from '../navigation/types';

View file

@ -17,7 +17,7 @@ import { Query } from '../../../../../../../src/plugins/data/public';
import { networkModel } from '../../../network/store';
import { hostsModel } from '../../../hosts/store';
import { HostsTableType } from '../../../hosts/store/model';
import { TimelineTabs } from '../../../timelines/store/timeline/model';
import { TimelineTabs } from '../../../../common/types/timeline';
type Action = 'PUSH' | 'POP' | 'REPLACE';
const pop: Action = 'POP';

View file

@ -24,13 +24,12 @@ import {
DEFAULT_INDEX_PATTERN,
} from '../../../common/constants';
import { networkModel } from '../../network/store';
import { TimelineType, TimelineStatus } from '../../../common/types/timeline';
import { TimelineType, TimelineStatus, TimelineTabs } from '../../../common/types/timeline';
import { mockManagementState } from '../../management/store/reducer';
import { ManagementState } from '../../management/types';
import { initialSourcererState, SourcererScopeName } from '../store/sourcerer/model';
import { mockBrowserFields, mockDocValueFields } from '../containers/source/mock';
import { mockIndexPattern } from './index_pattern';
import { TimelineTabs } from '../../timelines/store/timeline/model';
export const mockGlobalState: State = {
app: {

View file

@ -5,14 +5,19 @@
*/
import { FilterStateStore } from '../../../../../../src/plugins/data/common/es_query/filters/meta_filter';
import { TimelineId, TimelineType, TimelineStatus } from '../../../common/types/timeline';
import {
TimelineId,
TimelineType,
TimelineStatus,
TimelineTabs,
} from '../../../common/types/timeline';
import { OpenTimelineResult } from '../../timelines/components/open_timeline/types';
import { GetAllTimeline, SortFieldTimeline, TimelineResult, Direction } from '../../graphql/types';
import { TimelineEventsDetailsItem } from '../../../common/search_strategy';
import { allTimelinesQuery } from '../../timelines/containers/all/index.gql_query';
import { CreateTimelineProps } from '../../detections/components/alerts_table/types';
import { TimelineModel, TimelineTabs } from '../../timelines/store/timeline/model';
import { TimelineModel } from '../../timelines/store/timeline/model';
import { timelineDefaults } from '../../timelines/store/timeline/defaults';
export interface MockedProvidedQuery {

View file

@ -19,10 +19,14 @@ import {
} from '../../../common/mock/';
import { CreateTimeline, UpdateTimelineLoading } from './types';
import { Ecs } from '../../../../common/ecs';
import { TimelineId, TimelineType, TimelineStatus } from '../../../../common/types/timeline';
import {
TimelineId,
TimelineType,
TimelineStatus,
TimelineTabs,
} from '../../../../common/types/timeline';
import { ISearchStart } from '../../../../../../../src/plugins/data/public';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
import { TimelineTabs } from '../../../timelines/store/timeline/model';
jest.mock('apollo-client');

View file

@ -8,7 +8,7 @@ import { mount } from 'enzyme';
import React from 'react';
import { TestProviders } from '../../../../common/mock/test_providers';
import { TimelineTabs } from '../../../store/timeline/model';
import { TimelineTabs } from '../../../../../common/types/timeline';
import { FlyoutBottomBar } from '.';
describe('FlyoutBottomBar', () => {

View file

@ -14,7 +14,7 @@ import { DataProvider } from '../../timeline/data_providers/data_provider';
import { flattenIntoAndGroups } from '../../timeline/data_providers/helpers';
import { DataProviders } from '../../timeline/data_providers';
import { FlyoutHeaderPanel } from '../header';
import { TimelineTabs } from '../../../store/timeline/model';
import { TimelineTabs } from '../../../../../common/types/timeline';
export const FLYOUT_BUTTON_CLASS_NAME = 'timeline-flyout-button';

View file

@ -20,7 +20,7 @@ import styled from 'styled-components';
import { FormattedRelative } from '@kbn/i18n/react';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline';
import { TimelineStatus, TimelineTabs, TimelineType } from '../../../../../common/types/timeline';
import { timelineActions, timelineSelectors } from '../../../store/timeline';
import { timelineDefaults } from '../../../../timelines/store/timeline/defaults';
import { AddToFavoritesButton } from '../../timeline/properties/helpers';
@ -33,7 +33,6 @@ import { ActiveTimelines } from './active_timelines';
import * as i18n from './translations';
import * as commonI18n from '../../timeline/properties/translations';
import { getTimelineStatusByIdSelector } from './selectors';
import { TimelineTabs } from '../../../store/timeline/model';
// to hide side borders
const StyledPanel = styled(EuiPanel)`

View file

@ -11,10 +11,9 @@ import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { AppLeaveHandler } from '../../../../../../../src/core/public';
import { TimelineId, TimelineStatus } from '../../../../common/types/timeline';
import { TimelineId, TimelineStatus, TimelineTabs } from '../../../../common/types/timeline';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { timelineActions } from '../../store/timeline';
import { TimelineTabs } from '../../store/timeline/model';
import { FlyoutBottomBar } from './bottom_bar';
import { Pane } from './pane';
import { getTimelineShowStatusByIdSelector } from './selectors';

View file

@ -6,9 +6,8 @@
import { createSelector } from 'reselect';
import { TimelineStatus } from '../../../../common/types/timeline';
import { TimelineStatus, TimelineTabs } from '../../../../common/types/timeline';
import { timelineSelectors } from '../../store/timeline';
import { TimelineTabs } from '../../store/timeline/model';
export const getTimelineShowStatusByIdSelector = () =>
createSelector(timelineSelectors.selectTimeline, (timeline) => ({

View file

@ -39,12 +39,16 @@ import { KueryFilterQueryKind } from '../../../common/store/model';
import { Note } from '../../../common/lib/note';
import moment from 'moment';
import sinon from 'sinon';
import { TimelineId, TimelineType, TimelineStatus } from '../../../../common/types/timeline';
import {
TimelineId,
TimelineType,
TimelineStatus,
TimelineTabs,
} from '../../../../common/types/timeline';
import {
mockTimeline as mockSelectedTimeline,
mockTemplate as mockSelectedTemplate,
} from './__mocks__';
import { TimelineTabs } from '../../store/timeline/model';
jest.mock('../../../common/store/inputs/actions');
jest.mock('../../../common/components/url_state/normalize_time_range.ts');

View file

@ -27,6 +27,7 @@ import {
TimelineId,
TimelineStatus,
TimelineType,
TimelineTabs,
} from '../../../../common/types/timeline';
import {
@ -42,11 +43,7 @@ import {
addTimeline as dispatchAddTimeline,
addNote as dispatchAddGlobalTimelineNote,
} from '../../../timelines/store/timeline/actions';
import {
ColumnHeaderOptions,
TimelineModel,
TimelineTabs,
} from '../../../timelines/store/timeline/model';
import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
import {

View file

@ -17,6 +17,7 @@ import { MarkdownRenderer } from '../../../../common/components/markdown_editor'
import { timelineActions } from '../../../store/timeline';
import { NOTE_CONTENT_CLASS_NAME } from '../../timeline/body/helpers';
import * as i18n from './translations';
import { TimelineTabs } from '../../../../../common/types/timeline';
export const NotePreviewsContainer = styled.section`
padding-top: ${({ theme }) => `${theme.eui.euiSizeS}`};
@ -37,6 +38,7 @@ const ToggleEventDetailsButtonComponent: React.FC<ToggleEventDetailsButtonProps>
const handleClick = useCallback(() => {
dispatch(
timelineActions.toggleExpandedEvent({
tabType: TimelineTabs.notes,
timelineId,
event: {
eventId,

View file

@ -11,7 +11,8 @@ import { getOr } from 'lodash/fp';
import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '../../../../../common/components/drag_and_drop/helpers';
import { Ecs } from '../../../../../../common/ecs';
import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline';
import { ColumnHeaderOptions, TimelineTabs } from '../../../../../timelines/store/timeline/model';
import { TimelineTabs } from '../../../../../../common/types/timeline';
import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model';
import { ARIA_COLUMN_INDEX_OFFSET } from '../../helpers';
import { EventsTd, EVENTS_TD_CLASS_NAME, EventsTdContent, EventsTdGroupData } from '../../styles';
import { ColumnRenderer } from '../renderers/column_renderer';

View file

@ -11,7 +11,7 @@ import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants';
import * as i18n from '../translations';
import { EventColumnView } from './event_column_view';
import { TimelineType } from '../../../../../../common/types/timeline';
import { TimelineTabs, TimelineType } from '../../../../../../common/types/timeline';
import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector';
jest.mock('../../../../../common/hooks/use_selector');
@ -48,6 +48,7 @@ describe('EventColumnView', () => {
selectedEventIds: {},
showCheckboxes: false,
showNotes: false,
tabType: TimelineTabs.query,
timelineId: 'timeline-test',
toggleShowNotes: jest.fn(),
updateNote: jest.fn(),

View file

@ -9,7 +9,7 @@ import React, { useCallback, useMemo } from 'react';
import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector';
import { Ecs } from '../../../../../../common/ecs';
import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline';
import { ColumnHeaderOptions, TimelineTabs } from '../../../../../timelines/store/timeline/model';
import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model';
import { OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events';
import { EventsTrData } from '../../styles';
import { Actions } from '../actions';
@ -26,7 +26,7 @@ import { InvestigateInTimelineAction } from '../../../../../detections/component
import { AddEventNoteAction } from '../actions/add_note_icon_item';
import { PinEventAction } from '../actions/pin_event_action';
import { inputsModel } from '../../../../../common/store';
import { TimelineId } from '../../../../../../common/types/timeline';
import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
import { timelineSelectors } from '../../../../store/timeline';
import { timelineDefaults } from '../../../../store/timeline/defaults';
import { AddToCaseAction } from '../../../../../cases/components/timeline_actions/add_to_case_action';

View file

@ -12,7 +12,8 @@ import {
TimelineItem,
TimelineNonEcsData,
} from '../../../../../../common/search_strategy/timeline';
import { ColumnHeaderOptions, TimelineTabs } from '../../../../../timelines/store/timeline/model';
import { TimelineTabs } from '../../../../../../common/types/timeline';
import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model';
import { OnRowSelected } from '../../events';
import { EventsTbody } from '../../styles';
import { ColumnRenderer } from '../renderers/column_renderer';

View file

@ -8,13 +8,13 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
import { TimelineId } from '../../../../../../common/types/timeline';
import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
import { BrowserFields } from '../../../../../common/containers/source';
import {
TimelineItem,
TimelineNonEcsData,
} from '../../../../../../common/search_strategy/timeline';
import { ColumnHeaderOptions, TimelineTabs } from '../../../../../timelines/store/timeline/model';
import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model';
import { OnPinEvent, OnRowSelected } from '../../events';
import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../../helpers';
import { EventsTrGroup, EventsTrSupplement, EventsTrSupplementContainer } from '../../styles';
@ -92,7 +92,10 @@ const StatefulEventComponent: React.FC<Props> = ({
const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({});
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const expandedEvent = useDeepEqualSelector(
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent
(state) =>
(getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent[
tabType ?? TimelineTabs.query
] ?? {}
);
const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []);
const notesById = useDeepEqualSelector(getNotesByIds);
@ -153,6 +156,7 @@ const StatefulEventComponent: React.FC<Props> = ({
dispatch(
timelineActions.toggleExpandedEvent({
tabType,
timelineId,
event: {
eventId,
@ -161,10 +165,10 @@ const StatefulEventComponent: React.FC<Props> = ({
})
);
if (timelineId === TimelineId.active) {
if (timelineId === TimelineId.active && tabType === TimelineTabs.query) {
activeTimeline.toggleExpandedEvent({ eventId, indexName });
}
}, [dispatch, event._id, event._index, timelineId]);
}, [dispatch, event._id, event._index, tabType, timelineId]);
const associateNote = useCallback(
(noteId: string) => {

View file

@ -16,12 +16,11 @@ import {
TimelineTypeLiteral,
TimelineType,
TimelineId,
TimelineTabs,
} from '../../../../../common/types/timeline';
import { OnPinEvent, OnUnPinEvent } from '../events';
import { ActionIconItem } from './actions/action_icon_item';
import * as i18n from './translations';
import { TimelineTabs } from '../../../store/timeline/model';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const omitTypenameAndEmpty = (k: string, v: any): any | undefined =>

View file

@ -17,7 +17,7 @@ import { BodyComponent, StatefulBodyProps } from '.';
import { Sort } from './sort';
import { useMountAppended } from '../../../../common/utils/use_mount_appended';
import { timelineActions } from '../../../store/timeline';
import { TimelineTabs } from '../../../store/timeline/model';
import { TimelineTabs } from '../../../../../common/types/timeline';
const mockSort: Sort[] = [
{
@ -221,4 +221,78 @@ describe('Body', () => {
);
});
});
describe('event details', () => {
beforeEach(() => {
mockDispatch.mockReset();
});
test('call the right reduce action to show event details for query tab', async () => {
const wrapper = mount(
<TestProviders>
<BodyComponent {...props} />
</TestProviders>
);
wrapper.find(`[data-test-subj="expand-event"]`).first().simulate('click');
wrapper.update();
expect(mockDispatch).toBeCalledTimes(1);
expect(mockDispatch.mock.calls[0][0]).toEqual({
payload: {
event: {
eventId: '1',
indexName: undefined,
},
tabType: 'query',
timelineId: 'timeline-test',
},
type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT',
});
});
test('call the right reduce action to show event details for pinned tab', async () => {
const wrapper = mount(
<TestProviders>
<BodyComponent {...props} tabType={TimelineTabs.pinned} />
</TestProviders>
);
wrapper.find(`[data-test-subj="expand-event"]`).first().simulate('click');
wrapper.update();
expect(mockDispatch).toBeCalledTimes(1);
expect(mockDispatch.mock.calls[0][0]).toEqual({
payload: {
event: {
eventId: '1',
indexName: undefined,
},
tabType: 'pinned',
timelineId: 'timeline-test',
},
type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT',
});
});
test('call the right reduce action to show event details for notes tab', async () => {
const wrapper = mount(
<TestProviders>
<BodyComponent {...props} tabType={TimelineTabs.notes} />
</TestProviders>
);
wrapper.find(`[data-test-subj="expand-event"]`).first().simulate('click');
wrapper.update();
expect(mockDispatch).toBeCalledTimes(1);
expect(mockDispatch.mock.calls[0][0]).toEqual({
payload: {
event: {
eventId: '1',
indexName: undefined,
},
tabType: 'notes',
timelineId: 'timeline-test',
},
type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT',
});
});
});
});

View file

@ -10,7 +10,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { connect, ConnectedProps } from 'react-redux';
import deepEqual from 'fast-deep-equal';
import { RowRendererId, TimelineId } from '../../../../../common/types/timeline';
import { RowRendererId, TimelineId, TimelineTabs } from '../../../../../common/types/timeline';
import {
FIRST_ARIA_INDEX,
ARIA_COLINDEX_ATTRIBUTE,
@ -21,7 +21,7 @@ import { BrowserFields } from '../../../../common/containers/source';
import { TimelineItem } from '../../../../../common/search_strategy/timeline';
import { inputsModel, State } from '../../../../common/store';
import { useManageTimeline } from '../../manage_timeline';
import { ColumnHeaderOptions, TimelineModel, TimelineTabs } from '../../../store/timeline/model';
import { ColumnHeaderOptions, TimelineModel } from '../../../store/timeline/model';
import { timelineDefaults } from '../../../store/timeline/defaults';
import { timelineActions, timelineSelectors } from '../../../store/timeline';
import { OnRowSelected, OnSelectAll } from '../events';

View file

@ -25,10 +25,12 @@ import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useTimelineEventsDetails } from '../../containers/details';
import { timelineSelectors } from '../../store/timeline';
import { timelineDefaults } from '../../store/timeline/defaults';
import { TimelineTabs } from '../../../../common/types/timeline';
interface EventDetailsProps {
browserFields: BrowserFields;
docValueFields: DocValueFields[];
tabType: TimelineTabs;
timelineId: string;
handleOnEventClosed?: HandleOnEventClosed;
}
@ -36,12 +38,13 @@ interface EventDetailsProps {
const EventDetailsComponent: React.FC<EventDetailsProps> = ({
browserFields,
docValueFields,
tabType,
timelineId,
handleOnEventClosed,
}) => {
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const expandedEvent = useDeepEqualSelector(
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent[tabType] ?? {}
);
const [loading, detailsData] = useTimelineEventsDetails({
@ -71,6 +74,7 @@ const EventDetailsComponent: React.FC<EventDetailsProps> = ({
isAlert={isAlert}
loading={loading}
timelineId={timelineId}
timelineTabType={tabType}
/>
</>
);

View file

@ -20,7 +20,7 @@ import {
import React, { useMemo, useState } from 'react';
import styled from 'styled-components';
import { TimelineExpandedEvent } from '../../../../../common/types/timeline';
import { TimelineExpandedEventType, TimelineTabs } from '../../../../../common/types/timeline';
import { BrowserFields } from '../../../../common/containers/source';
import {
EventDetails,
@ -35,9 +35,10 @@ export type HandleOnEventClosed = () => void;
interface Props {
browserFields: BrowserFields;
detailsData: TimelineEventsDetailsItem[] | null;
event: TimelineExpandedEvent;
event: TimelineExpandedEventType;
isAlert: boolean;
loading: boolean;
timelineTabType: TimelineTabs | 'flyout';
timelineId: string;
}
@ -71,7 +72,7 @@ export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
ExpandableEventTitle.displayName = 'ExpandableEventTitle';
export const ExpandableEvent = React.memo<Props>(
({ browserFields, event, timelineId, isAlert, loading, detailsData }) => {
({ browserFields, event, timelineId, timelineTabType, isAlert, loading, detailsData }) => {
const [view, setView] = useState<View>(EventsViewType.summaryView);
const message = useMemo(() => {
@ -116,6 +117,7 @@ export const ExpandableEvent = React.memo<Props>(
id={event.eventId!}
isAlert={isAlert}
onViewSelected={setView}
timelineTabType={timelineTabType}
timelineId={timelineId}
view={view}
/>

View file

@ -17,7 +17,7 @@ import { isTab } from '../../../common/components/accessibility/helpers';
import { useSourcererScope } from '../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { FlyoutHeader, FlyoutHeaderPanel } from '../flyout/header';
import { TimelineType } from '../../../../common/types/timeline';
import { TimelineType, TimelineTabs } from '../../../../common/types/timeline';
import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector';
import { activeTimeline } from '../../containers/active_timeline_context';
import { EVENTS_COUNT_BUTTON_CLASS_NAME, onTimelineTabKeyPressed } from './helpers';
@ -68,7 +68,9 @@ const StatefulTimelineComponent: React.FC<Props> = ({ timelineId }) => {
id: timelineId,
columns: defaultHeaders,
indexNames: selectedPatterns,
expandedEvent: activeTimeline.getExpandedEvent(),
expandedEvent: {
[TimelineTabs.query]: activeTimeline.getExpandedEvent(),
},
show: false,
})
);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { filter, pick, uniqBy } from 'lodash/fp';
import { filter, uniqBy } from 'lodash/fp';
import {
EuiAvatar,
EuiFlexGroup,
@ -21,17 +21,17 @@ import styled from 'styled-components';
import { useSourcererScope } from '../../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { timelineActions, timelineSelectors } from '../../../store/timeline';
import { timelineActions } from '../../../store/timeline';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { TimelineStatus } from '../../../../../common/types/timeline';
import { TimelineStatus, TimelineTabs } from '../../../../../common/types/timeline';
import { appSelectors } from '../../../../common/store/app';
import { timelineDefaults } from '../../../store/timeline/defaults';
import { AddNote } from '../../notes/add_note';
import { CREATED_BY, NOTES } from '../../notes/translations';
import { PARTICIPANTS } from '../../../../cases/translations';
import { NotePreviews } from '../../open_timeline/note_previews';
import { TimelineResultNote } from '../../open_timeline/types';
import { EventDetails } from '../event_details';
import { getTimelineNoteSelector } from './selectors';
const FullWidthFlexGroup = styled(EuiFlexGroup)`
width: 100%;
@ -121,18 +121,14 @@ interface NotesTabContentProps {
const NotesTabContentComponent: React.FC<NotesTabContentProps> = ({ timelineId }) => {
const dispatch = useDispatch();
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const getTimelineNotes = useMemo(() => getTimelineNoteSelector(), []);
const {
createdBy,
expandedEvent,
eventIdToNoteIds,
noteIds,
status: timelineStatus,
} = useDeepEqualSelector((state) =>
pick(
['createdBy', 'expandedEvent', 'eventIdToNoteIds', 'status'],
getTimeline(state, timelineId) ?? timelineDefaults
)
);
} = useDeepEqualSelector((state) => getTimelineNotes(state, timelineId));
const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.timeline);
@ -142,7 +138,20 @@ const NotesTabContentComponent: React.FC<NotesTabContentProps> = ({ timelineId }
);
const [newNote, setNewNote] = useState('');
const isImmutable = timelineStatus === TimelineStatus.immutable;
const notes: TimelineResultNote[] = useDeepEqualSelector(getNotesAsCommentsList);
const appNotes: TimelineResultNote[] = useDeepEqualSelector(getNotesAsCommentsList);
const allTimelineNoteIds = useMemo(() => {
const eventNoteIds = Object.values(eventIdToNoteIds).reduce<string[]>(
(acc, v) => [...acc, ...v],
[]
);
return [...noteIds, ...eventNoteIds];
}, [noteIds, eventIdToNoteIds]);
const notes = useMemo(
() => appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote?.noteId ?? '-1')),
[appNotes, allTimelineNoteIds]
);
// filter for savedObjectId to make sure we don't display `elastic` user while saving the note
const participants = useMemo(() => uniqBy('updatedBy', filter('savedObjectId', notes)), [notes]);
@ -153,20 +162,21 @@ const NotesTabContentComponent: React.FC<NotesTabContentProps> = ({ timelineId }
);
const handleOnEventClosed = useCallback(() => {
dispatch(timelineActions.toggleExpandedEvent({ timelineId }));
dispatch(timelineActions.toggleExpandedEvent({ tabType: TimelineTabs.notes, timelineId }));
}, [dispatch, timelineId]);
const EventDetailsContent = useMemo(
() =>
expandedEvent.eventId ? (
expandedEvent?.eventId != null ? (
<EventDetails
browserFields={browserFields}
docValueFields={docValueFields}
handleOnEventClosed={handleOnEventClosed}
tabType={TimelineTabs.notes}
timelineId={timelineId}
/>
) : null,
[browserFields, docValueFields, expandedEvent.eventId, handleOnEventClosed, timelineId]
[browserFields, docValueFields, expandedEvent, handleOnEventClosed, timelineId]
);
const SidebarContent = useMemo(

View file

@ -0,0 +1,20 @@
/*
* 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 { createSelector } from 'reselect';
import { timelineSelectors } from '../../../store/timeline';
export const getTimelineNoteSelector = () =>
createSelector(timelineSelectors.selectTimeline, (timeline) => {
return {
createdBy: timeline.createdBy,
expandedEvent: timeline.expandedEvent?.notes ?? {},
eventIdToNoteIds: timeline?.eventIdToNoteIds ?? {},
noteIds: timeline.noteIds,
status: timeline.status,
};
});

View file

@ -23,11 +23,12 @@ import { EventDetailsWidthProvider } from '../../../../common/components/events_
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { timelineDefaults } from '../../../store/timeline/defaults';
import { useSourcererScope } from '../../../../common/containers/sourcerer';
import { TimelineModel, TimelineTabs } from '../../../store/timeline/model';
import { TimelineModel } from '../../../store/timeline/model';
import { EventDetails } from '../event_details';
import { ToggleExpandedEvent } from '../../../store/timeline/actions';
import { State } from '../../../../common/store';
import { calculateTotalPages } from '../helpers';
import { TimelineTabs } from '../../../../../common/types/timeline';
const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
overflow-y: hidden;
@ -167,7 +168,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
});
const handleOnEventClosed = useCallback(() => {
onEventClosed({ timelineId });
onEventClosed({ tabType: TimelineTabs.pinned, timelineId });
}, [timelineId, onEventClosed]);
return (
@ -218,6 +219,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
<EventDetails
browserFields={browserFields}
docValueFields={docValueFields}
tabType={TimelineTabs.pinned}
timelineId={timelineId}
handleOnEventClosed={handleOnEventClosed}
/>
@ -248,7 +250,7 @@ const makeMapStateToProps = () => {
itemsPerPage,
itemsPerPageOptions,
pinnedEventIds,
showEventDetails: !!expandedEvent.eventId,
showEventDetails: !!expandedEvent[TimelineTabs.pinned]?.eventId,
sort,
};
};

View file

@ -17,12 +17,11 @@ import { QueryTabContentComponent, Props as QueryTabContentComponentProps } from
import { Sort } from '../body/sort';
import { mockDataProviders } from '../data_providers/mock/mock_data_providers';
import { useMountAppended } from '../../../../common/utils/use_mount_appended';
import { TimelineId, TimelineStatus } from '../../../../../common/types/timeline';
import { TimelineId, TimelineStatus, TimelineTabs } from '../../../../../common/types/timeline';
import { useTimelineEvents } from '../../../containers/index';
import { useTimelineEventsDetails } from '../../../containers/details/index';
import { useSourcererScope } from '../../../../common/containers/sourcerer';
import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks';
import { TimelineTabs } from '../../../store/timeline/model';
jest.mock('../../../containers/index', () => ({
useTimelineEvents: jest.fn(),

View file

@ -14,7 +14,7 @@ import {
EuiBadge,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import styled from 'styled-components';
import { Dispatch } from 'redux';
import { connect, ConnectedProps } from 'react-redux';
@ -33,7 +33,7 @@ import { calculateTotalPages, combineQueries } from '../helpers';
import { TimelineRefetch } from '../refetch_timeline';
import { esQuery, FilterManager } from '../../../../../../../../src/plugins/data/public';
import { useManageTimeline } from '../../manage_timeline';
import { TimelineEventsType, TimelineId } from '../../../../../common/types/timeline';
import { TimelineEventsType, TimelineId, TimelineTabs } from '../../../../../common/types/timeline';
import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config';
import { SuperDatePicker } from '../../../../common/components/super_date_picker';
import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context';
@ -44,7 +44,7 @@ import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { timelineDefaults } from '../../../../timelines/store/timeline/defaults';
import { useSourcererScope } from '../../../../common/containers/sourcerer';
import { useTimelineEventsCountPortal } from '../../../../common/hooks/use_timeline_events_count';
import { TimelineModel, TimelineTabs } from '../../../../timelines/store/timeline/model';
import { TimelineModel } from '../../../../timelines/store/timeline/model';
import { EventDetails } from '../event_details';
import { TimelineDatePickerLock } from '../date_picker_lock';
import { HideShowContainer } from '../styles';
@ -173,9 +173,6 @@ export const QueryTabContentComponent: React.FC<Props> = ({
kqlQueryExpression,
]);
const prevCombinedQueries = useRef<{
filterQuery: string;
} | null>(null);
const combinedQueries = useMemo(
() =>
combineQueries({
@ -211,12 +208,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
return [...columnFields, ...requiredFieldsForActions];
}, [columns]);
const prevTimelineQuerySortField = useRef<
Array<{
field: string;
direction: Direction;
}>
>([]);
const timelineQuerySortField = useMemo(
() =>
sort.map(({ columnId, sortDirection }) => ({
@ -252,7 +244,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
});
const handleOnEventClosed = useCallback(() => {
onEventClosed({ timelineId });
onEventClosed({ tabType: TimelineTabs.query, timelineId });
if (timelineId === TimelineId.active) {
activeTimeline.toggleExpandedEvent({
@ -266,17 +258,6 @@ export const QueryTabContentComponent: React.FC<Props> = ({
setIsTimelineLoading({ id: timelineId, isLoading: isQueryLoading || loadingSourcerer });
}, [loadingSourcerer, timelineId, isQueryLoading, setIsTimelineLoading]);
useEffect(() => {
if (!deepEqual(prevCombinedQueries.current, combinedQueries)) {
prevCombinedQueries.current = combinedQueries;
handleOnEventClosed();
}
if (!deepEqual(prevTimelineQuerySortField.current, timelineQuerySortField)) {
prevTimelineQuerySortField.current = timelineQuerySortField;
handleOnEventClosed();
}
}, [combinedQueries, handleOnEventClosed, timelineQuerySortField]);
return (
<>
<InPortal node={timelineEventsCountPortalNode}>
@ -368,6 +349,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
<EventDetails
browserFields={browserFields}
docValueFields={docValueFields}
tabType={TimelineTabs.query}
timelineId={timelineId}
handleOnEventClosed={handleOnEventClosed}
/>
@ -416,7 +398,7 @@ const makeMapStateToProps = () => {
dataProviders,
eventType: eventType ?? 'raw',
end: input.timerange.to,
expandedEvent,
expandedEvent: expandedEvent[TimelineTabs.query] ?? {},
filters: timelineFilter,
timelineId,
isLive: input.policy.kind === 'interval',
@ -425,7 +407,7 @@ const makeMapStateToProps = () => {
kqlMode,
kqlQueryExpression,
showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state),
showEventDetails: !!expandedEvent.eventId,
showEventDetails: !!expandedEvent[TimelineTabs.query]?.eventId,
show,
sort,
start: input.timerange.from,

View file

@ -8,16 +8,21 @@ import { EuiBadge, EuiLoadingContent, EuiTabs, EuiTab } from '@elastic/eui';
import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { TimelineTabs } from '../../../../../common/types/timeline';
import { useShallowEqualSelector } from '../../../../common/hooks/use_selector';
import {
useShallowEqualSelector,
useDeepEqualSelector,
} from '../../../../common/hooks/use_selector';
import { TimelineEventsCountBadge } from '../../../../common/hooks/use_timeline_events_count';
import { timelineActions } from '../../../store/timeline';
import { TimelineTabs } from '../../../store/timeline/model';
import {
getActiveTabSelector,
getNoteIdsSelector,
getNotesSelector,
getPinnedEventSelector,
getShowTimelineSelector,
getEventIdToNoteIdsSelector,
} from './selectors';
import * as i18n from './translations';
@ -137,37 +142,55 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({ timelineId, graphEve
const getActiveTab = useMemo(() => getActiveTabSelector(), []);
const getShowTimeline = useMemo(() => getShowTimelineSelector(), []);
const getNumberOfPinnedEvents = useMemo(() => getPinnedEventSelector(), []);
const getNumberOfNotes = useMemo(() => getNotesSelector(), []);
const getAppNotes = useMemo(() => getNotesSelector(), []);
const getTimelineNoteIds = useMemo(() => getNoteIdsSelector(), []);
const getTimelinePinnedEventNotes = useMemo(() => getEventIdToNoteIdsSelector(), []);
const activeTab = useShallowEqualSelector((state) => getActiveTab(state, timelineId));
const showTimeline = useShallowEqualSelector((state) => getShowTimeline(state, timelineId));
const numberOfPinnedEvents = useShallowEqualSelector((state) =>
getNumberOfPinnedEvents(state, timelineId)
);
const numberOfNotes = useShallowEqualSelector((state) => getNumberOfNotes(state));
const globalTimelineNoteIds = useDeepEqualSelector((state) =>
getTimelineNoteIds(state, timelineId)
);
const eventIdToNoteIds = useDeepEqualSelector((state) =>
getTimelinePinnedEventNotes(state, timelineId)
);
const appNotes = useDeepEqualSelector((state) => getAppNotes(state));
const allTimelineNoteIds = useMemo(() => {
const eventNoteIds = Object.values(eventIdToNoteIds).reduce<string[]>(
(acc, v) => [...acc, ...v],
[]
);
return [...globalTimelineNoteIds, ...eventNoteIds];
}, [globalTimelineNoteIds, eventIdToNoteIds]);
const numberOfNotes = useMemo(
() => appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote.id)).length,
[appNotes, allTimelineNoteIds]
);
const setQueryAsActiveTab = useCallback(() => {
dispatch(timelineActions.toggleExpandedEvent({ timelineId }));
dispatch(
timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.query })
);
}, [dispatch, timelineId]);
const setGraphAsActiveTab = useCallback(() => {
dispatch(timelineActions.toggleExpandedEvent({ timelineId }));
dispatch(
timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.graph })
);
}, [dispatch, timelineId]);
const setNotesAsActiveTab = useCallback(() => {
dispatch(timelineActions.toggleExpandedEvent({ timelineId }));
dispatch(
timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.notes })
);
}, [dispatch, timelineId]);
const setPinnedAsActiveTab = useCallback(() => {
dispatch(timelineActions.toggleExpandedEvent({ timelineId }));
dispatch(
timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.pinned })
);

View file

@ -5,8 +5,8 @@
*/
import { createSelector } from 'reselect';
import { TimelineTabs } from '../../../../../common/types/timeline';
import { selectNotesById } from '../../../../common/store/app/selectors';
import { TimelineTabs } from '../../../store/timeline/model';
import { selectTimeline } from '../../../store/timeline/selectors';
export const getActiveTabSelector = () =>
@ -18,5 +18,11 @@ export const getShowTimelineSelector = () =>
export const getPinnedEventSelector = () =>
createSelector(selectTimeline, (timeline) => Object.keys(timeline?.pinnedEventIds ?? {}).length);
export const getNoteIdsSelector = () =>
createSelector(selectTimeline, (timeline) => timeline?.noteIds ?? []);
export const getEventIdToNoteIdsSelector = () =>
createSelector(selectTimeline, (timeline) => timeline?.eventIdToNoteIds ?? {});
export const getNotesSelector = () =>
createSelector(selectNotesById, (notesById) => Object.keys(notesById ?? {}).length);
createSelector(selectNotesById, (notesById) => Object.values(notesById));

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { TimelineExpandedEvent } from '../../../common/types/timeline';
import { TimelineExpandedEventType } from '../../../common/types/timeline';
import { TimelineEventsAllRequestOptions } from '../../../common/search_strategy/timeline';
import { TimelineArgs } from '.';
@ -21,7 +21,7 @@ import { TimelineArgs } from '.';
class ActiveTimelineEvents {
private _activePage: number = 0;
private _expandedEvent: TimelineExpandedEvent = {};
private _expandedEvent: TimelineExpandedEventType = {};
private _pageName: string = '';
private _request: TimelineEventsAllRequestOptions | null = null;
private _response: TimelineArgs | null = null;
@ -38,7 +38,7 @@ class ActiveTimelineEvents {
return this._expandedEvent;
}
toggleExpandedEvent(expandedEvent: TimelineExpandedEvent) {
toggleExpandedEvent(expandedEvent: TimelineExpandedEventType) {
if (expandedEvent.eventId === this._expandedEvent.eventId) {
this._expandedEvent = {};
} else {
@ -46,7 +46,7 @@ class ActiveTimelineEvents {
}
}
setExpandedEvent(expandedEvent: TimelineExpandedEvent) {
setExpandedEvent(expandedEvent: TimelineExpandedEventType) {
this._expandedEvent = expandedEvent;
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { noop } from 'lodash/fp';
import { isEmpty, noop } from 'lodash/fp';
import { useCallback, useEffect, useRef, useState } from 'react';
import deepEqual from 'fast-deep-equal';
@ -18,6 +18,7 @@ import {
TimelineEventsDetailsStrategyResponse,
} from '../../../../common/search_strategy';
import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/public';
import { AbortError } from '../../../../../../../src/plugins/kibana_utils/common';
export interface EventsArgs {
detailsData: TimelineEventsDetailsItem[] | null;
}
@ -50,7 +51,7 @@ export const useTimelineEventsDetails = ({
const timelineDetailsSearch = useCallback(
(request: TimelineEventsDetailsRequestOptions | null) => {
if (request == null || skip) {
if (request == null || skip || isEmpty(request.eventId)) {
return;
}
@ -84,11 +85,13 @@ export const useTimelineEventsDetails = ({
searchSubscription$.unsubscribe();
}
},
error: () => {
error: (msg) => {
if (!didCancel) {
setLoading(false);
}
notifications.toasts.addDanger('Failed to run search');
if (!(msg instanceof AbortError)) {
notifications.toasts.addDanger('Failed to run search');
}
},
});
};

View file

@ -15,13 +15,15 @@ import {
} from '../../../timelines/components/timeline/data_providers/data_provider';
import { SerializedFilterQuery } from '../../../common/store/types';
import { KqlMode, TimelineModel, ColumnHeaderOptions, TimelineTabs } from './model';
import { KqlMode, TimelineModel, ColumnHeaderOptions } from './model';
import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline';
import {
TimelineEventsType,
TimelineExpandedEvent,
TimelineExpandedEventType,
TimelineTypeLiteral,
RowRendererId,
TimelineExpandedEvent,
TimelineTabs,
} from '../../../../common/types/timeline';
import { InsertTimeline } from './types';
@ -36,8 +38,9 @@ export const addNoteToEvent = actionCreator<{ id: string; noteId: string; eventI
);
export interface ToggleExpandedEvent {
event?: TimelineExpandedEventType;
tabType?: TimelineTabs;
timelineId: string;
event?: TimelineExpandedEvent;
}
export const toggleExpandedEvent = actionCreator<ToggleExpandedEvent>('TOGGLE_EXPANDED_EVENT');

View file

@ -4,12 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { TimelineType, TimelineStatus } from '../../../../common/types/timeline';
import { TimelineType, TimelineStatus, TimelineTabs } from '../../../../common/types/timeline';
import { Direction } from '../../../graphql/types';
import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers';
import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range';
import { SubsetTimelineModel, TimelineModel, TimelineTabs } from './model';
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);

View file

@ -5,10 +5,10 @@
*/
import { Filter, esFilters } from '../../../../../../../src/plugins/data/public';
import { TimelineType, TimelineStatus } from '../../../../common/types/timeline';
import { TimelineType, TimelineStatus, TimelineTabs } from '../../../../common/types/timeline';
import { Direction } from '../../../graphql/types';
import { convertTimelineAsInput } from './epic';
import { TimelineModel, TimelineTabs } from './model';
import { TimelineModel } from './model';
describe('Epic Timeline', () => {
describe('#convertTimelineAsInput ', () => {

View file

@ -40,8 +40,7 @@ import { Direction } from '../../../graphql/types';
import { addTimelineInStorage } from '../../containers/local_storage';
import { isPageTimeline } from './epic_local_storage';
import { TimelineId, TimelineStatus } from '../../../../common/types/timeline';
import { TimelineTabs } from './model';
import { TimelineId, TimelineStatus, TimelineTabs } from '../../../../common/types/timeline';
jest.mock('../../containers/local_storage');

View file

@ -17,11 +17,11 @@ import type {
TimelineType,
TimelineStatus,
RowRendererId,
TimelineTabs,
} from '../../../../common/types/timeline';
export const DEFAULT_PAGE_COUNT = 2; // Eui Pager will not render unless this is a minimum of 2 pages
export type KqlMode = 'filter' | 'search';
export type ColumnHeaderType = 'not-filtered' | 'text-filter';
/** Uniquely identifies a column */
@ -43,13 +43,6 @@ export interface ColumnHeaderOptions {
width: number;
}
export enum TimelineTabs {
query = 'query',
graph = 'graph',
notes = 'notes',
pinned = 'pinned',
}
export interface TimelineModel {
/** The selected tab to displayed in the timeline */
activeTab: TimelineTabs;

View file

@ -5,7 +5,7 @@
*/
import { cloneDeep } from 'lodash/fp';
import { TimelineType, TimelineStatus } from '../../../../common/types/timeline';
import { TimelineType, TimelineStatus, TimelineTabs } from '../../../../common/types/timeline';
import {
IS_OPERATOR,
@ -40,7 +40,7 @@ import {
updateTimelineTitle,
upsertTimelineColumn,
} from './helpers';
import { ColumnHeaderOptions, TimelineModel, TimelineTabs } from './model';
import { ColumnHeaderOptions, TimelineModel } from './model';
import { timelineDefaults } from './defaults';
import { TimelineById } from './types';

View file

@ -103,7 +103,7 @@ import {
} from './helpers';
import { TimelineState, EMPTY_TIMELINE_BY_ID } from './types';
import { TimelineType } from '../../../../common/types/timeline';
import { TimelineType, TimelineTabs } from '../../../../common/types/timeline';
export const initialTimelineState: TimelineState = {
timelineById: EMPTY_TIMELINE_BY_ID,
@ -178,16 +178,22 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState)
...state,
timelineById: addTimelineNoteToEvent({ id, noteId, eventId, timelineById: state.timelineById }),
}))
.case(toggleExpandedEvent, (state, { timelineId, event = {} }) => ({
...state,
timelineById: {
...state.timelineById,
[timelineId]: {
...state.timelineById[timelineId],
expandedEvent: event,
.case(toggleExpandedEvent, (state, { tabType, timelineId, event = {} }) => {
const expandedTabType = tabType ?? TimelineTabs.query;
return {
...state,
timelineById: {
...state.timelineById,
[timelineId]: {
...state.timelineById[timelineId],
expandedEvent: {
...state.timelineById[timelineId].expandedEvent,
[expandedTabType]: event,
},
},
},
},
}))
};
})
.case(addProvider, (state, { id, provider }) => ({
...state,
timelineById: addTimelineProvider({ id, provider, timelineById: state.timelineById }),