From e18415b40142472b7c5ab76dc04227205f39b191 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Fri, 3 Jul 2020 14:08:22 -0400 Subject: [PATCH] [SECURITY] Bug fix for topN on draggables (#70450) * back to normal * add unit test * hover issue + indexToAdd issue * fix unit test * review II * fix bug + review * simplification * do not update state when component is unmounted * fix hover action on field name Co-authored-by: Elastic Machine --- .../alerts_histogram_panel/index.tsx | 7 +- .../common/components/charts/barchart.tsx | 5 +- .../charts/draggable_legend_item.tsx | 4 +- .../drag_drop_context_wrapper.tsx | 12 +-- .../drag_and_drop/draggable_wrapper.tsx | 58 +++++++++--- .../draggable_wrapper_hover_content.test.tsx | 34 +++++++ .../draggable_wrapper_hover_content.tsx | 91 ++++++++++++++++--- .../common/components/draggables/index.tsx | 4 +- .../components/event_details/columns.tsx | 1 - .../components/matrix_histogram/index.tsx | 3 + .../components/matrix_histogram/types.ts | 5 +- .../common/components/top_n/index.test.tsx | 25 +++-- .../public/common/components/top_n/index.tsx | 77 ++++++++-------- .../public/common/components/top_n/top_n.tsx | 10 +- .../components/with_hover_actions/index.tsx | 26 +++--- .../public/common/containers/source/index.tsx | 49 ++++------ .../public/common/lib/keury/index.test.ts | 18 ++-- .../public/common/lib/keury/index.ts | 7 +- .../components/events_by_dataset/index.tsx | 3 + .../components/signals_by_category/index.tsx | 3 + .../components/fields_browser/field_items.tsx | 1 - .../fields_browser/field_name.test.tsx | 4 +- .../components/fields_browser/field_name.tsx | 41 +++++++-- .../components/manage_timeline/index.tsx | 47 +++++++--- .../components/timeline/body/index.test.tsx | 15 +++ .../components/timeline/body/index.tsx | 1 + .../timeline/body/stateful_body.tsx | 4 +- .../timelines/components/timeline/styles.tsx | 5 +- .../components/timeline/timeline.tsx | 11 ++- 29 files changed, 399 insertions(+), 172 deletions(-) diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx index b6db6eb93d77f..b002700d7eff0 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx @@ -69,6 +69,7 @@ interface AlertsHistogramPanelProps { showLinkToAlerts?: boolean; showTotalAlertsCount?: boolean; stackByOptions?: AlertsHistogramOption[]; + timelineId?: string; title?: string; to: number; updateDateRange: UpdateDateRange; @@ -98,8 +99,9 @@ export const AlertsHistogramPanel = memo( showLinkToAlerts = false, showTotalAlertsCount = false, stackByOptions, - to, + timelineId, title = i18n.HISTOGRAM_HEADER, + to, updateDateRange, }) => { // create a unique, but stable (across re-renders) query id @@ -163,11 +165,12 @@ export const AlertsHistogramPanel = memo( `draggable-legend-item-${uuid.v4()}-${selectedStackByOption.value}-${bucket.key}` ), field: selectedStackByOption.value, + timelineId, value: bucket.key, })) : NO_LEGEND_DATA, // eslint-disable-next-line react-hooks/exhaustive-deps - [alertsData, selectedStackByOption.value] + [alertsData, selectedStackByOption.value, timelineId] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx index 42fc2ac9b8453..fba8c3faa9237 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx @@ -117,6 +117,7 @@ interface BarChartComponentProps { barChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; stackByField?: string; + timelineId?: string; } const NO_LEGEND_DATA: LegendItem[] = []; @@ -125,6 +126,7 @@ export const BarChartComponent: React.FC = ({ barChart, configs, stackByField, + timelineId, }) => { const { ref: measureRef, width, height } = useThrottledResizeObserver(); const legendItems: LegendItem[] = useMemo( @@ -135,11 +137,12 @@ export const BarChartComponent: React.FC = ({ dataProviderId: escapeDataProviderId( `draggable-legend-item-${uuid.v4()}-${stackByField}-${d.key}` ), + timelineId, field: stackByField, value: d.key, })) : NO_LEGEND_DATA, - [barChart, stackByField] + [barChart, stackByField, timelineId] ); const customHeight = get('customHeight', configs); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx index cdda1733932d5..bb71e5e73475d 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx @@ -21,13 +21,14 @@ export interface LegendItem { color?: string; dataProviderId: string; field: string; + timelineId?: string; value: string; } const DraggableLegendItemComponent: React.FC<{ legendItem: LegendItem; }> = ({ legendItem }) => { - const { color, dataProviderId, field, value } = legendItem; + const { color, dataProviderId, field, timelineId, value } = legendItem; return ( @@ -44,6 +45,7 @@ const DraggableLegendItemComponent: React.FC<{ data-test-subj={`legend-item-${dataProviderId}`} field={field} id={dataProviderId} + timelineId={timelineId} value={value} /> ) : ( diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx index 3edc1d0d84b69..74efe2d34fcca 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx @@ -18,11 +18,10 @@ import { IdToDataProvider } from '../../store/drag_and_drop/model'; import { State } from '../../store/types'; import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; import { reArrangeProviders } from '../../../timelines/components/timeline/data_providers/helpers'; -import { ACTIVE_TIMELINE_REDUX_ID } from '../top_n'; import { ADDED_TO_TIMELINE_MESSAGE } from '../../hooks/translations'; import { useAddToTimelineSensor } from '../../hooks/use_add_to_timeline'; import { displaySuccessToast, useStateToaster } from '../toasters'; - +import { TimelineId } from '../../../../common/types/timeline'; import { addFieldToTimelineColumns, addProviderToTimeline, @@ -35,7 +34,7 @@ import { userIsReArrangingProviders, } from './helpers'; -// @ts-ignore +// @ts-expect-error window['__react-beautiful-dnd-disable-dev-warnings'] = true; interface Props { @@ -67,7 +66,7 @@ const onDragEndHandler = ({ destination: result.destination, dispatch, source: result.source, - timelineId: ACTIVE_TIMELINE_REDUX_ID, + timelineId: TimelineId.active, }); } else if (providerWasDroppedOnTimeline(result)) { addProviderToTimeline({ @@ -76,7 +75,7 @@ const onDragEndHandler = ({ dispatch, onAddedToTimeline, result, - timelineId: ACTIVE_TIMELINE_REDUX_ID, + timelineId: TimelineId.active, }); } else if (fieldWasDroppedOnTimelineColumns(result)) { addFieldToTimelineColumns({ @@ -130,7 +129,6 @@ export const DragDropContextWrapperComponent = React.memo {children} @@ -152,7 +150,7 @@ const emptyActiveTimelineDataProviders: DataProvider[] = []; // stable reference const mapStateToProps = (state: State) => { const activeTimelineDataProviders = - timelineSelectors.getTimelineByIdSelector()(state, ACTIVE_TIMELINE_REDUX_ID)?.dataProviders ?? + timelineSelectors.getTimelineByIdSelector()(state, TimelineId.active)?.dataProviders ?? emptyActiveTimelineDataProviders; const dataProviders = dragAndDropSelectors.dataProvidersSelector(state) ?? emptyDataProviders; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx index 22b95f0d0c0e9..e7594365e8103 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { Draggable, DraggableProvided, @@ -22,7 +22,7 @@ import { DataProvider } from '../../../timelines/components/timeline/data_provid import { TruncatableText } from '../truncatable_text'; import { WithHoverActions } from '../with_hover_actions'; -import { DraggableWrapperHoverContent } from './draggable_wrapper_hover_content'; +import { DraggableWrapperHoverContent, useGetTimelineId } from './draggable_wrapper_hover_content'; import { getDraggableId, getDroppableId } from './helpers'; import { ProviderContainer } from './provider_container'; @@ -76,6 +76,7 @@ interface Props { dataProvider: DataProvider; inline?: boolean; render: RenderFunctionProp; + timelineId?: string; truncate?: boolean; onFilterAdded?: () => void; } @@ -100,16 +101,31 @@ export const getStyle = ( }; export const DraggableWrapper = React.memo( - ({ dataProvider, onFilterAdded, render, truncate }) => { + ({ dataProvider, onFilterAdded, render, timelineId, truncate }) => { + const draggableRef = useRef(null); + const [closePopOverTrigger, setClosePopOverTrigger] = useState(false); const [showTopN, setShowTopN] = useState(false); - const toggleTopN = useCallback(() => { - setShowTopN(!showTopN); - }, [setShowTopN, showTopN]); - + const [goGetTimelineId, setGoGetTimelineId] = useState(false); + const timelineIdFind = useGetTimelineId(draggableRef, goGetTimelineId); const [providerRegistered, setProviderRegistered] = useState(false); const dispatch = useDispatch(); + const handleClosePopOverTrigger = useCallback( + () => setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger), + [] + ); + + const toggleTopN = useCallback(() => { + setShowTopN((prevShowTopN) => { + const newShowTopN = !prevShowTopN; + if (newShowTopN === false) { + handleClosePopOverTrigger(); + } + return newShowTopN; + }); + }, [handleClosePopOverTrigger]); + const registerProvider = useCallback(() => { if (!providerRegistered) { dispatch(dragAndDropActions.registerProvider({ provider: dataProvider })); @@ -126,17 +142,19 @@ export const DraggableWrapper = React.memo( () => () => { unRegisterProvider(); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [unRegisterProvider] ); const hoverContent = useMemo( () => ( ( } /> ), - [dataProvider, onFilterAdded, showTopN, toggleTopN] + [ + dataProvider, + handleClosePopOverTrigger, + onFilterAdded, + showTopN, + timelineId, + timelineIdFind, + toggleTopN, + ] ); const renderContent = useCallback( @@ -184,7 +210,10 @@ export const DraggableWrapper = React.memo( { + provided.innerRef(e); + draggableRef.current = e; + }} data-test-subj="providerContainer" isDragging={snapshot.isDragging} registerProvider={registerProvider} @@ -214,7 +243,12 @@ export const DraggableWrapper = React.memo( ); return ( - + ); }, (prevProps, nextProps) => diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx index ee1dc73b27fe2..3507b0f8c447d 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx @@ -52,6 +52,7 @@ jest.mock('../../../timelines/components/manage_timeline', () => { return { ...original, useManageTimeline: () => ({ + getManageTimelineById: jest.fn().mockReturnValue({ indexToAdd: [] }), getTimelineFilterManager: mockGetTimelineFilterManager, isManagedTimeline: jest.fn().mockReturnValue(false), }), @@ -63,8 +64,10 @@ const timelineId = TimelineId.active; const field = 'process.name'; const value = 'nice'; const toggleTopN = jest.fn(); +const goGetTimelineId = jest.fn(); const defaultProps = { field, + goGetTimelineId, showTopN: false, timelineId, toggleTopN, @@ -130,6 +133,18 @@ describe('DraggableWrapperHoverContent', () => { wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first().exists() ).toBe(false); }); + + test(`it should call goGetTimelineId when user is over the 'Filter ${hoverAction} value' button`, () => { + const wrapper = mount( + + + + ); + const button = wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first(); + button.simulate('mouseenter'); + expect(goGetTimelineId).toHaveBeenCalledWith(true); + }); + describe('when run in the context of a timeline', () => { let wrapper: ReactWrapper; let onFilterAdded: () => void; @@ -151,6 +166,7 @@ describe('DraggableWrapperHoverContent', () => { ); }); + test('when clicked, it adds a filter to the timeline when running in the context of a timeline', () => { wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first().simulate('click'); wrapper.update(); @@ -459,6 +475,24 @@ describe('DraggableWrapperHoverContent', () => { expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(false); }); + test(`it should invokes goGetTimelineId when user is over the 'Show top field' button`, () => { + const whitelistedField = 'signal.rule.name'; + const wrapper = mount( + + + + ); + const button = wrapper.find(`[data-test-subj="show-top-field"]`).first(); + button.simulate('mouseenter'); + expect(goGetTimelineId).toHaveBeenCalledWith(true); + }); + test(`invokes the toggleTopN function when the 'Show top field' button is clicked`, async () => { const whitelistedField = 'signal.rule.name'; const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx index 4efdea5eee43b..a951bfa98d64b 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx @@ -5,7 +5,7 @@ */ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { DraggableId } from 'react-beautiful-dnd'; import { getAllFieldsByName, useWithSource } from '../../containers/source'; @@ -19,20 +19,25 @@ import { allowTopN } from './helpers'; import * as i18n from './translations'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { TimelineId } from '../../../../common/types/timeline'; +import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../../../timelines/components/timeline/styles'; interface Props { + closePopOver?: () => void; draggableId?: DraggableId; field: string; + goGetTimelineId?: (args: boolean) => void; onFilterAdded?: () => void; showTopN: boolean; - timelineId?: string; + timelineId?: string | null; toggleTopN: () => void; value?: string[] | string | null; } const DraggableWrapperHoverContentComponent: React.FC = ({ + closePopOver, draggableId, field, + goGetTimelineId, onFilterAdded, showTopN, timelineId, @@ -44,17 +49,37 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ const filterManagerBackup = useMemo(() => kibana.services.data.query.filterManager, [ kibana.services.data.query.filterManager, ]); - const { getTimelineFilterManager } = useManageTimeline(); + const { getManageTimelineById, getTimelineFilterManager } = useManageTimeline(); const filterManager = useMemo( () => - timelineId === TimelineId.active || - (draggableId != null && draggableId?.includes(TimelineId.active)) + timelineId === TimelineId.active ? getTimelineFilterManager(TimelineId.active) : filterManagerBackup, - [draggableId, timelineId, getTimelineFilterManager, filterManagerBackup] + [timelineId, getTimelineFilterManager, filterManagerBackup] ); + // Regarding data from useManageTimeline: + // * `indexToAdd`, which enables the alerts index to be appended to + // the `indexPattern` returned by `useWithSource`, may only be populated when + // this component is rendered in the context of the active timeline. This + // behavior enables the 'All events' view by appending the alerts index + // to the index pattern. + const { indexToAdd } = useMemo( + () => + timelineId === TimelineId.active + ? getManageTimelineById(TimelineId.active) + : { indexToAdd: null }, + [getManageTimelineById, timelineId] + ); + + const handleStartDragToTimeline = useCallback(() => { + startDragToTimeline(); + if (closePopOver != null) { + closePopOver(); + } + }, [closePopOver, startDragToTimeline]); + const filterForValue = useCallback(() => { const filter = value?.length === 0 ? createFilter(field, undefined) : createFilter(field, value); @@ -62,13 +87,15 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ if (activeFilterManager != null) { activeFilterManager.addFilters(filter); - + if (closePopOver != null) { + closePopOver(); + } if (onFilterAdded != null) { onFilterAdded(); } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, value, filterManager, onFilterAdded]); + }, [closePopOver, field, value, filterManager, onFilterAdded]); const filterOutValue = useCallback(() => { const filter = @@ -78,14 +105,23 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ if (activeFilterManager != null) { activeFilterManager.addFilters(filter); + if (closePopOver != null) { + closePopOver(); + } if (onFilterAdded != null) { onFilterAdded(); } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, value, filterManager, onFilterAdded]); + }, [closePopOver, field, value, filterManager, onFilterAdded]); - const { browserFields } = useWithSource(); + const handleGoGetTimelineId = useCallback(() => { + if (goGetTimelineId != null && timelineId == null) { + goGetTimelineId(true); + } + }, [goGetTimelineId, timelineId]); + + const { browserFields, indexPattern } = useWithSource('default', indexToAdd); return ( <> @@ -97,6 +133,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ data-test-subj="filter-for-value" iconType="magnifyWithPlus" onClick={filterForValue} + onMouseEnter={handleGoGetTimelineId} /> )} @@ -109,6 +146,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ data-test-subj="filter-out-value" iconType="magnifyWithMinus" onClick={filterOutValue} + onMouseEnter={handleGoGetTimelineId} /> )} @@ -120,7 +158,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ color="text" data-test-subj="add-to-timeline" iconType="timeline" - onClick={startDragToTimeline} + onClick={handleStartDragToTimeline} /> )} @@ -139,6 +177,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ data-test-subj="show-top-field" iconType="visBarVertical" onClick={toggleTopN} + onMouseEnter={handleGoGetTimelineId} /> )} @@ -147,7 +186,10 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ @@ -172,3 +214,30 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ DraggableWrapperHoverContentComponent.displayName = 'DraggableWrapperHoverContentComponent'; export const DraggableWrapperHoverContent = React.memo(DraggableWrapperHoverContentComponent); + +export const useGetTimelineId = function ( + elem: React.MutableRefObject, + getTimelineId: boolean = false +) { + const [timelineId, setTimelineId] = useState(null); + + useEffect(() => { + let startElem: Element | (Node & ParentNode) | null = elem.current; + if (startElem != null && getTimelineId) { + for (; startElem && startElem !== document; startElem = startElem.parentNode) { + const myElem: Element = startElem as Element; + if ( + myElem != null && + myElem.classList != null && + myElem.classList.contains(SELECTOR_TIMELINE_BODY_CLASS_NAME) && + myElem.hasAttribute('data-timeline-id') + ) { + setTimelineId(myElem.getAttribute('data-timeline-id')); + break; + } + } + } + }, [elem, getTimelineId]); + + return timelineId; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx index fcf007a4cf1ba..62a07550650aa 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx @@ -21,6 +21,7 @@ export interface DefaultDraggableType { name?: string | null; queryValue?: string | null; children?: React.ReactNode; + timelineId?: string; tooltipContent?: React.ReactNode; } @@ -83,7 +84,7 @@ Content.displayName = 'Content'; * @param queryValue - defaults to `value`, this query overrides the `queryMatch.value` used by the `DataProvider` that represents the data */ export const DefaultDraggable = React.memo( - ({ id, field, value, name, children, tooltipContent, queryValue }) => + ({ id, field, value, name, children, timelineId, tooltipContent, queryValue }) => value != null ? ( ( ) } + timelineId={timelineId} /> ) : null ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx index e01ccf1e544bb..7b6e9fb21a3e3 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx @@ -147,7 +147,6 @@ export const getColumns = ({ data-test-subj="field-name" fieldId={field} onUpdateColumns={onUpdateColumns} - timelineId={contextId} /> )} diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx index 3e196c4b7bad4..31f7e1b7fac7c 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx @@ -53,6 +53,7 @@ export interface OwnProps extends QueryTemplateProps { showLegend?: boolean; stackByOptions: MatrixHistogramOption[]; subtitle?: string | GetSubTitle; + timelineId?: string; title: string | GetTitle; type: hostsModel.HostsType | networkModel.NetworkType; } @@ -94,6 +95,7 @@ export const MatrixHistogramComponent: React.FC< stackByOptions, startDate, subtitle, + timelineId, title, titleSize, dispatchSetAbsoluteRangeDatePicker, @@ -242,6 +244,7 @@ export const MatrixHistogramComponent: React.FC< barChart={barChartData} configs={barchartConfigs} stackByField={selectedStackByOption.value} + timelineId={timelineId} /> )} diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts index a9e6cdd19bb20..f388409b443db 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts @@ -80,11 +80,12 @@ export interface MatrixHistogramQueryProps { } export interface MatrixHistogramProps extends MatrixHistogramBasicProps { + legendPosition?: Position; scaleType?: ScaleType; - yTickFormatter?: (value: number) => string; showLegend?: boolean; showSpacer?: boolean; - legendPosition?: Position; + timelineId?: string; + yTickFormatter?: (value: number) => string; } export interface HistogramBucket { diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx index 336f906b3bed0..503e9983692f1 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx @@ -15,17 +15,19 @@ import { SUB_PLUGINS_REDUCER, kibanaObservable, createSecuritySolutionStorageMock, + mockIndexPattern, } from '../../mock'; import { createKibanaCoreStartMock } from '../../mock/kibana_core'; import { FilterManager } from '../../../../../../../src/plugins/data/public'; import { createStore, State } from '../../store'; import { Props } from './top_n'; -import { ACTIVE_TIMELINE_REDUX_ID, StatefulTopN } from '.'; +import { StatefulTopN } from '.'; import { ManageGlobalTimeline, timelineDefaults, } from '../../../timelines/components/manage_timeline'; +import { TimelineId } from '../../../../common/types/timeline'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -94,9 +96,9 @@ const state: State = { timeline: { ...mockGlobalState.timeline, timelineById: { - [ACTIVE_TIMELINE_REDUX_ID]: { + [TimelineId.active]: { ...mockGlobalState.timeline.timelineById.test, - id: ACTIVE_TIMELINE_REDUX_ID, + id: TimelineId.active, dataProviders: [ { id: @@ -189,6 +191,9 @@ describe('StatefulTopN', () => { { beforeEach(() => { filterManager = new FilterManager(mockUiSettingsForFilterManager); const manageTimelineForTesting = { - [ACTIVE_TIMELINE_REDUX_ID]: { + [TimelineId.active]: { ...timelineDefaults, - id: ACTIVE_TIMELINE_REDUX_ID, + id: TimelineId.active, filterManager, }, }; @@ -278,6 +283,9 @@ describe('StatefulTopN', () => { { const filterManager = new FilterManager(mockUiSettingsForFilterManager); const manageTimelineForTesting = { - [ACTIVE_TIMELINE_REDUX_ID]: { + [TimelineId.active]: { ...timelineDefaults, - id: ACTIVE_TIMELINE_REDUX_ID, + id: TimelineId.active, filterManager, documentType: 'alerts', }, @@ -356,6 +364,9 @@ describe('StatefulTopN', () => { { // filters that appear at the top of most views in the app, and all the // filters in the active timeline: const mapStateToProps = (state: State) => { - const activeTimeline: TimelineModel = - getTimeline(state, ACTIVE_TIMELINE_REDUX_ID) ?? timelineDefaults; + const activeTimeline: TimelineModel = getTimeline(state, TimelineId.active) ?? timelineDefaults; const activeTimelineFilters = activeTimeline.filters ?? EMPTY_FILTERS; const activeTimelineInput: inputsModel.InputsRange = getInputsTimeline(state); @@ -48,7 +49,7 @@ const makeMapStateToProps = () => { activeTimelineEventType: activeTimeline.eventType, activeTimelineFilters, activeTimelineFrom: activeTimelineInput.timerange.from, - activeTimelineKqlQueryExpression: getKqlQueryTimeline(state, ACTIVE_TIMELINE_REDUX_ID), + activeTimelineKqlQueryExpression: getKqlQueryTimeline(state, TimelineId.active), activeTimelineTo: activeTimelineInput.timerange.to, dataProviders: activeTimeline.dataProviders, globalQuery: getGlobalQuerySelector(state), @@ -64,9 +65,17 @@ const mapDispatchToProps = { setAbsoluteRangeDatePicker: dispatchSetAbsoluteRang const connector = connect(makeMapStateToProps, mapDispatchToProps); +// * `indexToAdd`, which enables the alerts index to be appended to +// the `indexPattern` returned by `useWithSource`, may only be populated when +// this component is rendered in the context of the active timeline. This +// behavior enables the 'All events' view by appending the alerts index +// to the index pattern. interface OwnProps { browserFields: BrowserFields; field: string; + indexPattern: IIndexPattern; + indexToAdd: string[] | null; + timelineId?: string; toggleTopN: () => void; onFilterAdded?: () => void; value?: string[] | string | null; @@ -83,48 +92,29 @@ const StatefulTopNComponent: React.FC = ({ browserFields, dataProviders, field, + indexPattern, + indexToAdd, globalFilters = EMPTY_FILTERS, globalQuery = EMPTY_QUERY, kqlMode, onFilterAdded, setAbsoluteRangeDatePicker, + timelineId, toggleTopN, value, }) => { const kibana = useKibana(); - // Regarding data from useTimelineTypeContext: - // * `documentType` (e.g. 'alerts') may only be populated in some views, - // e.g. the `Alerts` view on the `Detections` page. - // * `id` (`timelineId`) may only be populated when we are rendered in the - // context of the active timeline. - // * `indexToAdd`, which enables the alerts index to be appended to - // the `indexPattern` returned by `useWithSource`, may only be populated when - // this component is rendered in the context of the active timeline. This - // behavior enables the 'All events' view by appending the alerts index - // to the index pattern. - const { isManagedTimeline, getManageTimelineById } = useManageTimeline(); - const { documentType, id: timelineId, indexToAdd } = useMemo( - () => - isManagedTimeline(ACTIVE_TIMELINE_REDUX_ID) - ? getManageTimelineById(ACTIVE_TIMELINE_REDUX_ID) - : { documentType: null, id: null, indexToAdd: null }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [getManageTimelineById] - ); - const options = getOptions( - timelineId === ACTIVE_TIMELINE_REDUX_ID ? activeTimelineEventType : undefined + timelineId === TimelineId.active ? activeTimelineEventType : undefined ); - const { indexPattern } = useWithSource('default', indexToAdd); - return ( {({ from, deleteQuery, setQuery, to }) => ( = ({ : undefined } data-test-subj="top-n" - defaultView={documentType?.toLocaleLowerCase() === 'alerts' ? 'alert' : options[0].value} - deleteQuery={timelineId === ACTIVE_TIMELINE_REDUX_ID ? undefined : deleteQuery} + defaultView={ + timelineId === TimelineId.alertsPage || timelineId === TimelineId.alertsRulesDetailsPage + ? 'alert' + : options[0].value + } + deleteQuery={timelineId === TimelineId.active ? undefined : deleteQuery} field={field} - filters={timelineId === ACTIVE_TIMELINE_REDUX_ID ? EMPTY_FILTERS : globalFilters} - from={timelineId === ACTIVE_TIMELINE_REDUX_ID ? activeTimelineFrom : from} + filters={timelineId === TimelineId.active ? EMPTY_FILTERS : globalFilters} + from={timelineId === TimelineId.active ? activeTimelineFrom : from} indexPattern={indexPattern} indexToAdd={indexToAdd} options={options} - query={timelineId === ACTIVE_TIMELINE_REDUX_ID ? EMPTY_QUERY : globalQuery} + query={timelineId === TimelineId.active ? EMPTY_QUERY : globalQuery} setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} setAbsoluteRangeDatePickerTarget={ - timelineId === ACTIVE_TIMELINE_REDUX_ID ? 'timeline' : 'global' + timelineId === TimelineId.active ? 'timeline' : 'global' } setQuery={setQuery} - to={timelineId === ACTIVE_TIMELINE_REDUX_ID ? activeTimelineTo : to} + timelineId={timelineId} + to={timelineId === TimelineId.active ? activeTimelineTo : to} toggleTopN={toggleTopN} onFilterAdded={onFilterAdded} value={value} diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx index 0ccb7e1e72f1f..7d19bf21271aa 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx @@ -5,7 +5,7 @@ */ import { EuiButtonIcon, EuiSuperSelect } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { ActionCreator } from 'typescript-fsa'; @@ -67,6 +67,7 @@ export interface Props { refetch: inputsModel.Refetch; }) => void; to: number; + timelineId?: string; toggleTopN: () => void; onFilterAdded?: () => void; value?: string[] | string | null; @@ -89,12 +90,17 @@ const TopNComponent: React.FC = ({ setAbsoluteRangeDatePicker, setAbsoluteRangeDatePickerTarget, setQuery, + timelineId, to, toggleTopN, }) => { const [view, setView] = useState(defaultView); const onViewSelected = useCallback((value: string) => setView(value as EventType), [setView]); + useEffect(() => { + setView(defaultView); + }, [defaultView]); + const headerChildren = useMemo( () => ( = ({ setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget} setQuery={setQuery} showSpacer={false} + timelineId={timelineId} to={to} /> ) : ( @@ -145,6 +152,7 @@ const TopNComponent: React.FC = ({ setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget} setQuery={setQuery} + timelineId={timelineId} to={to} /> )} diff --git a/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx b/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx index 8679dae448332..361779a4a33b2 100644 --- a/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx @@ -5,7 +5,7 @@ */ import { EuiPopover } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { IS_DRAGGING_CLASS_NAME } from '../drag_and_drop/helpers'; @@ -22,6 +22,7 @@ interface Props { * Always show the hover menu contents (default: false) */ alwaysShow?: boolean; + closePopOverTrigger?: boolean; /** * The contents of the hover menu. It is highly recommended you wrap this * content in a `div` with `position: absolute` to prevent it from effecting @@ -47,7 +48,8 @@ interface Props { * provides a signal to the content that the user is in a hover state. */ export const WithHoverActions = React.memo( - ({ alwaysShow = false, hoverContent, render }) => { + ({ alwaysShow = false, closePopOverTrigger, hoverContent, render }) => { + const [isOpen, setIsOpen] = useState(hoverContent != null && alwaysShow); const [showHoverContent, setShowHoverContent] = useState(false); const onMouseEnter = useCallback(() => { // NOTE: the following read from the DOM is expensive, but not as @@ -64,10 +66,16 @@ export const WithHoverActions = React.memo( const content = useMemo(() => <>{render(showHoverContent)}, [render, showHoverContent]); - const isOpen = hoverContent != null && (showHoverContent || alwaysShow); + useEffect(() => { + setIsOpen(hoverContent != null && (showHoverContent || alwaysShow)); + }, [hoverContent, showHoverContent, alwaysShow]); - const popover = useMemo(() => { - return ( + useEffect(() => { + setShowHoverContent(false); + }, [closePopOverTrigger]); + + return ( +
( isOpen={isOpen} panelPaddingSize={!alwaysShow ? 's' : 'none'} > - {isOpen ? hoverContent : null} + {isOpen ? <>{hoverContent} : null} - ); - }, [content, onMouseLeave, isOpen, alwaysShow, hoverContent]); - - return ( -
- {popover}
); } diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index 9aa3b007511a1..4f42f20c45ae1 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -97,7 +97,7 @@ export const useWithSource = ( const [configIndex] = useUiSetting$(DEFAULT_INDEX_KEY); const defaultIndex = useMemo(() => { if (indexToAdd != null && !isEmpty(indexToAdd)) { - return [...(!onlyCheckIndexToAdd ? configIndex : []), ...indexToAdd]; + return onlyCheckIndexToAdd ? indexToAdd : [...configIndex, ...indexToAdd]; } return configIndex; }, [configIndex, indexToAdd, onlyCheckIndexToAdd]); @@ -135,41 +135,32 @@ export const useWithSource = ( }, }, }); - if (!isSubscribed) { - return setState((prevState) => ({ - ...prevState, + + if (isSubscribed) { + setState({ loading: false, - })); + indicesExist: indicesExistOrDataTemporarilyUnavailable( + get('data.source.status.indicesExist', result) + ), + browserFields: getBrowserFields( + defaultIndex.join(), + get('data.source.status.indexFields', result) + ), + indexPattern: getIndexFields( + defaultIndex.join(), + get('data.source.status.indexFields', result) + ), + errorMessage: null, + }); } - - setState({ - loading: false, - indicesExist: indicesExistOrDataTemporarilyUnavailable( - get('data.source.status.indicesExist', result) - ), - browserFields: getBrowserFields( - defaultIndex.join(), - get('data.source.status.indexFields', result) - ), - indexPattern: getIndexFields( - defaultIndex.join(), - get('data.source.status.indexFields', result) - ), - errorMessage: null, - }); } catch (error) { - if (!isSubscribed) { - return setState((prevState) => ({ + if (isSubscribed) { + setState((prevState) => ({ ...prevState, loading: false, + errorMessage: error.message, })); } - - setState((prevState) => ({ - ...prevState, - loading: false, - errorMessage: error.message, - })); } } diff --git a/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts b/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts index 4e2b11b24e5a9..e84438581fcde 100644 --- a/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts @@ -26,33 +26,33 @@ describe('Kuery escape', () => { expect(escapeKuery(value)).to.be(expected); }); - it('should escape keywords', () => { + it('should NOT escape keywords', () => { const value = 'foo and bar or baz not qux'; - const expected = 'foo \\and bar \\or baz \\not qux'; + const expected = 'foo and bar or baz not qux'; expect(escapeKuery(value)).to.be(expected); }); - it('should escape keywords next to each other', () => { + it('should NOT escape keywords next to each other', () => { const value = 'foo and bar or not baz'; - const expected = 'foo \\and bar \\or \\not baz'; + const expected = 'foo and bar or not baz'; expect(escapeKuery(value)).to.be(expected); }); it('should not escape keywords without surrounding spaces', () => { const value = 'And this has keywords, or does it not?'; - const expected = 'And this has keywords, \\or does it not?'; + const expected = 'And this has keywords, or does it not?'; expect(escapeKuery(value)).to.be(expected); }); - it('should escape uppercase keywords', () => { + it('should NOT escape uppercase keywords', () => { const value = 'foo AND bar'; - const expected = 'foo \\AND bar'; + const expected = 'foo AND bar'; expect(escapeKuery(value)).to.be(expected); }); - it('should escape both keywords and special characters', () => { + it('should escape special characters and NOT keywords', () => { const value = 'Hello, "world", and to meet you!'; - const expected = 'Hello, \\"world\\", \\and to meet you!'; + const expected = 'Hello, \\"world\\", and to meet you!'; expect(escapeKuery(value)).to.be(expected); }); diff --git a/x-pack/plugins/security_solution/public/common/lib/keury/index.ts b/x-pack/plugins/security_solution/public/common/lib/keury/index.ts index bd4d96a98c815..b06a6ec10f48e 100644 --- a/x-pack/plugins/security_solution/public/common/lib/keury/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/keury/index.ts @@ -75,11 +75,12 @@ const escapeWhitespace = (val: string) => const escapeSpecialCharacters = (val: string) => val.replace(/["]/g, '\\$&'); // $& means the whole matched string // See the Keyword rule in kuery.peg -const escapeAndOr = (val: string) => val.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3'); +// I do not think that we need that anymore since we are doing a full match_phrase all the time now => return `"${escapeKuery(val)}"`; +// const escapeAndOr = (val: string) => val.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3'); -const escapeNot = (val: string) => val.replace(/not(\s+)/gi, '\\$&'); +// const escapeNot = (val: string) => val.replace(/not(\s+)/gi, '\\$&'); -export const escapeKuery = flow(escapeSpecialCharacters, escapeAndOr, escapeNot, escapeWhitespace); +export const escapeKuery = flow(escapeSpecialCharacters, escapeWhitespace); export const convertToBuildEsQuery = ({ config, diff --git a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx index fe3f9f8ecda33..7d42f744a2613 100644 --- a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx @@ -60,6 +60,7 @@ interface Props { refetch: inputsModel.Refetch; }) => void; showSpacer?: boolean; + timelineId?: string; to: number; } @@ -81,6 +82,7 @@ const EventsByDatasetComponent: React.FC = ({ setAbsoluteRangeDatePickerTarget, setQuery, showSpacer = true, + timelineId, to, }) => { // create a unique, but stable (across re-renders) query id @@ -177,6 +179,7 @@ const EventsByDatasetComponent: React.FC = ({ showSpacer={showSpacer} sourceId="default" startDate={from} + timelineId={timelineId} type={HostsType.page} {...eventsByDatasetHistogramConfigs} title={onlyField != null ? i18n.TOP(onlyField) : eventsByDatasetHistogramConfigs.title} diff --git a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx index 5010fd9c06eb7..41152dabe2ad8 100644 --- a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx @@ -37,6 +37,7 @@ interface Props { loading: boolean; refetch: inputsModel.Refetch; }) => void; + timelineId?: string; to: number; } @@ -50,6 +51,7 @@ const SignalsByCategoryComponent: React.FC = ({ setAbsoluteRangeDatePicker, setAbsoluteRangeDatePickerTarget = 'global', setQuery, + timelineId, to, }) => { const { signalIndexName } = useSignalIndex(); @@ -83,6 +85,7 @@ const SignalsByCategoryComponent: React.FC = ({ showLinkToAlerts={onlyField == null ? true : false} stackByOptions={onlyField == null ? alertsHistogramOptions : undefined} legendPosition={'right'} + timelineId={timelineId} to={to} title={i18n.ALERT_COUNT} updateDateRange={updateDateRangeCallback} diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx index aaad9cf145ab7..8922434746234 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx @@ -152,7 +152,6 @@ export const getFieldItems = ({ fieldId={field.name || ''} highlight={highlight} onUpdateColumns={onUpdateColumns} - timelineId={timelineId} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx index da0cbb99b8671..1f917c664e813 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx @@ -24,7 +24,6 @@ const defaultProps = { }), fieldId: timestampFieldId, onUpdateColumns: jest.fn(), - timelineId: 'timeline-id', }; describe('FieldName', () => { @@ -46,8 +45,7 @@ describe('FieldName', () => { ); - - wrapper.simulate('mouseenter'); + wrapper.find('div').at(1).simulate('mouseenter'); wrapper.update(); expect(wrapper.find('[data-test-subj="copy-to-clipboard"]').exists()).toBe(true); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx index 985c8b35094ef..62e41d967cb9a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx @@ -5,13 +5,16 @@ */ import { EuiHighlight, EuiText } from '@elastic/eui'; -import React, { useCallback, useState, useMemo } from 'react'; +import React, { useCallback, useState, useMemo, useRef } from 'react'; import styled from 'styled-components'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { OnUpdateColumns } from '../timeline/events'; import { WithHoverActions } from '../../../common/components/with_hover_actions'; -import { DraggableWrapperHoverContent } from '../../../common/components/drag_and_drop/draggable_wrapper_hover_content'; +import { + DraggableWrapperHoverContent, + useGetTimelineId, +} from '../../../common/components/drag_and_drop/draggable_wrapper_hover_content'; /** * The name of a (draggable) field @@ -77,23 +80,34 @@ export const FieldName = React.memo<{ fieldId: string; highlight?: string; onUpdateColumns: OnUpdateColumns; - timelineId: string; -}>(({ fieldId, highlight = '', timelineId }) => { +}>(({ fieldId, highlight = '' }) => { + const containerRef = useRef(null); + const [closePopOverTrigger, setClosePopOverTrigger] = useState(false); const [showTopN, setShowTopN] = useState(false); + const [goGetTimelineId, setGoGetTimelineId] = useState(false); + const timelineIdFind = useGetTimelineId(containerRef, goGetTimelineId); + const toggleTopN = useCallback(() => { - setShowTopN(!showTopN); - }, [setShowTopN, showTopN]); + setShowTopN((prevShowTopN) => !prevShowTopN); + }, []); + + const handleClosePopOverTrigger = useCallback( + () => setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger), + [] + ); const hoverContent = useMemo( () => ( ), - [fieldId, showTopN, toggleTopN, timelineId] + [fieldId, handleClosePopOverTrigger, showTopN, timelineIdFind, toggleTopN] ); const render = useCallback( @@ -109,7 +123,16 @@ export const FieldName = React.memo<{ [fieldId, highlight] ); - return ; + return ( +
+ +
+ ); }); FieldName.displayName = 'FieldName'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx index be655f7465a1b..3b40c36fccd16 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx @@ -57,6 +57,11 @@ type ActionManageTimeline = id: string; payload: boolean; } + | { + type: 'SET_INDEX_TO_ADD'; + id: string; + payload: string[]; + } | { type: 'SET_TIMELINE_ACTIONS'; id: string; @@ -81,7 +86,10 @@ export const timelineDefaults = { title: i18n.EVENTS, unit: (n: number) => i18n.UNIT(n), }; -const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTimeline) => { +const reducerManageTimeline = ( + state: ManageTimelineById, + action: ActionManageTimeline +): ManageTimelineById => { switch (action.type) { case 'INITIALIZE_TIMELINE': return { @@ -91,7 +99,15 @@ const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTi ...state[action.id], ...action.payload, }, - }; + } as ManageTimelineById; + case 'SET_INDEX_TO_ADD': + return { + ...state, + [action.id]: { + ...state[action.id], + indexToAdd: action.payload, + }, + } as ManageTimelineById; case 'SET_TIMELINE_ACTIONS': case 'SET_TIMELINE_FILTER_MANAGER': return { @@ -100,7 +116,7 @@ const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTi ...state[action.id], ...action.payload, }, - }; + } as ManageTimelineById; case 'SET_IS_LOADING': return { ...state, @@ -108,7 +124,7 @@ const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTi ...state[action.id], isLoading: action.payload, }, - }; + } as ManageTimelineById; default: return state; } @@ -119,6 +135,7 @@ interface UseTimelineManager { getTimelineFilterManager: (id: string) => FilterManager | undefined; initializeTimeline: (newTimeline: ManageTimelineInit) => void; isManagedTimeline: (id: string) => boolean; + setIndexToAdd: (indexToAddArgs: { id: string; indexToAdd: string[] }) => void; setIsTimelineLoading: (isLoadingArgs: { id: string; isLoading: boolean }) => void; setTimelineRowActions: (actionsArgs: { id: string; @@ -129,10 +146,9 @@ interface UseTimelineManager { } const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseTimelineManager => { - const [state, dispatch] = useReducer( - reducerManageTimeline, - manageTimelineForTesting ?? initManageTimeline - ); + const [state, dispatch] = useReducer< + (state: ManageTimelineById, action: ActionManageTimeline) => ManageTimelineById + >(reducerManageTimeline, manageTimelineForTesting ?? initManageTimeline); const initializeTimeline = useCallback((newTimeline: ManageTimelineInit) => { dispatch({ @@ -183,8 +199,16 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT [] ); + const setIndexToAdd = useCallback(({ id, indexToAdd }: { id: string; indexToAdd: string[] }) => { + dispatch({ + type: 'SET_INDEX_TO_ADD', + id, + payload: indexToAdd, + }); + }, []); + const getTimelineFilterManager = useCallback( - (id: string): FilterManager | undefined => state[id].filterManager, + (id: string): FilterManager | undefined => state[id]?.filterManager, [state] ); const getManageTimelineById = useCallback( @@ -195,8 +219,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT initializeTimeline({ id }); return { ...timelineDefaults, id }; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [state] + [initializeTimeline, state] ); const isManagedTimeline = useCallback((id: string): boolean => state[id] != null, [state]); @@ -205,6 +228,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT getTimelineFilterManager, initializeTimeline, isManagedTimeline, + setIndexToAdd, setIsTimelineLoading, setTimelineRowActions, setTimelineFilterManager, @@ -214,6 +238,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT const init = { getManageTimelineById: (id: string) => ({ ...timelineDefaults, id }), getTimelineFilterManager: () => undefined, + setIndexToAdd: () => undefined, isManagedTimeline: () => false, initializeTimeline: () => noop, setIsTimelineLoading: () => noop, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 51bf883ed2d61..43ea5e905ca8b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -17,6 +17,7 @@ import { columnRenderers, rowRenderers } from './renderers'; import { Sort } from './sort'; import { wait } from '../../../../common/lib/helpers'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; +import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../styles'; const testBodyHeight = 700; const mockGetNotesByIds = (eventId: string[]) => []; @@ -133,6 +134,20 @@ describe('Body', () => { ).toEqual(true); }); }, 20000); + + test(`it add attribute data-timeline-id in ${SELECTOR_TIMELINE_BODY_CLASS_NAME}`, () => { + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`[data-timeline-id="timeline-test"].${SELECTOR_TIMELINE_BODY_CLASS_NAME}`) + .first() + .exists() + ).toEqual(true); + }); }); describe('action on event', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 46895c86de084..6a296170fffde 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -139,6 +139,7 @@ export const Body = React.memo( )} ( pinnedEventIds={pinnedEventIds} rowRenderers={showRowRenderers ? rowRenderers : [plainRowRenderer]} selectedEventIds={selectedEventIds} - show={id === ACTIVE_TIMELINE_REDUX_ID ? show : true} + show={id === TimelineId.active ? show : true} showCheckboxes={showCheckboxes} sort={sort} toggleColumn={toggleColumn} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index d1a58dafcb328..47d848021ba43 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -14,16 +14,17 @@ import { IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME } from '../../../common/component /** * TIMELINE BODY */ +export const SELECTOR_TIMELINE_BODY_CLASS_NAME = 'securitySolutionTimeline__body'; // SIDE EFFECT: the following creates a global class selector export const TimelineBodyGlobalStyle = createGlobalStyle` - body.${IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME} .siemTimeline__body { + body.${IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME} .${SELECTOR_TIMELINE_BODY_CLASS_NAME} { overflow: hidden; } `; export const TimelineBody = styled.div.attrs(({ className = '' }) => ({ - className: `siemTimeline__body ${className}`, + className: `${SELECTOR_TIMELINE_BODY_CLASS_NAME} ${className}`, }))<{ bodyHeight?: number; visible: boolean }>` height: ${({ bodyHeight }) => (bodyHeight ? `${bodyHeight}px` : 'auto')}; overflow: auto; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index 07d4b004d2eda..18deaf0158723 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -174,6 +174,7 @@ export const TimelineComponent: React.FC = ({ const [isQueryLoading, setIsQueryLoading] = useState(false); const { initializeTimeline, + setIndexToAdd, setIsTimelineLoading, setTimelineFilterManager, setTimelineRowActions, @@ -188,12 +189,14 @@ export const TimelineComponent: React.FC = ({ }, []); useEffect(() => { setIsTimelineLoading({ id, isLoading: isQueryLoading || loadingIndexName }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadingIndexName, isQueryLoading]); + }, [loadingIndexName, id, isQueryLoading, setIsTimelineLoading]); useEffect(() => { setTimelineFilterManager({ id, filterManager }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filterManager]); + }, [filterManager, id, setTimelineFilterManager]); + + useEffect(() => { + setIndexToAdd({ id, indexToAdd }); + }, [id, indexToAdd, setIndexToAdd]); return (