diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts index 7c9a0edebe53e9..236d5a53481b7f 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts @@ -61,7 +61,7 @@ describe('timeline data providers', () => { cy.get(TIMELINE_DATA_PROVIDERS).should( 'have.css', 'background', - 'rgba(125, 226, 209, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' + 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' ); }); @@ -81,7 +81,7 @@ describe('timeline data providers', () => { cy.get(TIMELINE_DATA_PROVIDERS_EMPTY).should( 'have.css', 'background', - 'rgba(125, 226, 209, 0.2) none repeat scroll 0% 0% / auto padding-box border-box' + 'rgba(1, 125, 115, 0.2) none repeat scroll 0% 0% / auto padding-box border-box' ); }); @@ -101,7 +101,7 @@ describe('timeline data providers', () => { cy.get(TIMELINE_DATA_PROVIDERS).should( 'have.css', 'border', - '3.1875px dashed rgb(125, 226, 209)' + '3.1875px dashed rgb(1, 125, 115)' ); }); }); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts index 811c529b8bec59..c1c35e497d0815 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts @@ -41,7 +41,7 @@ describe('timeline flyout button', () => { cy.get(TIMELINE_NOT_READY_TO_DROP_BUTTON).should( 'have.css', 'background', - 'rgba(125, 226, 209, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' + 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' ); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx b/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx index 997a19b0e8a2ec..aaf7be2f7f5a6d 100644 --- a/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx +++ b/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx @@ -18,13 +18,8 @@ interface SuggestionItemProps { suggestion: AutocompleteSuggestion; } -export class SuggestionItem extends React.PureComponent { - public static defaultProps: Partial = { - isSelected: false, - }; - - public render() { - const { isSelected, onClick, onMouseEnter, suggestion } = this.props; +export const SuggestionItem = React.memo( + ({ isSelected = false, onClick, onMouseEnter, suggestion }) => { return ( { ); } -} +); + +SuggestionItem.displayName = 'SuggestionItem'; const SuggestionItemContainer = euiStyled.div<{ isSelected?: boolean; diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx index aab83ec7908fe0..11b604571378b1 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx @@ -6,7 +6,7 @@ import { defaultTo, noop } from 'lodash/fp'; import * as React from 'react'; -import { DragDropContext, DropResult, ResponderProvided, DragStart } from 'react-beautiful-dnd'; +import { DragDropContext, DropResult, DragStart } from 'react-beautiful-dnd'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; @@ -57,43 +57,39 @@ const onDragEndHandler = ({ /** * DragDropContextWrapperComponent handles all drag end events */ -export class DragDropContextWrapperComponent extends React.Component { - public shouldComponentUpdate = ({ children, dataProviders }: Props) => - children === this.props.children && dataProviders !== this.props.dataProviders // prevent re-renders when data providers are added or removed, but all other props are the same - ? false - : true; - - public render() { - const { children } = this.props; - +export const DragDropContextWrapperComponent = React.memo( + ({ browserFields, children, dataProviders, dispatch }) => { + function onDragEnd(result: DropResult) { + enableScrolling(); + + if (dataProviders != null) { + onDragEndHandler({ + browserFields, + result, + dataProviders, + dispatch, + }); + } + + if (!draggableIsField(result)) { + document.body.classList.remove(IS_DRAGGING_CLASS_NAME); + } + } return ( - + {children} ); + }, + (prevProps, nextProps) => { + return ( + prevProps.children === nextProps.children && + prevProps.dataProviders === nextProps.dataProviders + ); // prevent re-renders when data providers are added or removed, but all other props are the same } +); - private onDragEnd: (result: DropResult, provided: ResponderProvided) => void = ( - result: DropResult - ) => { - const { browserFields, dataProviders, dispatch } = this.props; - - enableScrolling(); - - if (dataProviders != null) { - onDragEndHandler({ - browserFields, - result, - dataProviders, - dispatch, - }); - } - - if (!draggableIsField(result)) { - document.body.classList.remove(IS_DRAGGING_CLASS_NAME); - } - }; -} +DragDropContextWrapperComponent.displayName = 'DragDropContextWrapperComponent'; const emptyDataProviders: dragAndDropModel.IdToDataProvider = {}; // stable reference diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx index 0755ef0e5592cf..8a12a5035fc3a8 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx @@ -5,7 +5,7 @@ */ import { isEqual } from 'lodash/fp'; -import * as React from 'react'; +import React, { useEffect } from 'react'; import { Draggable, DraggableProvided, @@ -161,28 +161,15 @@ type Props = OwnProps & DispatchProps; * Wraps a draggable component to handle registration / unregistration of the * data provider associated with the item being dropped */ -class DraggableWrapperComponent extends React.Component { - public shouldComponentUpdate = ({ dataProvider, render, truncate }: Props) => - isEqual(dataProvider, this.props.dataProvider) && - render !== this.props.render && - truncate === this.props.truncate - ? false - : true; - - public componentDidMount() { - const { dataProvider, registerProvider } = this.props; - - registerProvider!({ provider: dataProvider }); - } - - public componentWillUnmount() { - const { dataProvider, unRegisterProvider } = this.props; - - unRegisterProvider!({ id: dataProvider.id }); - } - public render() { - const { dataProvider, render, truncate } = this.props; +const DraggableWrapperComponent = React.memo( + ({ dataProvider, registerProvider, render, truncate, unRegisterProvider }) => { + useEffect(() => { + registerProvider!({ provider: dataProvider }); + return () => { + unRegisterProvider!({ id: dataProvider.id }); + }; + }, []); return ( @@ -223,8 +210,17 @@ class DraggableWrapperComponent extends React.Component { ); + }, + (prevProps, nextProps) => { + return ( + isEqual(prevProps.dataProvider, nextProps.dataProvider) && + prevProps.render !== nextProps.render && + prevProps.truncate === nextProps.truncate + ); } -} +); + +DraggableWrapperComponent.displayName = 'DraggableWrapperComponent'; export const DraggableWrapper = connect( null, diff --git a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx index 10b4340b6a88d2..dc7f2185c26b72 100644 --- a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx @@ -9,15 +9,15 @@ import { EuiButton, EuiComboBox, EuiComboBoxOptionProps, + EuiFieldText, EuiFlexGroup, EuiFlexItem, - EuiFieldText, EuiFormRow, EuiPanel, EuiSpacer, EuiToolTip, } from '@elastic/eui'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import styled, { injectGlobal } from 'styled-components'; import { BrowserFields } from '../../containers/source'; @@ -37,8 +37,8 @@ import * as i18n from './translations'; const EDIT_DATA_PROVIDER_WIDTH = 400; const FIELD_COMBO_BOX_WIDTH = 195; const OPERATOR_COMBO_BOX_WIDTH = 160; -const VALUE_INPUT_CLASS_NAME = 'edit-data-provider-value'; const SAVE_CLASS_NAME = 'edit-data-provider-save'; +const VALUE_INPUT_CLASS_NAME = 'edit-data-provider-value'; export const HeaderContainer = styled.div` width: ${EDIT_DATA_PROVIDER_WIDTH}; @@ -68,12 +68,6 @@ interface Props { value: string | number; } -interface State { - updatedField: EuiComboBoxOptionProps[]; - updatedOperator: EuiComboBoxOptionProps[]; - updatedValue: string | number; -} - const sanatizeValue = (value: string | number): string => Array.isArray(value) ? `${value[0]}` : `${value}`; // fun fact: value should never be an array @@ -88,37 +82,80 @@ export const getInitialOperatorLabel = ( } }; -export class StatefulEditDataProvider extends React.PureComponent { - constructor(props: Props) { - super(props); +export const StatefulEditDataProvider = React.memo( + ({ + andProviderId, + browserFields, + field, + isExcluded, + onDataProviderEdited, + operator, + providerId, + timelineId, + value, + }) => { + const [updatedField, setUpdatedField] = useState([{ label: field }]); + const [updatedOperator, setUpdatedOperator] = useState( + getInitialOperatorLabel(isExcluded, operator) + ); + const [updatedValue, setUpdatedValue] = useState(value); - const { field, isExcluded, operator, value } = props; + /** Focuses the Value input if it is visible, falling back to the Save button if it's not */ + function focusInput() { + const elements = document.getElementsByClassName(VALUE_INPUT_CLASS_NAME); - this.state = { - updatedField: [{ label: field }], - updatedOperator: getInitialOperatorLabel(isExcluded, operator), - updatedValue: value, - }; - } + if (elements.length > 0) { + (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` + } else { + const saveElements = document.getElementsByClassName(SAVE_CLASS_NAME); - public componentDidMount() { - this.disableScrolling(); - this.focusInput(); - } + if (saveElements.length > 0) { + (saveElements[0] as HTMLElement).focus(); + } + } + } - public componentWillUnmount() { - this.enableScrolling(); - } + function onFieldSelected(selectedField: EuiComboBoxOptionProps[]) { + setUpdatedField(selectedField); + + focusInput(); + } + + function onOperatorSelected(operatorSelected: EuiComboBoxOptionProps[]) { + setUpdatedOperator(operatorSelected); + + focusInput(); + } + + function onValueChange(e: React.ChangeEvent) { + setUpdatedValue(e.target.value); + } + + function disableScrolling() { + const x = + window.pageXOffset !== undefined + ? window.pageXOffset + : (document.documentElement || document.body.parentNode || document.body).scrollLeft; - public render() { - const { - andProviderId, - browserFields, - onDataProviderEdited, - providerId, - timelineId, - } = this.props; - const { updatedField, updatedOperator, updatedValue } = this.state; + const y = + window.pageYOffset !== undefined + ? window.pageYOffset + : (document.documentElement || document.body.parentNode || document.body).scrollTop; + + window.onscroll = () => window.scrollTo(x, y); + } + + function enableScrolling() { + window.onscroll = () => noop; + } + + useEffect(() => { + disableScrolling(); + focusInput(); + return () => { + enableScrolling(); + }; + }, []); return ( @@ -127,18 +164,14 @@ export class StatefulEditDataProvider extends React.PureComponent - 0 ? this.state.updatedField[0].label : null - } - > + 0 ? updatedField[0].label : null}> @@ -151,10 +184,10 @@ export class StatefulEditDataProvider extends React.PureComponent @@ -167,17 +200,17 @@ export class StatefulEditDataProvider extends React.PureComponent - {this.state.updatedOperator.length > 0 && - this.state.updatedOperator[0].label !== i18n.EXISTS && - this.state.updatedOperator[0].label !== i18n.DOES_NOT_EXIST ? ( + {updatedOperator.length > 0 && + updatedOperator[0].label !== i18n.EXISTS && + updatedOperator[0].label !== i18n.DOES_NOT_EXIST ? ( @@ -196,6 +229,13 @@ export class StatefulEditDataProvider extends React.PureComponent color="primary" data-test-subj="save" fill={true} + isDisabled={ + !selectionsAreValid({ + browserFields, + selectedField: updatedField, + selectedOperator: updatedOperator, + }) + } onClick={() => { onDataProviderEdited({ andProviderId, @@ -207,13 +247,6 @@ export class StatefulEditDataProvider extends React.PureComponent value: updatedValue, }); }} - isDisabled={ - !selectionsAreValid({ - browserFields: this.props.browserFields, - selectedField: updatedField, - selectedOperator: updatedOperator, - }) - } size="s" > {i18n.SAVE} @@ -225,53 +258,6 @@ export class StatefulEditDataProvider extends React.PureComponent ); } +); - /** Focuses the Value input if it is visible, falling back to the Save button if it's not */ - private focusInput = () => { - const elements = document.getElementsByClassName(VALUE_INPUT_CLASS_NAME); - - if (elements.length > 0) { - (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` - } else { - const saveElements = document.getElementsByClassName(SAVE_CLASS_NAME); - - if (saveElements.length > 0) { - (saveElements[0] as HTMLElement).focus(); - } - } - }; - - private onFieldSelected = (selectedField: EuiComboBoxOptionProps[]) => { - this.setState({ updatedField: selectedField }); - - this.focusInput(); - }; - - private onOperatorSelected = (updatedOperator: EuiComboBoxOptionProps[]) => { - this.setState({ updatedOperator }); - - this.focusInput(); - }; - - private onValueChange = (e: React.ChangeEvent) => { - this.setState({ - updatedValue: e.target.value, - }); - }; - - private disableScrolling = () => { - const x = - window.pageXOffset !== undefined - ? window.pageXOffset - : (document.documentElement || document.body.parentNode || document.body).scrollLeft; - - const y = - window.pageYOffset !== undefined - ? window.pageYOffset - : (document.documentElement || document.body.parentNode || document.body).scrollTop; - - window.onscroll = () => window.scrollTo(x, y); - }; - - private enableScrolling = () => (window.onscroll = () => noop); -} +StatefulEditDataProvider.displayName = 'StatefulEditDataProvider'; diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx index 86696503dbda38..18040a35a52807 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx @@ -43,7 +43,7 @@ export interface EmbeddedMapProps { } export const EmbeddedMap = React.memo( - ({ applyFilterQueryFromKueryExpression, queryExpression, startDate, endDate, setQuery }) => { + ({ applyFilterQueryFromKueryExpression, endDate, queryExpression, setQuery, startDate }) => { const [embeddable, setEmbeddable] = React.useState(null); const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx index ec76d8f90c3de9..cb677368298782 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; +import React, { useState } from 'react'; import { BrowserFields } from '../../containers/source'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; @@ -23,43 +23,24 @@ interface Props { toggleColumn: (column: ColumnHeader) => void; } -interface State { - view: View; -} - -export class StatefulEventDetails extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { view: 'table-view' }; - } +export const StatefulEventDetails = React.memo( + ({ browserFields, columnHeaders, data, id, onUpdateColumns, timelineId, toggleColumn }) => { + const [view, setView] = useState('table-view'); - public onViewSelected = (view: View): void => { - this.setState({ view }); - }; - - public render() { - const { - browserFields, - columnHeaders, - data, - id, - onUpdateColumns, - timelineId, - toggleColumn, - } = this.props; return ( setView(newView)} timelineId={timelineId} toggleColumn={toggleColumn} + view={view} /> ); } -} +); + +StatefulEventDetails.displayName = 'StatefulEventDetails'; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx index 03fb37760bc356..d85231b564da8a 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx @@ -20,8 +20,17 @@ jest.mock('../../lib/settings/use_kibana_ui_setting'); const from = 1566943856794; const to = 1566857456791; - +// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769 +/* eslint-disable no-console */ +const originalError = console.error; describe('EventsViewer', () => { + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); test('it renders the "Showing..." subtitle with the expected event count', async () => { const wrapper = mount( diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx index bef5e66faecd16..dc0e1288f40f83 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx @@ -20,7 +20,17 @@ jest.mock('../../lib/settings/use_kibana_ui_setting'); const from = 1566943856794; const to = 1566857456791; +// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769 +/* eslint-disable no-console */ +const originalError = console.error; describe('StatefulEventsViewer', () => { + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); test('it renders the events viewer', async () => { const wrapper = mount( diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx index 52b724525f5a94..d572d6dd4913b7 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx @@ -87,7 +87,7 @@ const StatefulEventsViewerComponent = React.memo( updateItemsPerPage, upsertColumn, }) => { - const [showInspect, setShowInspect] = useState(false); + const [showInspect, setShowInspect] = useState(false); useEffect(() => { if (createTimeline != null) { diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx index 17785ff582a3c2..fb47672512de56 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx @@ -5,8 +5,8 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiOutsideClickDetector } from '@elastic/eui'; +import React, { useEffect } from 'react'; import { noop } from 'lodash/fp'; -import * as React from 'react'; import styled, { css } from 'styled-components'; import { BrowserFields } from '../../containers/source'; @@ -22,7 +22,7 @@ import { getFieldBrowserSearchInputClassName, PANES_FLEX_GROUP_WIDTH, } from './helpers'; -import { FieldBrowserProps, OnFieldSelected, OnHideFieldBrowser } from './types'; +import { FieldBrowserProps, OnHideFieldBrowser } from './types'; const FieldsBrowserContainer = styled.div<{ width: number }>` ${({ theme, width }) => css` @@ -102,34 +102,80 @@ type Props = Pick< * This component has no internal state, but it uses lifecycle methods to * set focus to the search input, scroll to the selected category, etc */ -export class FieldsBrowser extends React.PureComponent { - public componentDidMount() { - this.scrollViews(); - this.focusInput(); - } +export const FieldsBrowser = React.memo( + ({ + browserFields, + columnHeaders, + filteredBrowserFields, + isEventViewer, + isSearching, + onCategorySelected, + onFieldSelected, + onHideFieldBrowser, + onSearchInputChange, + onOutsideClick, + onUpdateColumns, + searchInput, + selectedCategoryId, + timelineId, + toggleColumn, + width, + }) => { + /** Focuses the input that filters the field browser */ + function focusInput() { + const elements = document.getElementsByClassName( + getFieldBrowserSearchInputClassName(timelineId) + ); - public componentDidUpdate() { - this.scrollViews(); - this.focusInput(); // always re-focus the input to enable additional filtering - } + if (elements.length > 0) { + (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` + } + } + + /** Invoked when the user types in the input to filter the field browser */ + function onInputChange(event: React.ChangeEvent) { + onSearchInputChange(event.target.value); + } + + function selectFieldAndHide(fieldId: string) { + if (onFieldSelected != null) { + onFieldSelected(fieldId); + } - public render() { - const { - columnHeaders, - browserFields, - filteredBrowserFields, - searchInput, - isEventViewer, - isSearching, - onCategorySelected, - onFieldSelected, - onOutsideClick, - onUpdateColumns, - selectedCategoryId, - timelineId, - toggleColumn, - width, - } = this.props; + onHideFieldBrowser(); + } + + function scrollViews() { + if (selectedCategoryId !== '') { + const categoryPaneTitles = document.getElementsByClassName( + getCategoryPaneCategoryClassName({ + categoryId: selectedCategoryId, + timelineId, + }) + ); + + if (categoryPaneTitles.length > 0) { + categoryPaneTitles[0].scrollIntoView(); + } + + const fieldPaneTitles = document.getElementsByClassName( + getFieldBrowserCategoryTitleClassName({ + categoryId: selectedCategoryId, + timelineId, + }) + ); + + if (fieldPaneTitles.length > 0) { + fieldPaneTitles[0].scrollIntoView(); + } + } + + focusInput(); // always re-focus the input to enable additional filtering + } + + useEffect(() => { + scrollViews(); + }, [selectedCategoryId, timelineId]); return ( { isEventViewer={isEventViewer} isSearching={isSearching} onOutsideClick={onOutsideClick} - onSearchInputChange={this.onInputChange} + onSearchInputChange={onInputChange} onUpdateColumns={onUpdateColumns} searchInput={searchInput} timelineId={timelineId} @@ -170,7 +216,7 @@ export class FieldsBrowser extends React.PureComponent { data-test-subj="fields-pane" filteredBrowserFields={filteredBrowserFields} onCategorySelected={onCategorySelected} - onFieldSelected={this.selectFieldAndHide} + onFieldSelected={selectFieldAndHide} onUpdateColumns={onUpdateColumns} searchInput={searchInput} selectedCategoryId={selectedCategoryId} @@ -184,59 +230,4 @@ export class FieldsBrowser extends React.PureComponent { ); } - - /** Focuses the input that filters the field browser */ - private focusInput = () => { - const elements = document.getElementsByClassName( - getFieldBrowserSearchInputClassName(this.props.timelineId) - ); - - if (elements.length > 0) { - (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` - } - }; - - /** Invoked when the user types in the input to filter the field browser */ - private onInputChange = (event: React.ChangeEvent) => - this.props.onSearchInputChange(event.target.value); - - private selectFieldAndHide: OnFieldSelected = (fieldId: string) => { - const { onFieldSelected, onHideFieldBrowser } = this.props; - - if (onFieldSelected != null) { - onFieldSelected(fieldId); - } - - onHideFieldBrowser(); - }; - - private scrollViews = () => { - const { selectedCategoryId, timelineId } = this.props; - - if (this.props.selectedCategoryId !== '') { - const categoryPaneTitles = document.getElementsByClassName( - getCategoryPaneCategoryClassName({ - categoryId: selectedCategoryId, - timelineId, - }) - ); - - if (categoryPaneTitles.length > 0) { - categoryPaneTitles[0].scrollIntoView(); - } - - const fieldPaneTitles = document.getElementsByClassName( - getFieldBrowserCategoryTitleClassName({ - categoryId: selectedCategoryId, - timelineId, - }) - ); - - if (fieldPaneTitles.length > 0) { - fieldPaneTitles[0].scrollIntoView(); - } - } - - this.focusInput(); // always re-focus the input to enable additional filtering - }; -} +); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx index 69720c76cab803..7d21e1f44d04b4 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx @@ -6,7 +6,7 @@ import { EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import * as React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { connect } from 'react-redux'; import styled from 'styled-components'; import { ActionCreator } from 'typescript-fsa'; @@ -15,7 +15,6 @@ import { BrowserFields } from '../../containers/source'; import { timelineActions } from '../../store/actions'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { DEFAULT_CATEGORY_NAME } from '../timeline/body/column_headers/default_headers'; -import { OnUpdateColumns } from '../timeline/events'; import { FieldsBrowser } from './field_browser'; import { filterBrowserFieldsByFieldName, mergeBrowserFieldsWithDefaultCategory } from './helpers'; import * as i18n from './translations'; @@ -26,19 +25,6 @@ const fieldsButtonClassName = 'fields-button'; /** wait this many ms after the user completes typing before applying the filter input */ const INPUT_TIMEOUT = 250; -interface State { - /** all field names shown in the field browser must contain this string (when specified) */ - filterInput: string; - /** all fields in this collection have field names that match the filterInput */ - filteredBrowserFields: BrowserFields | null; - /** when true, show a spinner in the input to indicate the field browser is searching for matching field names */ - isSearching: boolean; - /** this category will be displayed in the right-hand pane of the field browser */ - selectedCategoryId: string; - /** show the field browser */ - show: boolean; -} - const FieldsBrowserButtonContainer = styled.div` position: relative; `; @@ -60,52 +46,110 @@ interface DispatchProps { /** * Manages the state of the field browser */ -export class StatefulFieldsBrowserComponent extends React.PureComponent< - FieldBrowserProps & DispatchProps, - State -> { - /** tracks the latest timeout id from `setTimeout`*/ - private inputTimeoutId: number = 0; - - constructor(props: FieldBrowserProps) { - super(props); - - this.state = { - filterInput: '', - filteredBrowserFields: null, - isSearching: false, - selectedCategoryId: DEFAULT_CATEGORY_NAME, - show: false, - }; - } +export const StatefulFieldsBrowserComponent = React.memo( + ({ + columnHeaders, + browserFields, + height, + isEventViewer = false, + onFieldSelected, + onUpdateColumns, + timelineId, + toggleColumn, + width, + }) => { + /** tracks the latest timeout id from `setTimeout`*/ + const inputTimeoutId = useRef(0); + + /** all field names shown in the field browser must contain this string (when specified) */ + const [filterInput, setFilterInput] = useState(''); + /** all fields in this collection have field names that match the filterInput */ + const [filteredBrowserFields, setFilteredBrowserFields] = useState(null); + /** when true, show a spinner in the input to indicate the field browser is searching for matching field names */ + const [isSearching, setIsSearching] = useState(false); + /** this category will be displayed in the right-hand pane of the field browser */ + const [selectedCategoryId, setSelectedCategoryId] = useState(DEFAULT_CATEGORY_NAME); + /** show the field browser */ + const [show, setShow] = useState(false); + useEffect(() => { + return () => { + if (inputTimeoutId.current !== 0) { + // ⚠️ mutation: cancel any remaining timers and zero-out the timer id: + clearTimeout(inputTimeoutId.current); + inputTimeoutId.current = 0; + } + }; + }, []); + + /** Shows / hides the field browser */ + function toggleShow() { + setShow(!show); + } + + /** Invoked when the user types in the filter input */ + function updateFilter(newFilterInput: string) { + setFilterInput(newFilterInput); + setIsSearching(true); + + if (inputTimeoutId.current !== 0) { + clearTimeout(inputTimeoutId.current); // ⚠️ mutation: cancel any previous timers + } + + // ⚠️ mutation: schedule a new timer that will apply the filter when it fires: + inputTimeoutId.current = window.setTimeout(() => { + const newFilteredBrowserFields = filterBrowserFieldsByFieldName({ + browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields), + substring: filterInput, + }); + + setFilteredBrowserFields(newFilteredBrowserFields); + setIsSearching(false); + + const newSelectedCategoryId = + filterInput === '' || Object.keys(newFilteredBrowserFields).length === 0 + ? DEFAULT_CATEGORY_NAME + : Object.keys(newFilteredBrowserFields) + .sort() + .reduce( + (selected, category) => + newFilteredBrowserFields[category].fields != null && + newFilteredBrowserFields[selected].fields != null && + newFilteredBrowserFields[category].fields!.length > + newFilteredBrowserFields[selected].fields!.length + ? category + : selected, + Object.keys(newFilteredBrowserFields)[0] + ); + setSelectedCategoryId(newSelectedCategoryId); + }, INPUT_TIMEOUT); + } - public componentWillUnmount() { - if (this.inputTimeoutId !== 0) { - // ⚠️ mutation: cancel any remaining timers and zero-out the timer id: - clearTimeout(this.inputTimeoutId); - this.inputTimeoutId = 0; + /** + * Invoked when the user clicks a category name in the left-hand side of + * the field browser + */ + function updateSelectedCategoryId(categoryId: string) { + setSelectedCategoryId(categoryId); } - } - public render() { - const { - columnHeaders, - browserFields, - height, - isEventViewer = false, - onFieldSelected, - timelineId, - toggleColumn, - width, - } = this.props; - const { - filterInput, - filteredBrowserFields, - isSearching, - selectedCategoryId, - show, - } = this.state; + /** + * Invoked when the user clicks on the context menu to view a category's + * columns in the timeline, this function dispatches the action that + * causes the timeline display those columns. + */ + function updateColumnsAndSelectCategoryId(columns: ColumnHeader[]) { + onUpdateColumns(columns); // show the category columns in the timeline + } + /** Invoked when the field browser should be hidden */ + function hideFieldBrowser() { + setFilterInput(''); + setFilterInput(''); + setFilteredBrowserFields(null); + setIsSearching(false); + setSelectedCategoryId(DEFAULT_CATEGORY_NAME); + setShow(false); + } // only merge in the default category if the field browser is visible const browserFieldsWithDefaultCategory = show ? mergeBrowserFieldsWithDefaultCategory(browserFields) @@ -121,14 +165,14 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< className={fieldsButtonClassName} data-test-subj="show-field-browser-gear" iconType="list" - onClick={this.toggleShow} + onClick={toggleShow} /> ) : ( {i18n.FIELDS} @@ -148,12 +192,12 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< height={height} isEventViewer={isEventViewer} isSearching={isSearching} - onCategorySelected={this.updateSelectedCategoryId} + onCategorySelected={updateSelectedCategoryId} onFieldSelected={onFieldSelected} - onHideFieldBrowser={this.hideFieldBrowser} - onOutsideClick={show ? this.hideFieldBrowser : noop} - onSearchInputChange={this.updateFilter} - onUpdateColumns={this.updateColumnsAndSelectCategoryId} + onHideFieldBrowser={hideFieldBrowser} + onOutsideClick={show ? hideFieldBrowser : noop} + onSearchInputChange={updateFilter} + onUpdateColumns={updateColumnsAndSelectCategoryId} searchInput={filterInput} selectedCategoryId={selectedCategoryId} timelineId={timelineId} @@ -165,84 +209,9 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< ); } +); - /** Shows / hides the field browser */ - private toggleShow = () => { - this.setState(({ show }) => ({ - show: !show, - })); - }; - - /** Invoked when the user types in the filter input */ - private updateFilter = (filterInput: string): void => { - this.setState({ - filterInput, - isSearching: true, - }); - - if (this.inputTimeoutId !== 0) { - clearTimeout(this.inputTimeoutId); // ⚠️ mutation: cancel any previous timers - } - - // ⚠️ mutation: schedule a new timer that will apply the filter when it fires: - this.inputTimeoutId = window.setTimeout(() => { - const filteredBrowserFields = filterBrowserFieldsByFieldName({ - browserFields: mergeBrowserFieldsWithDefaultCategory(this.props.browserFields), - substring: this.state.filterInput, - }); - - this.setState(currentState => ({ - filteredBrowserFields, - isSearching: false, - selectedCategoryId: - currentState.filterInput === '' || Object.keys(filteredBrowserFields).length === 0 - ? DEFAULT_CATEGORY_NAME - : Object.keys(filteredBrowserFields) - .sort() - .reduce( - (selected, category) => - filteredBrowserFields[category].fields != null && - filteredBrowserFields[selected].fields != null && - filteredBrowserFields[category].fields!.length > - filteredBrowserFields[selected].fields!.length - ? category - : selected, - Object.keys(filteredBrowserFields)[0] - ), - })); - }, INPUT_TIMEOUT); - }; - - /** - * Invoked when the user clicks a category name in the left-hand side of - * the field browser - */ - private updateSelectedCategoryId = (categoryId: string): void => { - this.setState({ - selectedCategoryId: categoryId, - }); - }; - - /** - * Invoked when the user clicks on the context menu to view a category's - * columns in the timeline, this function dispatches the action that - * causes the timeline display those columns. - */ - private updateColumnsAndSelectCategoryId: OnUpdateColumns = (columns: ColumnHeader[]): void => { - this.props.onUpdateColumns(columns); // show the category columns in the timeline - }; - - /** Invoked when the field browser should be hidden */ - private hideFieldBrowser = () => { - this.setState({ - filterInput: '', - filteredBrowserFields: null, - isSearching: false, - selectedCategoryId: DEFAULT_CATEGORY_NAME, - show: false, - }); - }; -} +StatefulFieldsBrowserComponent.displayName = 'StatefulFieldsBrowserComponent'; export const StatefulFieldsBrowser = connect( null, diff --git a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx index 15ce42c6a16b64..ceaff289f776cb 100644 --- a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx @@ -111,18 +111,30 @@ const FlyoutHeaderWithCloseButton = React.memo<{ FlyoutHeaderWithCloseButton.displayName = 'FlyoutHeaderWithCloseButton'; -class FlyoutPaneComponent extends React.PureComponent { - public render() { - const { - children, - flyoutHeight, - headerHeight, - onClose, - timelineId, - usersViewing, - width, - } = this.props; - +const FlyoutPaneComponent = React.memo( + ({ + applyDeltaToWidth, + children, + flyoutHeight, + headerHeight, + onClose, + timelineId, + usersViewing, + width, + }) => { + const renderFlyout = () => <>; + + const onResize: OnResize = ({ delta, id }) => { + const bodyClientWidthPixels = document.body.clientWidth; + + applyDeltaToWidth({ + bodyClientWidthPixels, + delta, + id, + maxWidthPercent, + minWidthPixels, + }); + }; return ( { } id={timelineId} - onResize={this.onResize} - render={this.renderFlyout} + onResize={onResize} + render={renderFlyout} /> { ); } +); - private renderFlyout = () => <>; - - private onResize: OnResize = ({ delta, id }) => { - const { applyDeltaToWidth } = this.props; - - const bodyClientWidthPixels = document.body.clientWidth; - - applyDeltaToWidth({ - bodyClientWidthPixels, - delta, - id, - maxWidthPercent, - minWidthPixels, - }); - }; -} +FlyoutPaneComponent.displayName = 'FlyoutPaneComponent'; export const Pane = connect( null, diff --git a/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx b/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx index 90af0d56c15820..b59753e8add6a7 100644 --- a/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx +++ b/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx @@ -16,30 +16,28 @@ export const Icon = styled(EuiIcon)` Icon.displayName = 'Icon'; -export class HelpMenuComponent extends React.PureComponent { - public render() { - return ( - <> - - - - - -
- - -
-
- - - - - - - - ); - } -} +export const HelpMenuComponent = React.memo(() => ( + <> + + + + + +
+ + +
+
+ + + + + + + +)); + +HelpMenuComponent.displayName = 'HelpMenuComponent'; diff --git a/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx b/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx index 5ed9a3b623c1ce..da2e7334756e4e 100644 --- a/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx @@ -5,7 +5,7 @@ */ import { EuiAccordion, EuiAccordionProps } from '@elastic/eui'; -import * as React from 'react'; +import React, { useState } from 'react'; type Props = Pick> & { forceExpand?: boolean; @@ -14,10 +14,6 @@ type Props = Pick React.ReactNode; }; -interface State { - expanded: boolean; -} - /** * An accordion that doesn't render it's content unless it's expanded. * This component was created because `EuiAccordion`'s eager rendering of @@ -33,29 +29,36 @@ interface State { * TODO: animate the expansion and collapse of content rendered "below" * the real `EuiAccordion`. */ -export class LazyAccordion extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { - expanded: false, +export const LazyAccordion = React.memo( + ({ + buttonContent, + buttonContentClassName, + extraAction, + forceExpand, + id, + onCollapse, + onExpand, + paddingSize, + renderExpandedContent, + }) => { + const [expanded, setExpanded] = useState(false); + const onCollapsedClick = () => { + setExpanded(true); + if (onExpand != null) { + onExpand(); + } }; - } - public render() { - const { - id, - buttonContentClassName, - buttonContent, - forceExpand, - extraAction, - renderExpandedContent, - paddingSize, - } = this.props; + const onExpandedClick = () => { + setExpanded(false); + if (onCollapse != null) { + onCollapse(); + } + }; return ( <> - {forceExpand || this.state.expanded ? ( + {forceExpand || expanded ? ( <> { extraAction={extraAction} id={id} initialIsOpen={true} - onClick={this.onExpandedClick} + onClick={onExpandedClick} paddingSize={paddingSize} > <> - {renderExpandedContent(this.state.expanded)} + {renderExpandedContent(expanded)} ) : ( { data-test-subj="lazy-accordion-placeholder" extraAction={extraAction} id={id} - onClick={this.onCollapsedClick} + onClick={onCollapsedClick} paddingSize={paddingSize} /> )} ); } +); - private onCollapsedClick = () => { - const { onExpand } = this.props; - - this.setState({ expanded: true }); - - if (onExpand != null) { - onExpand(); - } - }; - - private onExpandedClick = () => { - const { onCollapse } = this.props; - - this.setState({ expanded: false }); - - if (onCollapse != null) { - onCollapse(); - } - }; -} +LazyAccordion.displayName = 'LazyAccordion'; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/load_more_table/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 4bf3f647502e2d..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,104 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Load More Table Component rendering it renders the default load more table 1`] = ` - - My test supplement. -

- } - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={ - Array [ - Object { - "numberOfRow": 2, - "text": "2 rows", - }, - Object { - "numberOfRow": 5, - "text": "5 rows", - }, - Object { - "numberOfRow": 10, - "text": "10 rows", - }, - Object { - "numberOfRow": 20, - "text": "20 rows", - }, - Object { - "numberOfRow": 50, - "text": "50 rows", - }, - ] - } - limit={1} - loadMore={[Function]} - loading={false} - pageOfItems={ - Array [ - Object { - "cursor": Object { - "value": "98966fa2013c396155c460d35c0902be", - }, - "host": Object { - "_id": "cPsuhGcB0WOhS6qyTKC0", - "firstSeen": "2018-12-06T15:40:53.319Z", - "name": "elrond.elstc.co", - "os": "Ubuntu", - "version": "18.04.1 LTS (Bionic Beaver)", - }, - }, - Object { - "cursor": Object { - "value": "aa7ca589f1b8220002f2fc61c64cfbf1", - }, - "host": Object { - "_id": "KwQDiWcB0WOhS6qyXmrW", - "firstSeen": "2018-12-07T14:12:38.560Z", - "name": "siem-kibana", - "os": "Debian GNU/Linux", - "version": "9 (stretch)", - }, - }, - ] - } - updateLimitPagination={[Function]} -/> -`; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.mock.tsx b/x-pack/legacy/plugins/siem/public/components/load_more_table/index.mock.tsx deleted file mode 100644 index 02ec00a78bc91c..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.mock.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* - * 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 { getOrEmptyTagFromValue } from '../empty_value'; - -import { Columns, ItemsPerRow } from './index'; - -export const mockData = { - Hosts: { - totalCount: 4, - edges: [ - { - host: { - _id: 'cPsuhGcB0WOhS6qyTKC0', - name: 'elrond.elstc.co', - os: 'Ubuntu', - version: '18.04.1 LTS (Bionic Beaver)', - firstSeen: '2018-12-06T15:40:53.319Z', - }, - cursor: { - value: '98966fa2013c396155c460d35c0902be', - }, - }, - { - host: { - _id: 'KwQDiWcB0WOhS6qyXmrW', - name: 'siem-kibana', - os: 'Debian GNU/Linux', - version: '9 (stretch)', - firstSeen: '2018-12-07T14:12:38.560Z', - }, - cursor: { - value: 'aa7ca589f1b8220002f2fc61c64cfbf1', - }, - }, - ], - pageInfo: { - endCursor: { - value: 'aa7ca589f1b8220002f2fc61c64cfbf1', - }, - hasNextPage: true, - }, - }, -}; - -export const getHostsColumns = (): [ - Columns, - Columns, - Columns, - Columns -] => [ - { - field: 'node.host.name', - name: 'Host', - truncateText: false, - hideForMobile: false, - render: (name: string) => getOrEmptyTagFromValue(name), - }, - { - field: 'node.host.firstSeen', - name: 'First seen', - truncateText: false, - hideForMobile: false, - render: (firstSeen: string) => getOrEmptyTagFromValue(firstSeen), - }, - { - field: 'node.host.os', - name: 'OS', - truncateText: false, - hideForMobile: false, - render: (os: string) => getOrEmptyTagFromValue(os), - }, - { - field: 'node.host.version', - name: 'Version', - truncateText: false, - hideForMobile: false, - render: (version: string) => getOrEmptyTagFromValue(version), - }, -]; - -export const sortedHosts: [ - Columns, - Columns, - Columns, - Columns -] = getHostsColumns().map(h => ({ ...h, sortable: true })) as [ - Columns, - Columns, - Columns, - Columns -]; - -export const rowItems: ItemsPerRow[] = [ - { - text: '2 rows', - numberOfRow: 2, - }, - { - text: '5 rows', - numberOfRow: 5, - }, - { - text: '10 rows', - numberOfRow: 10, - }, - { - text: '20 rows', - numberOfRow: 20, - }, - { - text: '50 rows', - numberOfRow: 50, - }, -]; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/load_more_table/index.test.tsx deleted file mode 100644 index 3c42d3d2acfe32..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.test.tsx +++ /dev/null @@ -1,360 +0,0 @@ -/* - * 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 { mount, shallow } from 'enzyme'; -import toJson from 'enzyme-to-json'; -import * as React from 'react'; - -import { Direction } from '../../graphql/types'; - -import { LoadMoreTable } from './index'; -import { getHostsColumns, mockData, rowItems, sortedHosts } from './index.mock'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { ThemeProvider } from 'styled-components'; - -describe('Load More Table Component', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - const loadMore = jest.fn(); - const updateLimitPagination = jest.fn(); - describe('rendering', () => { - test('it renders the default load more table', () => { - const wrapper = shallow( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(toJson(wrapper)).toMatchSnapshot(); - }); - - test('it renders the loading panel at the beginning ', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={[]} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect( - wrapper.find('[data-test-subj="initialLoadingPanelLoadMoreTable"]').exists() - ).toBeTruthy(); - }); - - test('it renders the over loading panel after data has been in the table ', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('[data-test-subj="loadingPanelLoadMoreTable"]').exists()).toBeTruthy(); - }); - - test('it renders the loadMore button if need to fetch more', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect( - wrapper - .find('[data-test-subj="loadingMoreButton"]') - .first() - .text() - ).toContain('Load more'); - }); - - test('it renders the Loading... in the more load button when fetching new data', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect( - wrapper.find('[data-test-subj="initialLoadingPanelLoadMoreTable"]').exists() - ).toBeFalsy(); - expect( - wrapper - .find('[data-test-subj="loadingMoreButton"]') - .first() - .text() - ).toContain('Loading…'); - }); - - test('it does NOT render the loadMore button because there is nothing else to fetch', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('[data-test-subj="loadingMoreButton"]').exists()).toBeFalsy(); - }); - - test('it render popover to select new limit in table', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('[data-test-subj="loadingMoreSizeRowPopover"] button') - .first() - .simulate('click'); - expect(wrapper.find('[data-test-subj="loadingMorePickSizeRow"]').exists()).toBeTruthy(); - }); - - test('it will NOT render popover to select new limit in table if props itemsPerRow is empty', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={[]} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeFalsy(); - }); - - test('It should render a sort icon if sorting is defined', () => { - const mockOnChange = jest.fn(); - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={jest.fn()} - onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - sorting={{ direction: Direction.asc, field: 'node.host.name' }} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('.euiTable thead tr th button svg')).toBeTruthy(); - }); - }); - - describe('Events', () => { - test('should call loadmore when clicking on the button load more', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('[data-test-subj="loadingMoreButton"]') - .first() - .simulate('click'); - - expect(loadMore).toBeCalled(); - }); - - test('Should call updateLimitPagination when you pick a new limit', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('[data-test-subj="loadingMoreSizeRowPopover"] button') - .first() - .simulate('click'); - - wrapper - .find('[data-test-subj="loadingMorePickSizeRow"] button') - .first() - .simulate('click'); - expect(updateLimitPagination).toBeCalled(); - }); - - test('Should call onChange when you choose a new sort in the table', () => { - const mockOnChange = jest.fn(); - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={jest.fn()} - onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - sorting={{ direction: Direction.asc, field: 'node.host.name' }} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('.euiTable thead tr th button') - .first() - .simulate('click'); - - expect(mockOnChange).toBeCalled(); - expect(mockOnChange.mock.calls[0]).toEqual([ - { page: undefined, sort: { direction: 'desc', field: 'node.host.name' } }, - ]); - }); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/load_more_table/index.tsx deleted file mode 100644 index 0663246039cb8d..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.tsx +++ /dev/null @@ -1,320 +0,0 @@ -/* - * 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 { - EuiBasicTable, - EuiButton, - EuiButtonEmpty, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingContent, - EuiPopover, -} from '@elastic/eui'; -import { isEmpty, noop } from 'lodash/fp'; -import React from 'react'; -import styled from 'styled-components'; - -import { Direction } from '../../graphql/types'; -import { HeaderPanel } from '../header_panel'; -import { Loader } from '../loader'; - -import * as i18n from './translations'; -import { Panel } from '../panel'; - -const DEFAULT_DATA_TEST_SUBJ = 'load-more-table'; - -export interface ItemsPerRow { - text: string; - numberOfRow: number; -} - -export interface SortingBasicTable { - field: string; - direction: Direction; - allowNeutralSort?: boolean; -} - -export interface Criteria { - page?: { index: number; size: number }; - sort?: SortingBasicTable; -} - -// Using telescoping templates to remove 'any' that was polluting downstream column type checks -interface BasicTableProps { - columns: - | [Columns] - | [Columns, Columns] - | [Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns, Columns, Columns, Columns] - | [ - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns - ] - | [ - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns - ]; - hasNextPage: boolean; - dataTestSubj?: string; - headerCount: number; - headerSupplement?: React.ReactElement; - headerTitle: string | React.ReactElement; - headerTooltip?: string; - headerUnit: string | React.ReactElement; - id?: string; - itemsPerRow?: ItemsPerRow[]; - limit: number; - loading: boolean; - loadMore: () => void; - onChange?: (criteria: Criteria) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - pageOfItems: any[]; - sorting?: SortingBasicTable; - updateLimitPagination: (limit: number) => void; -} - -interface BasicTableState { - loadingInitial: boolean; - isPopoverOpen: boolean; - showInspect: boolean; -} - -type Func = (arg: T) => string | number; - -export interface Columns { - field?: string; - align?: string; - name: string | React.ReactNode; - isMobileHeader?: boolean; - sortable?: boolean | Func; - truncateText?: boolean; - hideForMobile?: boolean; - render?: (item: T, node: U) => React.ReactNode; - width?: string; -} - -export class LoadMoreTable extends React.PureComponent< - BasicTableProps, - BasicTableState -> { - public readonly state = { - loadingInitial: this.props.headerCount === -1, - isPopoverOpen: false, - showInspect: false, - }; - - static getDerivedStateFromProps( - props: BasicTableProps, - state: BasicTableState - ) { - if (state.loadingInitial && props.headerCount >= 0) { - return { - ...state, - loadingInitial: false, - }; - } - return null; - } - - public render() { - const { - columns, - dataTestSubj = DEFAULT_DATA_TEST_SUBJ, - hasNextPage, - headerCount, - headerSupplement, - headerTitle, - headerTooltip, - headerUnit, - id, - itemsPerRow, - limit, - loading, - onChange = noop, - pageOfItems, - sorting = null, - updateLimitPagination, - } = this.props; - const { loadingInitial } = this.state; - - const button = ( - - {`${i18n.ROWS}: ${limit}`} - - ); - - const rowItems = - itemsPerRow && - itemsPerRow.map(item => ( - { - this.closePopover(); - updateLimitPagination(item.numberOfRow); - }} - > - {item.text} - - )); - - return ( - - = 0 ? headerCount.toLocaleString() : 0} ${headerUnit}` - } - title={headerTitle} - tooltip={headerTooltip} - > - {!loadingInitial && headerSupplement} - - - {loadingInitial ? ( - - ) : ( - <> - - - {hasNextPage && ( - - - {!isEmpty(itemsPerRow) && ( - - - - )} - - - - - {loading ? `${i18n.LOADING}` : i18n.LOAD_MORE} - - - - )} - - {loading && } - - )} - - ); - } - - private mouseEnter = () => { - this.setState(prevState => ({ - ...prevState, - showInspect: true, - })); - }; - - private mouseLeave = () => { - this.setState(prevState => ({ - ...prevState, - showInspect: false, - })); - }; - - private onButtonClick = () => { - this.setState(prevState => ({ - ...prevState, - isPopoverOpen: !prevState.isPopoverOpen, - })); - }; - - private closePopover = () => { - this.setState(prevState => ({ - ...prevState, - isPopoverOpen: false, - })); - }; -} - -const BasicTable = styled(EuiBasicTable)` - tbody { - th, - td { - vertical-align: top; - } - - .euiTableCellContent { - display: block; - } - } -`; - -BasicTable.displayName = 'BasicTable'; - -const FooterAction = styled(EuiFlexGroup).attrs({ - alignItems: 'center', - responsive: false, -})` - margin-top: ${props => props.theme.eui.euiSizeXS}; -`; - -FooterAction.displayName = 'FooterAction'; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/translations.ts b/x-pack/legacy/plugins/siem/public/components/load_more_table/translations.ts deleted file mode 100644 index ec093f97216247..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/translations.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 { i18n } from '@kbn/i18n'; - -export const LOADING = i18n.translate('xpack.siem.loadMoreTable.loadingButtonLabel', { - defaultMessage: 'Loading…', -}); - -export const LOAD_MORE = i18n.translate('xpack.siem.loadMoreTable.loadMoreButtonLabel', { - defaultMessage: 'Load more', -}); - -export const SHOWING = i18n.translate('xpack.siem.loadMoreTable.showingSubtitle', { - defaultMessage: 'Showing', -}); - -export const ROWS = i18n.translate('xpack.siem.loadMoreTable.rowsButtonLabel', { - defaultMessage: 'Rows per page', -}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx b/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx index b2470bc0f5abd4..0956e93829e5ab 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx @@ -21,7 +21,7 @@ export const MlCapabilitiesContext = React.createContext(emptyMl MlCapabilitiesContext.displayName = 'MlCapabilitiesContext'; export const MlCapabilitiesProvider = React.memo<{ children: JSX.Element }>(({ children }) => { - const [capabilities, setCapabilities] = useState(emptyMlCapabilities); + const [capabilities, setCapabilities] = useState(emptyMlCapabilities); const [, dispatchToaster] = useStateToaster(); const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx index 3a1fcbb653efec..04fed8e4fff3f0 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx @@ -8,7 +8,7 @@ import { getAnomaliesHostTableColumnsCurated } from './get_anomalies_host_table_ import { HostsType } from '../../../store/hosts/model'; import * as i18n from './translations'; import { AnomaliesByHost, Anomaly } from '../types'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { TestProviders } from '../../../mock'; import { mount } from 'enzyme'; import React from 'react'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx index f0cf2e5a6e662d..6650449dd8200d 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import moment from 'moment'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { AnomaliesByHost, Anomaly, NarrowDateRange } from '../types'; import { getRowItemDraggable } from '../../tables/helpers'; import { EntityDraggable } from '../entity_draggable'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx index d063ed023bca6a..768c7af8f4b2c9 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx @@ -8,7 +8,7 @@ import { getAnomaliesNetworkTableColumnsCurated } from './get_anomalies_network_ import { NetworkType } from '../../../store/network/model'; import * as i18n from './translations'; import { AnomaliesByNetwork, Anomaly } from '../types'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { mount } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../../mock'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx index fb43175686e3d7..1e1628fb077dd4 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import moment from 'moment'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { Anomaly, NarrowDateRange, AnomaliesByNetwork } from '../types'; import { getRowItemDraggable } from '../../tables/helpers'; import { EntityDraggable } from '../entity_draggable'; diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx index 25ebb8ad89ecd1..96cb85b246a49b 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; +import { mount } from 'enzyme'; import * as React from 'react'; import { CONSTANTS } from '../url_state/constants'; @@ -63,7 +63,7 @@ describe('SIEM Navigation', () => { }, [CONSTANTS.timelineId]: '', }; - const wrapper = shallow(); + const wrapper = mount(); test('it calls setBreadcrumbs with correct path on mount', () => { expect(setBreadcrumbs).toHaveBeenNthCalledWith(1, { detailName: undefined, diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx index 6c5cac1464e79c..06f7a2ffb05661 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx @@ -5,7 +5,7 @@ */ import { isEqual } from 'lodash/fp'; -import React from 'react'; +import React, { useEffect } from 'react'; import { compose } from 'redux'; import { connect } from 'react-redux'; @@ -27,78 +27,23 @@ import { TabNavigation } from './tab_navigation'; import { TabNavigationProps } from './tab_navigation/types'; import { SiemNavigationComponentProps } from './types'; -export class SiemNavigationComponent extends React.Component { - public shouldComponentUpdate(nextProps: Readonly): boolean { - if ( - this.props.pathName === nextProps.pathName && - this.props.search === nextProps.search && - isEqual(this.props.hosts, nextProps.hosts) && - isEqual(this.props.hostDetails, nextProps.hostDetails) && - isEqual(this.props.network, nextProps.network) && - isEqual(this.props.navTabs, nextProps.navTabs) && - isEqual(this.props.timerange, nextProps.timerange) && - isEqual(this.props.timelineId, nextProps.timelineId) - ) { - return false; - } - return true; - } - - public componentWillMount(): void { - const { - detailName, - hosts, - hostDetails, - navTabs, - network, - pageName, - pathName, - search, - tabName, - timerange, - timelineId, - } = this.props; - if (pathName) { - setBreadcrumbs({ - detailName, - hosts, - hostDetails, - navTabs, - network, - pageName, - pathName, - search, - tabName, - timerange, - timelineId, - }); - } - } - - public componentWillReceiveProps(nextProps: Readonly): void { - if ( - this.props.pathName !== nextProps.pathName || - this.props.search !== nextProps.search || - !isEqual(this.props.hosts, nextProps.hosts) || - !isEqual(this.props.hostDetails, nextProps.hostDetails) || - !isEqual(this.props.network, nextProps.network) || - !isEqual(this.props.navTabs, nextProps.navTabs) || - !isEqual(this.props.timerange, nextProps.timerange) || - !isEqual(this.props.timelineId, nextProps.timelineId) - ) { - const { - detailName, - hosts, - hostDetails, - navTabs, - network, - pageName, - pathName, - search, - tabName, - timelineId, - timerange, - } = nextProps; +export const SiemNavigationComponent = React.memo( + ({ + detailName, + display, + hostDetails, + hosts, + navTabs, + network, + pageName, + pathName, + search, + showBorder, + tabName, + timelineId, + timerange, + }) => { + useEffect(() => { if (pathName) { setBreadcrumbs({ detailName, @@ -114,23 +59,8 @@ export class SiemNavigationComponent extends React.Component ); + }, + (prevProps, nextProps) => { + return ( + prevProps.pathName === nextProps.pathName && + prevProps.search === nextProps.search && + isEqual(prevProps.hosts, nextProps.hosts) && + isEqual(prevProps.hostDetails, nextProps.hostDetails) && + isEqual(prevProps.network, nextProps.network) && + isEqual(prevProps.navTabs, nextProps.navTabs) && + isEqual(prevProps.timerange, nextProps.timerange) && + isEqual(prevProps.timelineId, nextProps.timelineId) + ); } -} +); + +SiemNavigationComponent.displayName = 'SiemNavigationComponent'; const makeMapStateToProps = () => { const getInputsSelector = inputsSelectors.inputsSelector(); diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx index b6ec9e5ee0e029..61a0ec9c06c2d0 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import * as React from 'react'; import { TabNavigation } from './'; @@ -74,8 +74,8 @@ describe('Tab Navigation', () => { expect(hostsTab.prop('isSelected')).toBeTruthy(); }); test('it changes active tab when nav changes by props', () => { - const wrapper = shallow(); - const networkTab = () => wrapper.find('[data-test-subj="navigation-network"]'); + const wrapper = mount(); + const networkTab = () => wrapper.find('[data-test-subj="navigation-network"]').first(); expect(networkTab().prop('isSelected')).toBeFalsy(); wrapper.setProps({ pageName: 'network', @@ -151,9 +151,9 @@ describe('Tab Navigation', () => { expect(tableNavigationTab.prop('isSelected')).toBeTruthy(); }); test('it changes active tab when nav changes by props', () => { - const wrapper = shallow(); + const wrapper = mount(); const tableNavigationTab = () => - wrapper.find(`[data-test-subj="navigation-${HostsTableType.events}"]`); + wrapper.find(`[data-test-subj="navigation-${HostsTableType.events}"]`).first(); expect(tableNavigationTab().prop('isSelected')).toBeFalsy(); wrapper.setProps({ pageName: SiemPageName.hosts, diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx index 9a409b9f53d8ce..c62335ea1c06db 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx @@ -4,14 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiTab, EuiTabs, EuiLink } from '@elastic/eui'; -import { get, getOr } from 'lodash/fp'; +import { getOr } from 'lodash/fp'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import styled from 'styled-components'; import classnames from 'classnames'; import { trackUiAction as track, METRIC_TYPE, TELEMETRY_EVENT } from '../../../lib/track_usage'; -import { HostsTableType } from '../../../store/hosts/model'; import { getSearch } from '../helpers'; import { TabNavigationProps } from './types'; @@ -36,71 +35,51 @@ const TabContainer = styled.div` TabContainer.displayName = 'TabContainer'; -interface TabNavigationState { - selectedTabId: string; -} - -export class TabNavigation extends React.PureComponent { - constructor(props: TabNavigationProps) { - super(props); - const selectedTabId = this.mapLocationToTab(props.pageName, props.tabName); - this.state = { selectedTabId }; - } - public componentWillReceiveProps(nextProps: TabNavigationProps): void { - const selectedTabId = this.mapLocationToTab(nextProps.pageName, nextProps.tabName); - - if (this.state.selectedTabId !== selectedTabId) { - this.setState(prevState => ({ - ...prevState, - selectedTabId, - })); - } - } - public render() { - const { display = 'condensed' } = this.props; - return ( - - {this.renderTabs()} - - ); - } - - public mapLocationToTab = (pageName: string, tabName?: HostsTableType): string => { - const { navTabs } = this.props; +export const TabNavigation = React.memo(props => { + const { display = 'condensed', navTabs, pageName, showBorder, tabName } = props; + const mapLocationToTab = (): string => { return getOr( '', 'id', Object.values(navTabs).find(item => tabName === item.id || pageName === item.id) ); }; + const [selectedTabId, setSelectedTabId] = useState(mapLocationToTab()); + useEffect(() => { + const currentTabSelected = mapLocationToTab(); - private renderTabs = (): JSX.Element[] => { - const { navTabs } = this.props; - return Object.keys(navTabs).map(tabName => { - const tab = get(tabName, navTabs); - return ( - + Object.values(navTabs).map(tab => ( + + - { + track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.TAB_CLICKED}${tab.id}`); + }} > - { - track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.TAB_CLICKED}${tab.id}`); - }} - > - {tab.name} - - - - ); - }); - }; -} + {tab.name} + + + + )); + return ( + + {renderTabs()} + + ); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/notes/index.tsx b/x-pack/legacy/plugins/siem/public/components/notes/index.tsx index 8eaf368058631b..29f7686ade88b5 100644 --- a/x-pack/legacy/plugins/siem/public/components/notes/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/notes/index.tsx @@ -5,7 +5,7 @@ */ import { EuiInMemoryTable, EuiModalBody, EuiModalHeader, EuiPanel, EuiSpacer } from '@elastic/eui'; -import * as React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; import { Note } from '../../lib/note'; @@ -23,10 +23,6 @@ interface Props { updateNote: UpdateNote; } -interface State { - newNote: string; -} - const NotesPanel = styled(EuiPanel)` height: ${NOTES_PANEL_HEIGHT}px; width: ${NOTES_PANEL_WIDTH}px; @@ -47,15 +43,9 @@ const InMemoryTable = styled(EuiInMemoryTable)` InMemoryTable.displayName = 'InMemoryTable'; /** A view for entering and reviewing notes */ -export class Notes extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { newNote: '' }; - } - - public render() { - const { associateNote, getNotesByIds, getNewNoteId, noteIds, updateNote } = this.props; +export const Notes = React.memo( + ({ associateNote, getNotesByIds, getNewNoteId, noteIds, updateNote }) => { + const [newNote, setNewNote] = useState(''); return ( @@ -67,8 +57,8 @@ export class Notes extends React.PureComponent { @@ -84,8 +74,6 @@ export class Notes extends React.PureComponent { ); } +); - private updateNewNote = (newNote: string): void => { - this.setState({ newNote }); - }; -} +Notes.displayName = 'Notes'; diff --git a/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx b/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx index 51992e00313a4a..aa9415aadeda16 100644 --- a/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx @@ -5,7 +5,7 @@ */ import { EuiFlexGroup, EuiPanel } from '@elastic/eui'; -import * as React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; import { Note } from '../../../lib/note'; @@ -53,27 +53,23 @@ interface Props { updateNote: UpdateNote; } -interface State { - newNote: string; -} - /** A view for entering and reviewing notes */ -export class NoteCards extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { newNote: '' }; - } - - public render() { - const { - getNotesByIds, - getNewNoteId, - noteIds, - showAddNote, - toggleShowAddNote, - updateNote, - } = this.props; +export const NoteCards = React.memo( + ({ + associateNote, + getNotesByIds, + getNewNoteId, + noteIds, + showAddNote, + toggleShowAddNote, + updateNote, + }) => { + const [newNote, setNewNote] = useState(''); + + const associateNoteAndToggleShow = (noteId: string) => { + associateNote(noteId); + toggleShowAddNote(); + }; return ( @@ -90,11 +86,11 @@ export class NoteCards extends React.PureComponent { {showAddNote ? ( @@ -102,13 +98,6 @@ export class NoteCards extends React.PureComponent { ); } +); - private associateNoteAndToggleShow = (noteId: string) => { - this.props.associateNote(noteId); - this.props.toggleShowAddNote(); - }; - - private updateNewNote = (newNote: string): void => { - this.setState({ newNote }); - }; -} +NoteCards.displayName = 'NoteCards'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx index e91feed536f93c..917ec3f1bf0b86 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx @@ -17,7 +17,7 @@ describe('DeleteTimelineModal', () => { ); @@ -34,7 +34,7 @@ describe('DeleteTimelineModal', () => { ); @@ -48,7 +48,7 @@ describe('DeleteTimelineModal', () => { test('it displays `Untitled Timeline` in the title when title is undefined', () => { const wrapper = mountWithIntl( - + ); expect( @@ -61,7 +61,7 @@ describe('DeleteTimelineModal', () => { test('it displays `Untitled Timeline` in the title when title is null', () => { const wrapper = mountWithIntl( - + ); expect( @@ -74,7 +74,7 @@ describe('DeleteTimelineModal', () => { test('it displays `Untitled Timeline` in the title when title is just whitespace', () => { const wrapper = mountWithIntl( - + ); expect( @@ -90,7 +90,7 @@ describe('DeleteTimelineModal', () => { ); @@ -102,14 +102,14 @@ describe('DeleteTimelineModal', () => { ).toEqual(i18n.DELETE_WARNING); }); - test('it invokes toggleShowModal when the Cancel button is clicked', () => { - const toggleShowModal = jest.fn(); + test('it invokes closeModal when the Cancel button is clicked', () => { + const closeModal = jest.fn(); const wrapper = mountWithIntl( ); @@ -118,7 +118,7 @@ describe('DeleteTimelineModal', () => { .first() .simulate('click'); - expect(toggleShowModal).toBeCalled(); + expect(closeModal).toBeCalled(); }); test('it invokes onDelete when the Delete button is clicked', () => { @@ -128,7 +128,7 @@ describe('DeleteTimelineModal', () => { ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 9c416419066e66..e9e438a8c5e2e2 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -14,7 +14,7 @@ import * as i18n from '../translations'; interface Props { title?: string | null; onDelete: () => void; - toggleShowModal: () => void; + closeModal: () => void; } export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px @@ -22,7 +22,7 @@ export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px /** * Renders a modal that confirms deletion of a timeline */ -export const DeleteTimelineModal = pure(({ title, toggleShowModal, onDelete }) => ( +export const DeleteTimelineModal = pure(({ title, closeModal, onDelete }) => ( (({ title, toggleShowModal, onDele }} /> } - onCancel={toggleShowModal} + onCancel={closeModal} onConfirm={onDelete} cancelButtonText={i18n.CANCEL} confirmButtonText={i18n.DELETE} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx index 1700e86f57c844..561eac000bbf76 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -5,7 +5,6 @@ */ import { EuiButtonIconProps } from '@elastic/eui'; -import { get } from 'lodash/fp'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import * as React from 'react'; @@ -79,35 +78,6 @@ describe('DeleteTimelineModal', () => { expect(props.isDisabled).toBe(false); }); - test('it defaults showModal to false until the trash button is clicked', () => { - const wrapper = mountWithIntl( - - ); - - expect(get('showModal', wrapper.state())).toBe(false); - }); - - test('it sets showModal to true when the trash button is clicked', () => { - const wrapper = mountWithIntl( - - ); - - wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .simulate('click'); - - expect(get('showModal', wrapper.state())).toBe(true); - }); - test('it does NOT render the modal when showModal is false', () => { const wrapper = mountWithIntl( { - constructor(props: Props) { - super(props); +export const DeleteTimelineModalButton = React.memo( + ({ deleteTimelines, savedObjectId, title }) => { + const [showModal, setShowModal] = useState(false); - this.state = { showModal: false }; - } + const openModal = () => setShowModal(true); + const closeModal = () => setShowModal(false); - public render() { - const { deleteTimelines, savedObjectId, title } = this.props; + const onDelete = () => { + if (deleteTimelines != null && savedObjectId != null) { + deleteTimelines([savedObjectId]); + } + closeModal(); + }; return ( <> @@ -44,19 +43,19 @@ export class DeleteTimelineModalButton extends React.PureComponent iconSize="s" iconType="trash" isDisabled={deleteTimelines == null || savedObjectId == null || savedObjectId === ''} - onClick={this.toggleShowModal} + onClick={openModal} size="s" /> - {this.state.showModal ? ( + {showModal ? ( - + @@ -64,20 +63,6 @@ export class DeleteTimelineModalButton extends React.PureComponent ); } +); - private toggleShowModal = () => { - this.setState(state => ({ - showModal: !state.showModal, - })); - }; - - private onDelete = () => { - const { deleteTimelines, savedObjectId } = this.props; - - if (deleteTimelines != null && savedObjectId != null) { - deleteTimelines([savedObjectId]); - } - - this.toggleShowModal(); - }; -} +DeleteTimelineModalButton.displayName = 'DeleteTimelineModalButton'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx index 62de2ea30542a0..7a0caf14af302e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx @@ -5,8 +5,7 @@ */ import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { mount, ReactWrapper } from 'enzyme'; -import { get } from 'lodash/fp'; +import { mount } from 'enzyme'; import { MockedProvider } from 'react-apollo/test-utils'; import * as React from 'react'; import { ThemeProvider } from 'styled-components'; @@ -22,21 +21,11 @@ import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; jest.mock('../../lib/settings/use_kibana_ui_setting'); -const getStateChildComponent = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - wrapper: ReactWrapper, React.Component<{}, {}, any>> -): // eslint-disable-next-line @typescript-eslint/no-explicit-any -React.Component<{}, {}, any> => - wrapper - .find('[data-test-subj="stateful-timeline"]') - .last() - .instance(); - describe('StatefulOpenTimeline', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); const title = 'All Timelines / Open Timelines'; - test('it has the expected initial state', async () => { + test('it has the expected initial state', () => { const wrapper = mount( @@ -53,15 +42,18 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - wrapper.update(); + const componentProps = wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .props(); - expect(getStateChildComponent(wrapper).state).toEqual({ + expect(componentProps).toEqual({ + ...componentProps, itemIdToExpandedNotesRowMap: {}, onlyFavorites: false, pageIndex: 0, pageSize: 10, - search: '', + query: '', selectedItems: [], sortDirection: 'desc', sortField: 'updated', @@ -69,7 +61,7 @@ describe('StatefulOpenTimeline', () => { }); describe('#onQueryChange', () => { - test('it updates the query state with the expected trimmed value when the user enters a query', async () => { + test('it updates the query state with the expected trimmed value when the user enters a query', () => { const wrapper = mount( @@ -85,26 +77,15 @@ describe('StatefulOpenTimeline', () => { ); - - await wait(); - wrapper.update(); - wrapper .find('[data-test-subj="search-bar"] input') .simulate('keyup', { keyCode: 13, target: { value: ' abcd ' } }); - - wrapper.update(); - - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: false, - pageIndex: 0, - pageSize: 10, - search: 'abcd', - selectedItems: [], - sortDirection: 'desc', - sortField: 'updated', - }); + expect( + wrapper + .find('[data-test-subj="search-row"]') + .first() + .prop('query') + ).toEqual('abcd'); }); test('it appends the word "with" to the Showing in Timelines message when the user enters a query', async () => { @@ -129,8 +110,6 @@ describe('StatefulOpenTimeline', () => { .find('[data-test-subj="search-bar"] input') .simulate('keyup', { keyCode: 13, target: { value: ' abcd ' } }); - wrapper.update(); - expect( wrapper .find('[data-test-subj="query-message"]') @@ -161,8 +140,6 @@ describe('StatefulOpenTimeline', () => { .find('[data-test-subj="search-bar"] input') .simulate('keyup', { keyCode: 13, target: { value: ' abcd ' } }); - wrapper.update(); - expect( wrapper .find('[data-test-subj="selectable-query-text"]') @@ -226,7 +203,6 @@ describe('StatefulOpenTimeline', () => { .find('.euiCheckbox__input') .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); wrapper .find('[data-test-subj="favorite-selected"]') @@ -273,7 +249,6 @@ describe('StatefulOpenTimeline', () => { .find('.euiCheckbox__input') .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); wrapper .find('[data-test-subj="delete-selected"]') @@ -319,14 +294,17 @@ describe('StatefulOpenTimeline', () => { .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); + const selectedItems: [] = wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('selectedItems'); - expect(get('selectedItems', getStateChildComponent(wrapper).state).length).toEqual(13); // 13 because we did mock 13 timelines in the query + expect(selectedItems.length).toEqual(13); // 13 because we did mock 13 timelines in the query }); }); describe('#onTableChange', () => { - test('it updates the sort state when the user clicks on a column to sort it', async () => { + test('it updates the sort state when the user clicks on a column to sort it', () => { const wrapper = mount( @@ -343,32 +321,29 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - wrapper.update(); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('sortDirection') + ).toEqual('desc'); wrapper .find('thead tr th button') .at(0) .simulate('click'); - wrapper.update(); - - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: false, - pageIndex: 0, - pageSize: 10, - search: '', - selectedItems: [], - sortDirection: 'asc', - sortField: 'updated', - }); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('sortDirection') + ).toEqual('asc'); }); }); describe('#onToggleOnlyFavorites', () => { - test('it updates the onlyFavorites state when the user clicks the Only Favorites button', async () => { + test('it updates the onlyFavorites state when the user clicks the Only Favorites button', () => { const wrapper = mount( @@ -385,25 +360,24 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('onlyFavorites') + ).toEqual(false); wrapper .find('[data-test-subj="only-favorites-toggle"]') .first() .simulate('click'); - wrapper.update(); - - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: true, - pageIndex: 0, - pageSize: 10, - search: '', - selectedItems: [], - sortDirection: 'desc', - sortField: 'updated', - }); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('onlyFavorites') + ).toEqual(true); }); }); @@ -426,38 +400,38 @@ describe('StatefulOpenTimeline', () => { ); await wait(); - wrapper.update(); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('itemIdToExpandedNotesRowMap') + ).toEqual({}); + wrapper .find('[data-test-subj="expand-notes"]') .first() .simulate('click'); - wrapper.update(); - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: { - '10849df0-7b44-11e9-a608-ab3d811609': ( - ({ ...note, savedObjectId: note.noteId }) - ) - : [] - } - /> - ), - }, - onlyFavorites: false, - pageIndex: 0, - pageSize: 10, - search: '', - selectedItems: [], - sortDirection: 'desc', - sortField: 'updated', + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('itemIdToExpandedNotesRowMap') + ).toEqual({ + '10849df0-7b44-11e9-a608-ab3d811609': ( + ({ ...note, savedObjectId: note.noteId }) + ) + : [] + } + /> + ), }); }); @@ -487,8 +461,6 @@ describe('StatefulOpenTimeline', () => { .first() .simulate('click'); - wrapper.update(); - expect( wrapper .find('[data-test-subj="note-previews-container"]') @@ -543,25 +515,23 @@ describe('StatefulOpenTimeline', () => { ); - + const getSelectedItem = (): [] => + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('selectedItems'); await wait(); - - wrapper.update(); - + expect(getSelectedItem().length).toEqual(0); wrapper .find('.euiCheckbox__input') .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); - + expect(getSelectedItem().length).toEqual(13); wrapper .find('[data-test-subj="delete-selected"]') .first() .simulate('click'); - - wrapper.update(); - - expect(get('selectedItems', getStateChildComponent(wrapper).state).length).toEqual(0); + expect(getSelectedItem().length).toEqual(0); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index c686228ed31e8f..d101d1f4d39f41 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -5,7 +5,7 @@ */ import ApolloClient from 'apollo-client'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; @@ -43,25 +43,6 @@ import { import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; -export interface OpenTimelineState { - /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ - itemIdToExpandedNotesRowMap: Record; - /** Only query for favorite timelines when true */ - onlyFavorites: boolean; - /** The requested page of results */ - pageIndex: number; - /** The requested size of each page of search results */ - pageSize: number; - /** The current search criteria */ - search: string; - /** The currently-selected timelines in the table */ - selectedItems: OpenTimelineResult[]; - /** The requested sort direction of the query results */ - sortDirection: 'asc' | 'desc'; - /** The requested field to sort on */ - sortField: string; -} - interface OwnProps { apolloClient: ApolloClient; /** Displays open timeline in modal */ @@ -85,70 +66,208 @@ export const getSelectedTimelineIds = (selectedItems: OpenTimelineResult[]): str ); /** Manages the state (e.g table selection) of the (pure) `OpenTimeline` component */ -export class StatefulOpenTimelineComponent extends React.PureComponent< - OpenTimelineOwnProps, - OpenTimelineState -> { - constructor(props: OpenTimelineOwnProps) { - super(props); +export const StatefulOpenTimelineComponent = React.memo( + ({ + defaultPageSize, + isModal = false, + title, + apolloClient, + closeModalTimeline, + updateTimeline, + updateIsLoading, + timeline, + createNewTimeline, + }) => { + /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ + const [itemIdToExpandedNotesRowMap, setItemIdToExpandedNotesRowMap] = useState< + Record + >({}); + /** Only query for favorite timelines when true */ + const [onlyFavorites, setOnlyFavorites] = useState(false); + /** The requested page of results */ + const [pageIndex, setPageIndex] = useState(0); + /** The requested size of each page of search results */ + const [pageSize, setPageSize] = useState(defaultPageSize); + /** The current search criteria */ + const [search, setSearch] = useState(''); + /** The currently-selected timelines in the table */ + const [selectedItems, setSelectedItems] = useState([]); + /** The requested sort direction of the query results */ + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(DEFAULT_SORT_DIRECTION); + /** The requested field to sort on */ + const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD); + + /** Invoked when the user presses enters to submit the text in the search input */ + const onQueryChange: OnQueryChange = (query: EuiSearchBarQuery) => { + setSearch(query.queryText.trim()); + }; + + /** Focuses the input that filters the field browser */ + const focusInput = () => { + const elements = document.querySelector(`.${OPEN_TIMELINE_CLASS_NAME} input`); - this.state = { - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: false, - search: '', - pageIndex: 0, - pageSize: props.defaultPageSize, - sortField: DEFAULT_SORT_FIELD, - sortDirection: DEFAULT_SORT_DIRECTION, - selectedItems: [], + if (elements != null) { + elements.focus(); + } }; - } - public componentDidMount() { - this.focusInput(); - } + /* This feature will be implemented in the near future, so we are keeping it to know what to do */ + + /** Invoked when the user clicks the action to add the selected timelines to favorites */ + // const onAddTimelinesToFavorites: OnAddTimelinesToFavorites = () => { + // const { addTimelinesToFavorites } = this.props; + // const { selectedItems } = this.state; + // if (addTimelinesToFavorites != null) { + // addTimelinesToFavorites(getSelectedTimelineIds(selectedItems)); + // TODO: it's not possible to clear the selection state of the newly-favorited + // items, because we can't pass the selection state as props to the table. + // See: https://github.com/elastic/eui/issues/1077 + // TODO: the query must re-execute to show the results of the mutation + // } + // }; + + const onDeleteOneTimeline: OnDeleteOneTimeline = (timelineIds: string[]) => { + deleteTimelines(timelineIds, { + search, + pageInfo: { + pageIndex: pageIndex + 1, + pageSize, + }, + sort: { + sortField: sortField as SortFieldTimeline, + sortOrder: sortDirection as Direction, + }, + onlyUserFavorite: onlyFavorites, + }); + }; + + /** Invoked when the user clicks the action to delete the selected timelines */ + const onDeleteSelected: OnDeleteSelected = () => { + deleteTimelines(getSelectedTimelineIds(selectedItems), { + search, + pageInfo: { + pageIndex: pageIndex + 1, + pageSize, + }, + sort: { + sortField: sortField as SortFieldTimeline, + sortOrder: sortDirection as Direction, + }, + onlyUserFavorite: onlyFavorites, + }); + + // NOTE: we clear the selection state below, but if the server fails to + // delete a timeline, it will remain selected in the table: + resetSelectionState(); + + // TODO: the query must re-execute to show the results of the deletion + }; + + /** Invoked when the user selects (or de-selects) timelines */ + const onSelectionChange: OnSelectionChange = (newSelectedItems: OpenTimelineResult[]) => { + setSelectedItems(newSelectedItems); // <-- this is NOT passed down as props to the table: https://github.com/elastic/eui/issues/1077 + }; + + /** Invoked by the EUI table implementation when the user interacts with the table (i.e. to update sorting) */ + const onTableChange: OnTableChange = ({ page, sort }: OnTableChangeParams) => { + const { index, size } = page; + const { field, direction } = sort; + setPageIndex(index); + setPageSize(size); + setSortDirection(direction); + setSortField(field); + }; + + /** Invoked when the user toggles the option to only view favorite timelines */ + const onToggleOnlyFavorites: OnToggleOnlyFavorites = () => { + setOnlyFavorites(!onlyFavorites); + }; + + /** Invoked when the user toggles the expansion or collapse of inline notes in a table row */ + const onToggleShowNotes: OnToggleShowNotes = ( + newItemIdToExpandedNotesRowMap: Record + ) => { + setItemIdToExpandedNotesRowMap(newItemIdToExpandedNotesRowMap); + }; + + /** Resets the selection state such that all timelines are unselected */ + const resetSelectionState = () => { + setSelectedItems([]); + }; + + const openTimeline: OnOpenTimeline = ({ + duplicate, + timelineId, + }: { + duplicate: boolean; + timelineId: string; + }) => { + if (isModal && closeModalTimeline != null) { + closeModalTimeline(); + } + + queryTimelineById({ + apolloClient, + duplicate, + timelineId, + updateIsLoading, + updateTimeline, + }); + }; + + const deleteTimelines: DeleteTimelines = ( + timelineIds: string[], + variables?: AllTimelinesVariables + ) => { + if (timelineIds.includes(timeline.savedObjectId || '')) { + createNewTimeline({ id: 'timeline-1', columns: defaultHeaders, show: false }); + } + apolloClient.mutate({ + mutation: deleteTimelineMutation, + fetchPolicy: 'no-cache', + variables: { id: timelineIds }, + refetchQueries: [ + { + query: allTimelinesQuery, + variables, + }, + ], + }); + }; + useEffect(() => { + focusInput(); + }, []); - public render() { - const { defaultPageSize, isModal = false, title } = this.props; - const { - itemIdToExpandedNotesRowMap, - onlyFavorites, - pageIndex, - pageSize, - search: query, - selectedItems, - sortDirection, - sortField, - } = this.state; return ( {({ timelines, loading, totalCount }) => { return !isModal ? ( ) : ( ); } - - /** Invoked when the user presses enters to submit the text in the search input */ - private onQueryChange: OnQueryChange = (query: EuiSearchBarQuery) => { - this.setState({ - search: query.queryText.trim(), - }); - }; - - /** Focuses the input that filters the field browser */ - private focusInput = () => { - const elements = document.querySelector(`.${OPEN_TIMELINE_CLASS_NAME} input`); - - if (elements != null) { - elements.focus(); - } - }; - - /* This feature will be implemented in the near future, so we are keeping it to know what to do */ - - /** Invoked when the user clicks the action to add the selected timelines to favorites */ - // private onAddTimelinesToFavorites: OnAddTimelinesToFavorites = () => { - // const { addTimelinesToFavorites } = this.props; - // const { selectedItems } = this.state; - // if (addTimelinesToFavorites != null) { - // addTimelinesToFavorites(getSelectedTimelineIds(selectedItems)); - // TODO: it's not possible to clear the selection state of the newly-favorited - // items, because we can't pass the selection state as props to the table. - // See: https://github.com/elastic/eui/issues/1077 - // TODO: the query must re-execute to show the results of the mutation - // } - // }; - - private onDeleteOneTimeline: OnDeleteOneTimeline = (timelineIds: string[]) => { - const { onlyFavorites, pageIndex, pageSize, search, sortDirection, sortField } = this.state; - - this.deleteTimelines(timelineIds, { - search, - pageInfo: { - pageIndex: pageIndex + 1, - pageSize, - }, - sort: { - sortField: sortField as SortFieldTimeline, - sortOrder: sortDirection as Direction, - }, - onlyUserFavorite: onlyFavorites, - }); - }; - - /** Invoked when the user clicks the action to delete the selected timelines */ - private onDeleteSelected: OnDeleteSelected = () => { - const { selectedItems, onlyFavorites } = this.state; - - this.deleteTimelines(getSelectedTimelineIds(selectedItems), { - search: this.state.search, - pageInfo: { - pageIndex: this.state.pageIndex + 1, - pageSize: this.state.pageSize, - }, - sort: { - sortField: this.state.sortField as SortFieldTimeline, - sortOrder: this.state.sortDirection as Direction, - }, - onlyUserFavorite: onlyFavorites, - }); - - // NOTE: we clear the selection state below, but if the server fails to - // delete a timeline, it will remain selected in the table: - this.resetSelectionState(); - - // TODO: the query must re-execute to show the results of the deletion - }; - - /** Invoked when the user selects (or de-selects) timelines */ - private onSelectionChange: OnSelectionChange = (selectedItems: OpenTimelineResult[]) => { - this.setState({ selectedItems }); // <-- this is NOT passed down as props to the table: https://github.com/elastic/eui/issues/1077 - }; - - /** Invoked by the EUI table implementation when the user interacts with the table (i.e. to update sorting) */ - private onTableChange: OnTableChange = ({ page, sort }: OnTableChangeParams) => { - const { index: pageIndex, size: pageSize } = page; - const { field: sortField, direction: sortDirection } = sort; - - this.setState({ - pageIndex, - pageSize, - sortDirection, - sortField, - }); - }; - - /** Invoked when the user toggles the option to only view favorite timelines */ - private onToggleOnlyFavorites: OnToggleOnlyFavorites = () => { - this.setState(state => ({ - onlyFavorites: !state.onlyFavorites, - })); - }; - - /** Invoked when the user toggles the expansion or collapse of inline notes in a table row */ - private onToggleShowNotes: OnToggleShowNotes = ( - itemIdToExpandedNotesRowMap: Record - ) => { - this.setState(() => ({ - itemIdToExpandedNotesRowMap, - })); - }; - - /** Resets the selection state such that all timelines are unselected */ - private resetSelectionState = () => { - this.setState({ - selectedItems: [], - }); - }; - - private openTimeline: OnOpenTimeline = ({ - duplicate, - timelineId, - }: { - duplicate: boolean; - timelineId: string; - }) => { - const { - apolloClient, - closeModalTimeline, - isModal, - updateTimeline, - updateIsLoading, - } = this.props; - - if (isModal && closeModalTimeline != null) { - closeModalTimeline(); - } - - queryTimelineById({ - apolloClient, - duplicate, - timelineId, - updateIsLoading, - updateTimeline, - }); - }; - - private deleteTimelines: DeleteTimelines = ( - timelineIds: string[], - variables?: AllTimelinesVariables - ) => { - if (timelineIds.includes(this.props.timeline.savedObjectId || '')) { - this.props.createNewTimeline({ id: 'timeline-1', columns: defaultHeaders, show: false }); - } - this.props.apolloClient.mutate< - DeleteTimelineMutation.Mutation, - DeleteTimelineMutation.Variables - >({ - mutation: deleteTimelineMutation, - fetchPolicy: 'no-cache', - variables: { id: timelineIds }, - refetchQueries: [ - { - query: allTimelinesQuery, - variables, - }, - ], - }); - }; -} +); const makeMapStateToProps = () => { const getTimeline = timelineSelectors.getTimelineByIdSelector(); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx index bcafed20a50ffd..146afa85e10a76 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx @@ -5,8 +5,7 @@ */ import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { get } from 'lodash/fp'; -import { mount, ReactWrapper } from 'enzyme'; +import { mount } from 'enzyme'; import * as React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; import { ThemeProvider } from 'styled-components'; @@ -20,12 +19,6 @@ import { OpenTimelineModalButton } from '.'; jest.mock('../../../lib/settings/use_kibana_ui_setting'); -const getStateChildComponent = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - wrapper: ReactWrapper, React.Component<{}, {}, any>> -): // eslint-disable-next-line @typescript-eslint/no-explicit-any -React.Component<{}, {}, any> => wrapper.find('[data-test-subj="state-child-component"]').instance(); - describe('OpenTimelineModalButton', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); @@ -56,10 +49,7 @@ describe('OpenTimelineModalButton', () => { - + @@ -69,7 +59,7 @@ describe('OpenTimelineModalButton', () => { wrapper.update(); - expect(get('showModal', getStateChildComponent(wrapper).state)).toEqual(false); + expect(wrapper.find('div[data-test-subj="open-timeline-modal"].euiModal').length).toEqual(0); }); test('it sets showModal to true when the button is clicked', async () => { @@ -151,10 +141,7 @@ describe('OpenTimelineModalButton', () => { - + @@ -169,7 +156,7 @@ describe('OpenTimelineModalButton', () => { wrapper.update(); - expect(get('showModal', getStateChildComponent(wrapper).state)).toEqual(true); + expect(wrapper.find('div[data-test-subj="open-timeline-modal"].euiModal').length).toEqual(1); }); test('it invokes the optional onToggle function provided as a prop when the open timeline button is clicked', async () => { diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx index 79fa747aee0817..41907e07d5c1bf 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx @@ -5,8 +5,7 @@ */ import { EuiButtonEmpty, EuiModal, EuiOverlayMask } from '@elastic/eui'; -import * as React from 'react'; -import styled from 'styled-components'; +import React, { useState } from 'react'; import { ApolloConsumer } from 'react-apollo'; import * as i18n from '../translations'; @@ -20,90 +19,61 @@ export interface OpenTimelineModalButtonProps { onToggle?: () => void; } -export interface OpenTimelineModalButtonState { - showModal: boolean; -} - const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; const OPEN_TIMELINE_MODAL_WIDTH = 1000; // px -// TODO: this container can be removed when -// the following EUI PR is available (in Kibana): -// https://github.com/elastic/eui/pull/1902/files#diff-d662c14c5dcd7e4b41028bf60b9bc77b -const ModalContainer = styled.div` - .euiModalBody { - display: flex; - flex-direction: column; - } -`; - -ModalContainer.displayName = 'ModalContainer'; - /** * Renders a button that when clicked, displays the `Open Timelines` modal */ -export class OpenTimelineModalButton extends React.PureComponent< - OpenTimelineModalButtonProps, - OpenTimelineModalButtonState -> { - constructor(props: OpenTimelineModalButtonProps) { - super(props); +export const OpenTimelineModalButton = React.memo(({ onToggle }) => { + const [showModal, setShowModal] = useState(false); - this.state = { showModal: false }; + /** shows or hides the `Open Timeline` modal */ + function toggleShowModal() { + if (onToggle != null) { + onToggle(); + } + setShowModal(!showModal); } - public render() { - return ( - - {client => ( - <> - - {i18n.OPEN_TIMELINE} - - - {this.state.showModal && ( - - - - - - - - )} - - )} - - ); + function closeModalTimeline() { + toggleShowModal(); } + return ( + + {client => ( + <> + + {i18n.OPEN_TIMELINE} + - /** shows or hides the `Open Timeline` modal */ - private toggleShowModal = () => { - if (this.props.onToggle != null) { - this.props.onToggle(); - } - - this.setState(state => ({ - showModal: !state.showModal, - })); - }; + {showModal && ( + + + + + + )} + + )} + + ); +}); - private closeModalTimeline = () => { - this.toggleShowModal(); - }; -} +OpenTimelineModalButton.displayName = 'OpenTimelineModalButton'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx index 693dcf7516bc45..5c0b449916a1f9 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx @@ -7,7 +7,6 @@ import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React from 'react'; -import { pure } from 'recompose'; import styled from 'styled-components'; import { StaticIndexPattern } from 'ui/index_patterns'; @@ -25,15 +24,21 @@ interface Props { filterQueryDraft: KueryFilterQuery; } -class AddToKqlComponent extends React.PureComponent { - public render() { - const { children } = this.props; +const AddToKqlComponent = React.memo( + ({ children, expression, filterQueryDraft, applyFilterQueryFromKueryExpression }) => { + const addToKql = () => { + applyFilterQueryFromKueryExpression( + filterQueryDraft && !isEmpty(filterQueryDraft.expression) + ? `${filterQueryDraft.expression} and ${expression}` + : expression + ); + }; return ( - + } @@ -41,16 +46,9 @@ class AddToKqlComponent extends React.PureComponent { /> ); } +); - private addToKql = () => { - const { expression, filterQueryDraft, applyFilterQueryFromKueryExpression } = this.props; - applyFilterQueryFromKueryExpression( - filterQueryDraft && !isEmpty(filterQueryDraft.expression) - ? `${filterQueryDraft.expression} and ${expression}` - : expression - ); - }; -} +AddToKqlComponent.displayName = 'AddToKqlComponent'; const HoverActionsContainer = styled(EuiPanel)` align-items: center; @@ -75,7 +73,7 @@ interface AddToKqlProps { type: networkModel.NetworkType | hostsModel.HostsType; } -export const AddToKql = pure( +export const AddToKql = React.memo( ({ children, expression, type, componentFilterType, indexPattern }) => { switch (componentFilterType) { case 'hosts': diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx index 65c8e9a6546866..d4b3b5e8759892 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import memoizeOne from 'memoize-one'; -import React from 'react'; +import React, { useMemo } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; import { StaticIndexPattern } from 'ui/index_patterns'; @@ -38,8 +37,8 @@ interface OwnProps { data: HostsEdges[]; fakeTotalCount: number; id: string; - isInspect: boolean; indexPattern: StaticIndexPattern; + isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; showMorePagesIndicator: boolean; @@ -49,15 +48,15 @@ interface OwnProps { interface HostsTableReduxProps { activePage: number; + direction: Direction; limit: number; sortField: HostsFields; - direction: Direction; } interface HostsTableDispatchProps { updateHostsSort: ActionCreator<{ - sort: HostsSortField; hostsType: hostsModel.HostsType; + sort: HostsSortField; }>; updateTableActivePage: ActionCreator<{ activePage: number; @@ -65,8 +64,8 @@ interface HostsTableDispatchProps { tableType: hostsModel.HostsTableType; }>; updateTableLimit: ActionCreator<{ - limit: number; hostsType: hostsModel.HostsType; + limit: number; tableType: hostsModel.HostsTableType; }>; } @@ -90,47 +89,58 @@ const rowItems: ItemsPerRow[] = [ numberOfRow: 10, }, ]; +const getSorting = ( + trigger: string, + sortField: HostsFields, + direction: Direction +): SortingBasicTable => ({ field: getNodeField(sortField), direction }); + +const HostsTableComponent = React.memo( + ({ + activePage, + data, + direction, + fakeTotalCount, + id, + indexPattern, + isInspect, + limit, + loading, + loadPage, + showMorePagesIndicator, + sortField, + totalCount, + type, + updateHostsSort, + updateTableActivePage, + updateTableLimit, + }) => { + const onChange = (criteria: Criteria) => { + if (criteria.sort != null) { + const sort: HostsSortField = { + field: getSortField(criteria.sort.field), + direction: criteria.sort.direction, + }; + if (sort.direction !== direction || sort.field !== sortField) { + updateHostsSort({ + sort, + hostsType: type, + }); + } + } + }; -class HostsTableComponent extends React.PureComponent { - private memoizedColumns: ( - type: hostsModel.HostsType, - indexPattern: StaticIndexPattern - ) => HostsTableColumns; - private memoizedSorting: ( - trigger: string, - sortField: HostsFields, - direction: Direction - ) => SortingBasicTable; - - constructor(props: HostsTableProps) { - super(props); - this.memoizedColumns = memoizeOne(this.getMemoizeHostsColumns); - this.memoizedSorting = memoizeOne(this.getSorting); - } + const hostsColumns = useMemo(() => getHostsColumns(type, indexPattern), [type, indexPattern]); - public render() { - const { - activePage, - data, - direction, - fakeTotalCount, - id, - isInspect, - indexPattern, - limit, - loading, - loadPage, - showMorePagesIndicator, - totalCount, + const sorting = useMemo(() => getSorting(`${sortField}-${direction}`, sortField, direction), [ sortField, - type, - updateTableActivePage, - updateTableLimit, - } = this.props; + direction, + ]); + return ( { limit={limit} loading={loading} loadPage={newActivePage => loadPage(newActivePage)} - onChange={this.onChange} + onChange={onChange} pageOfItems={data} showMorePagesIndicator={showMorePagesIndicator} - sorting={this.memoizedSorting(`${sortField}-${direction}`, sortField, direction)} + sorting={sorting} totalCount={fakeTotalCount} updateLimitPagination={newLimit => updateTableLimit({ @@ -163,33 +173,9 @@ class HostsTableComponent extends React.PureComponent { /> ); } +); - private getSorting = ( - trigger: string, - sortField: HostsFields, - direction: Direction - ): SortingBasicTable => ({ field: getNodeField(sortField), direction }); - - private getMemoizeHostsColumns = ( - type: hostsModel.HostsType, - indexPattern: StaticIndexPattern - ): HostsTableColumns => getHostsColumns(type, indexPattern); - - private onChange = (criteria: Criteria) => { - if (criteria.sort != null) { - const sort: HostsSortField = { - field: getSortField(criteria.sort.field), - direction: criteria.sort.direction, - }; - if (sort.direction !== this.props.direction || sort.field !== this.props.sortField) { - this.props.updateHostsSort({ - sort, - hostsType: this.props.type, - }); - } - } - }; -} +HostsTableComponent.displayName = 'HostsTableComponent'; const getSortField = (field: string): HostsFields => { switch (field) { diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx index 24820b637d388e..cf5da3fbebba64 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx @@ -25,7 +25,7 @@ import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_ import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { defaultToEmptyTag, getEmptyTagValue } from '../../../empty_value'; import { PreferenceFormattedDate } from '../../../formatted_date'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { LocalizedDateTooltip } from '../../../localized_date_tooltip'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; import { PreferenceFormattedBytes } from '../../../formatted_bytes'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx index 1fdea3f2b03322..353699c5158bc4 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx @@ -12,7 +12,7 @@ import { networkModel } from '../../../../store'; import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_wrapper'; import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { defaultToEmptyTag, getEmptyTagValue } from '../../../empty_value'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; import { PreferenceFormattedBytes } from '../../../formatted_bytes'; import { Provider } from '../../../timeline/data_providers/provider'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx index 38eda9810740c2..97fa601a49af17 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx @@ -21,7 +21,7 @@ import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_ import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { getEmptyTagValue } from '../../../empty_value'; import { IPDetailsLink } from '../../../links'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; import { Provider } from '../../../timeline/data_providers/provider'; import * as i18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx index 5abbdab9c980fe..714d3f7a8131c6 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx @@ -79,26 +79,45 @@ const rowItems: ItemsPerRow[] = [ export const NetworkTopNFlowTableId = 'networkTopSourceFlow-top-talkers'; -class NetworkTopNFlowTableComponent extends React.PureComponent { - public render() { - const { - activePage, - data, - fakeTotalCount, - flowTargeted, - id, - indexPattern, - isInspect, - limit, - loading, - loadPage, - showMorePagesIndicator, - topNFlowSort, - totalCount, - type, - updateTopNFlowLimit, - updateTableActivePage, - } = this.props; +const NetworkTopNFlowTableComponent = React.memo( + ({ + activePage, + data, + fakeTotalCount, + flowTargeted, + id, + indexPattern, + isInspect, + limit, + loading, + loadPage, + showMorePagesIndicator, + topNFlowSort, + totalCount, + type, + updateTopNFlowLimit, + updateTopNFlowSort, + updateTableActivePage, + }) => { + const onChange = (criteria: Criteria, tableType: networkModel.TopNTableType) => { + if (criteria.sort != null) { + const splitField = criteria.sort.field.split('.'); + const field = last(splitField); + const newSortDirection = + field !== topNFlowSort.field ? Direction.desc : criteria.sort.direction; // sort by desc on init click + const newTopNFlowSort: NetworkTopNFlowSortField = { + field: field as NetworkTopNFlowFields, + direction: newSortDirection, + }; + if (!isEqual(newTopNFlowSort, topNFlowSort)) { + updateTopNFlowSort({ + topNFlowSort: newTopNFlowSort, + networkType: type, + tableType, + }); + } + } + }; let tableType: networkModel.TopNTableType; let headerTitle: string; @@ -136,7 +155,7 @@ class NetworkTopNFlowTableComponent extends React.PureComponent loadPage(newActivePage)} - onChange={criteria => this.onChange(criteria, tableType)} + onChange={criteria => onChange(criteria, tableType)} pageOfItems={data} showMorePagesIndicator={showMorePagesIndicator} sorting={{ field, direction: topNFlowSort.direction }} @@ -153,27 +172,9 @@ class NetworkTopNFlowTableComponent extends React.PureComponent ); } +); - private onChange = (criteria: Criteria, tableType: networkModel.TopNTableType) => { - if (criteria.sort != null) { - const splitField = criteria.sort.field.split('.'); - const field = last(splitField); - const newSortDirection = - field !== this.props.topNFlowSort.field ? Direction.desc : criteria.sort.direction; // sort by desc on init click - const newTopNFlowSort: NetworkTopNFlowSortField = { - field: field as NetworkTopNFlowFields, - direction: newSortDirection, - }; - if (!isEqual(newTopNFlowSort, this.props.topNFlowSort)) { - this.props.updateTopNFlowSort({ - topNFlowSort: newTopNFlowSort, - networkType: this.props.type, - tableType, - }); - } - } - }; -} +NetworkTopNFlowTableComponent.displayName = 'NetworkTopNFlowTableComponent'; const mapStateToProps = (state: State, ownProps: OwnProps) => networkSelectors.topNFlowSelector(ownProps.flowTargeted); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx index 7578c5decc8519..aea8ee9e6b9e14 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx @@ -7,7 +7,7 @@ import React from 'react'; import moment from 'moment'; import { TlsNode } from '../../../../graphql/types'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { getRowItemDraggables, getRowItemDraggable } from '../../../tables/helpers'; import { LocalizedDateTooltip } from '../../../localized_date_tooltip'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx index b17ec74fa05401..2c51fb8f94561a 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx @@ -6,7 +6,7 @@ import { FlowTarget, UsersItem } from '../../../../graphql/types'; import { defaultToEmptyTag } from '../../../empty_value'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import * as i18n from './translations'; import { getRowItemDraggables, getRowItemDraggable } from '../../../tables/helpers'; diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx index b5678a36c1eedf..257ee03c944bfb 100644 --- a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx @@ -100,15 +100,17 @@ export interface BasicTableProps { updateActivePage: (activePage: number) => void; updateLimitPagination: (limit: number) => void; } +type Func = (arg: T) => string | number; -export interface Columns { +export interface Columns { + align?: string; field?: string; - name: string | React.ReactNode; + hideForMobile?: boolean; isMobileHeader?: boolean; - sortable?: boolean; + name: string | React.ReactNode; + render?: (item: T, node: U) => React.ReactNode; + sortable?: boolean | Func; truncateText?: boolean; - hideForMobile?: boolean; - render?: (item: T) => void; width?: string; } diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx b/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx index 40df2c134047f2..0a6203056fd20f 100644 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; +import React, { useEffect, useRef } from 'react'; import { fromEvent, Observable, Subscription } from 'rxjs'; import { concatMap, takeUntil } from 'rxjs/operators'; import styled, { css } from 'styled-components'; @@ -41,10 +41,6 @@ interface Props extends ResizeHandleContainerProps { render: (isResizing: boolean) => React.ReactNode; } -interface State { - isResizing: boolean; -} - const ResizeHandleContainer = styled.div` ${({ bottom, height, left, positionAbsolute, right, theme, top }) => css` bottom: ${positionAbsolute && bottom}; @@ -67,88 +63,75 @@ export const removeGlobalResizeCursorStyleFromBody = () => { document.body.classList.remove(globalResizeCursorClassName); }; -export class Resizeable extends React.PureComponent { - private drag$: Observable | null; - private dragEventTargets: Array<{ htmlElement: HTMLElement; prevCursor: string }>; - private dragSubscription: Subscription | null; - private prevX: number = 0; - private ref: React.RefObject; - private upSubscription: Subscription | null; - - constructor(props: Props) { - super(props); - - // NOTE: the ref and observable below are NOT stored in component `State` - this.ref = React.createRef(); - this.drag$ = null; - this.dragSubscription = null; - this.upSubscription = null; - this.dragEventTargets = []; - - this.state = { - isResizing: false, +export const Resizeable = React.memo( + ({ bottom, handle, height, id, left, onResize, positionAbsolute, render, right, top }) => { + const drag$ = useRef | null>(null); + const dragEventTargets = useRef>([]); + const dragSubscription = useRef(null); + const prevX = useRef(0); + const ref = useRef>(React.createRef()); + const upSubscription = useRef(null); + const isResizingRef = useRef(false); + + const calculateDelta = (e: MouseEvent) => { + const deltaX = calculateDeltaX({ prevX: prevX.current, screenX: e.screenX }); + prevX.current = e.screenX; + return deltaX; }; - } - - public componentDidMount() { - const { id, onResize } = this.props; - - const move$ = fromEvent(document, 'mousemove'); - const down$ = fromEvent(this.ref.current!, 'mousedown'); - const up$ = fromEvent(document, 'mouseup'); - - this.drag$ = down$.pipe(concatMap(() => move$.pipe(takeUntil(up$)))); - this.dragSubscription = this.drag$.subscribe(event => { - // We do a feature detection of event.movementX here and if it is missing - // we calculate the delta manually. Browsers IE-11 and Safari will call calculateDelta - const delta = - event.movementX == null || isSafari ? this.calculateDelta(event) : event.movementX; - if (!this.state.isResizing) { - this.setState({ isResizing: true }); - } - onResize({ id, delta }); - if (event.target != null && event.target instanceof HTMLElement) { - const htmlElement: HTMLElement = event.target; - this.dragEventTargets = [ - ...this.dragEventTargets, - { htmlElement, prevCursor: htmlElement.style.cursor }, - ]; - htmlElement.style.cursor = resizeCursorStyle; - } - }); - - this.upSubscription = up$.subscribe(() => { - if (this.state.isResizing) { - this.dragEventTargets.reverse().forEach(eventTarget => { - eventTarget.htmlElement.style.cursor = eventTarget.prevCursor; + useEffect(() => { + const move$ = fromEvent(document, 'mousemove'); + const down$ = fromEvent(ref.current.current!, 'mousedown'); + const up$ = fromEvent(document, 'mouseup'); + + drag$.current = down$.pipe(concatMap(() => move$.pipe(takeUntil(up$)))); + dragSubscription.current = + drag$.current && + drag$.current.subscribe(event => { + // We do a feature detection of event.movementX here and if it is missing + // we calculate the delta manually. Browsers IE-11 and Safari will call calculateDelta + const delta = + event.movementX == null || isSafari ? calculateDelta(event) : event.movementX; + if (!isResizingRef.current) { + isResizingRef.current = true; + } + onResize({ id, delta }); + if (event.target != null && event.target instanceof HTMLElement) { + const htmlElement: HTMLElement = event.target; + dragEventTargets.current = [ + ...dragEventTargets.current, + { htmlElement, prevCursor: htmlElement.style.cursor }, + ]; + htmlElement.style.cursor = resizeCursorStyle; + } }); - this.dragEventTargets = []; - this.setState({ isResizing: false }); - } - }); - } - public componentWillUnmount() { - if (this.dragSubscription != null) { - this.dragSubscription.unsubscribe(); - } - - if (this.upSubscription != null) { - this.upSubscription.unsubscribe(); - } - } - - public render() { - const { bottom, handle, height, left, positionAbsolute, render, right, top } = this.props; + upSubscription.current = up$.subscribe(() => { + if (isResizingRef.current) { + dragEventTargets.current.reverse().forEach(eventTarget => { + eventTarget.htmlElement.style.cursor = eventTarget.prevCursor; + }); + dragEventTargets.current = []; + isResizingRef.current = false; + } + }); + return () => { + if (dragSubscription.current != null) { + dragSubscription.current.unsubscribe(); + } + if (upSubscription.current != null) { + upSubscription.current.unsubscribe(); + } + }; + }, []); return ( <> - {render(this.state.isResizing)} + {render(isResizingRef.current)} { ); } +); - private calculateDelta = (e: MouseEvent) => { - const deltaX = calculateDeltaX({ prevX: this.prevX, screenX: e.screenX }); - - this.prevX = e.screenX; - - return deltaX; - }; -} +Resizeable.displayName = 'Resizeable'; diff --git a/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx b/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx index 722cf9db731f72..fa695d76f9f3e5 100644 --- a/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx @@ -7,13 +7,13 @@ import dateMath from '@elastic/datemath'; import { EuiSuperDatePicker, - EuiSuperDatePickerProps, OnRefreshChangeProps, + EuiSuperDatePickerRecentRange, OnRefreshProps, OnTimeChangeProps, } from '@elastic/eui'; import { getOr, take } from 'lodash/fp'; -import React, { Component } from 'react'; +import React, { useState } from 'react'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; @@ -36,33 +36,17 @@ import { InputsRange, Policy } from '../../store/inputs/model'; const MAX_RECENTLY_USED_RANGES = 9; -type MyEuiSuperDatePickerProps = Pick< - EuiSuperDatePickerProps, - | 'end' - | 'isPaused' - | 'onTimeChange' - | 'onRefreshChange' - | 'onRefresh' - | 'recentlyUsedRanges' - | 'refreshInterval' - | 'showUpdateButton' - | 'start' -> & { - isLoading?: boolean; -}; -const MyEuiSuperDatePicker: React.SFC = EuiSuperDatePicker; - interface SuperDatePickerStateRedux { duration: number; - policy: Policy['kind']; - kind: string; - fromStr: string; - toStr: string; - start: number; end: number; + fromStr: string; isLoading: boolean; - queries: inputsModel.GlobalGraphqlQuery[]; + kind: string; kqlQuery: inputsModel.GlobalKqlQuery; + policy: Policy['kind']; + queries: inputsModel.GlobalGraphqlQuery[]; + start: number; + toStr: string; } interface UpdateReduxTime extends OnTimeChangeProps { @@ -85,145 +69,137 @@ type DispatchUpdateReduxTime = ({ }: UpdateReduxTime) => ReturnUpdateReduxTime; interface SuperDatePickerDispatchProps { + setDuration: ({ id, duration }: { id: InputsModelId; duration: number }) => void; startAutoReload: ({ id }: { id: InputsModelId }) => void; stopAutoReload: ({ id }: { id: InputsModelId }) => void; - setDuration: ({ id, duration }: { id: InputsModelId; duration: number }) => void; updateReduxTime: DispatchUpdateReduxTime; } interface OwnProps { - id: InputsModelId; disabled?: boolean; + id: InputsModelId; timelineId?: string; } -interface TimeArgs { - start: string; - end: string; -} - export type SuperDatePickerProps = OwnProps & SuperDatePickerDispatchProps & SuperDatePickerStateRedux; -export interface SuperDatePickerState { - isQuickSelection: boolean; - recentlyUsedRanges: TimeArgs[]; - showUpdateButton: boolean; -} +export const SuperDatePickerComponent = React.memo( + ({ + duration, + end, + fromStr, + id, + isLoading, + kind, + kqlQuery, + policy, + queries, + setDuration, + start, + startAutoReload, + stopAutoReload, + timelineId, + toStr, + updateReduxTime, + }) => { + const [isQuickSelection, setIsQuickSelection] = useState(true); + const [recentlyUsedRanges, setRecentlyUsedRanges] = useState( + [] + ); + const onRefresh = ({ start: newStart, end: newEnd }: OnRefreshProps): void => { + const { kqlHasBeenUpdated } = updateReduxTime({ + end: newEnd, + id, + isInvalid: false, + isQuickSelection, + kql: kqlQuery, + start: newStart, + timelineId, + }); + const currentStart = formatDate(newStart); + const currentEnd = isQuickSelection + ? formatDate(newEnd, { roundUp: true }) + : formatDate(newEnd); + if ( + !kqlHasBeenUpdated && + (!isQuickSelection || (start === currentStart && end === currentEnd)) + ) { + refetchQuery(queries); + } + }; + + const onRefreshChange = ({ isPaused, refreshInterval }: OnRefreshChangeProps): void => { + if (duration !== refreshInterval) { + setDuration({ id, duration: refreshInterval }); + } -export const SuperDatePickerComponent = class extends Component< - SuperDatePickerProps, - SuperDatePickerState -> { - constructor(props: SuperDatePickerProps) { - super(props); + if (isPaused && policy === 'interval') { + stopAutoReload({ id }); + } else if (!isPaused && policy === 'manual') { + startAutoReload({ id }); + } - this.state = { - isQuickSelection: true, - recentlyUsedRanges: [], - showUpdateButton: true, + if (!isPaused && (!isQuickSelection || (isQuickSelection && toStr !== 'now'))) { + refetchQuery(queries); + } + }; + + const refetchQuery = (newQueries: inputsModel.GlobalGraphqlQuery[]) => { + newQueries.forEach(q => q.refetch && (q.refetch as inputsModel.Refetch)()); }; - } - public render() { - const { duration, end, start, kind, fromStr, policy, toStr, isLoading } = this.props; + const onTimeChange = ({ + start: newStart, + end: newEnd, + isQuickSelection: newIsQuickSelection, + isInvalid, + }: OnTimeChangeProps) => { + if (!isInvalid) { + updateReduxTime({ + end: newEnd, + id, + isInvalid, + isQuickSelection: newIsQuickSelection, + kql: kqlQuery, + start: newStart, + timelineId, + }); + const newRecentlyUsedRanges = [ + { start: newStart, end: newEnd }, + ...take( + MAX_RECENTLY_USED_RANGES, + recentlyUsedRanges.filter( + recentlyUsedRange => + !(recentlyUsedRange.start === newStart && recentlyUsedRange.end === newEnd) + ) + ), + ]; + + setRecentlyUsedRanges(newRecentlyUsedRanges); + setIsQuickSelection(newIsQuickSelection); + } + }; const endDate = kind === 'relative' ? toStr : new Date(end).toISOString(); const startDate = kind === 'relative' ? fromStr : new Date(start).toISOString(); return ( - ); } - private onRefresh = ({ start, end }: OnRefreshProps): void => { - const { kqlHasBeenUpdated } = this.props.updateReduxTime({ - end, - id: this.props.id, - isInvalid: false, - isQuickSelection: this.state.isQuickSelection, - kql: this.props.kqlQuery, - start, - timelineId: this.props.timelineId, - }); - const currentStart = formatDate(start); - const currentEnd = this.state.isQuickSelection - ? formatDate(end, { roundUp: true }) - : formatDate(end); - if ( - !kqlHasBeenUpdated && - (!this.state.isQuickSelection || - (this.props.start === currentStart && this.props.end === currentEnd)) - ) { - this.refetchQuery(this.props.queries); - } - }; - - private onRefreshChange = ({ isPaused, refreshInterval }: OnRefreshChangeProps): void => { - const { id, duration, policy, stopAutoReload, startAutoReload } = this.props; - if (duration !== refreshInterval) { - this.props.setDuration({ id, duration: refreshInterval }); - } - - if (isPaused && policy === 'interval') { - stopAutoReload({ id }); - } else if (!isPaused && policy === 'manual') { - startAutoReload({ id }); - } - - if ( - !isPaused && - (!this.state.isQuickSelection || (this.state.isQuickSelection && this.props.toStr !== 'now')) - ) { - this.refetchQuery(this.props.queries); - } - }; - - private refetchQuery = (queries: inputsModel.GlobalGraphqlQuery[]) => { - queries.forEach(q => q.refetch && (q.refetch as inputsModel.Refetch)()); - }; - - private onTimeChange = ({ start, end, isQuickSelection, isInvalid }: OnTimeChangeProps) => { - if (!isInvalid) { - this.props.updateReduxTime({ - end, - id: this.props.id, - isInvalid, - isQuickSelection, - kql: this.props.kqlQuery, - start, - timelineId: this.props.timelineId, - }); - this.setState((prevState: SuperDatePickerState) => { - const recentlyUsedRanges = [ - { start, end }, - ...take( - MAX_RECENTLY_USED_RANGES, - prevState.recentlyUsedRanges.filter( - recentlyUsedRange => - !(recentlyUsedRange.start === start && recentlyUsedRange.end === end) - ) - ), - ]; - - return { - recentlyUsedRanges, - isQuickSelection, - }; - }); - } - }; -}; +); const formatDate = ( date: string, @@ -292,33 +268,35 @@ const dispatchUpdateReduxTime = (dispatch: Dispatch) => ({ }; export const makeMapStateToProps = () => { - const getPolicySelector = policySelector(); const getDurationSelector = durationSelector(); - const getKindSelector = kindSelector(); - const getStartSelector = startSelector(); const getEndSelector = endSelector(); const getFromStrSelector = fromStrSelector(); - const getToStrSelector = toStrSelector(); const getIsLoadingSelector = isLoadingSelector(); - const getQueriesSelector = queriesSelector(); + const getKindSelector = kindSelector(); const getKqlQuerySelector = kqlQuerySelector(); + const getPolicySelector = policySelector(); + const getQueriesSelector = queriesSelector(); + const getStartSelector = startSelector(); + const getToStrSelector = toStrSelector(); return (state: State, { id }: OwnProps) => { const inputsRange: InputsRange = getOr({}, `inputs.${id}`, state); return { - policy: getPolicySelector(inputsRange), duration: getDurationSelector(inputsRange), - kind: getKindSelector(inputsRange), - start: getStartSelector(inputsRange), end: getEndSelector(inputsRange), fromStr: getFromStrSelector(inputsRange), - toStr: getToStrSelector(inputsRange), isLoading: getIsLoadingSelector(inputsRange), - queries: getQueriesSelector(inputsRange), + kind: getKindSelector(inputsRange), kqlQuery: getKqlQuerySelector(inputsRange), + policy: getPolicySelector(inputsRange), + queries: getQueriesSelector(inputsRange), + start: getStartSelector(inputsRange), + toStr: getToStrSelector(inputsRange), }; }; }; +SuperDatePickerComponent.displayName = 'SuperDatePickerComponent'; + const mapDispatchToProps = (dispatch: Dispatch) => ({ startAutoReload: ({ id }: { id: InputsModelId }) => dispatch(inputsActions.startAutoReload({ id })), diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx index 62f58e1b585d9e..1e603b0c157793 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx @@ -91,53 +91,56 @@ interface Props { } /** Renders a header */ -export class Header extends React.PureComponent { - public render() { - const { header } = this.props; +export const Header = React.memo( + ({ + header, + onColumnRemoved, + onColumnResized, + onColumnSorted, + onFilterChange = noop, + setIsResizing, + sort, + }) => { + const onClick = () => { + onColumnSorted!({ + columnId: header.id, + sortDirection: getNewSortDirectionOnClick({ + clickedHeader: header, + currentSort: sort, + }), + }); + }; + + const onResize: OnResize = ({ delta, id }) => { + onColumnResized({ columnId: id, delta }); + }; + + const renderActions = (isResizing: boolean) => { + setIsResizing(isResizing); + return ( + <> + + + + + + + ); + }; return ( } id={header.id} - onResize={this.onResize} + onResize={onResize} positionAbsolute - render={this.renderActions} + render={renderActions} right="-1px" top={0} /> ); } +); - private renderActions = (isResizing: boolean) => { - const { header, onColumnRemoved, onFilterChange = noop, setIsResizing, sort } = this.props; - - setIsResizing(isResizing); - - return ( - <> - - - - - - - ); - }; - - private onClick = () => { - const { header, onColumnSorted, sort } = this.props; - - onColumnSorted!({ - columnId: header.id, - sortDirection: getNewSortDirectionOnClick({ - clickedHeader: header, - currentSort: sort, - }), - }); - }; - - private onResize: OnResize = ({ delta, id }) => { - this.props.onColumnResized({ columnId: id, delta }); - }; -} +Header.displayName = 'Header'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx index 44d0480bc5f28e..2b2401519eb322 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx @@ -22,10 +22,8 @@ interface Props { timelineId: string; } -export class DataDrivenColumns extends React.PureComponent { - public render() { - const { _id, columnHeaders, columnRenderers, data, timelineId } = this.props; - +export const DataDrivenColumns = React.memo( + ({ _id, columnHeaders, columnRenderers, data, timelineId }) => { // Passing the styles directly to the component because the width is // being calculated and is recommended by Styled Components for performance // https://github.com/styled-components/styled-components/issues/134#issuecomment-312415291 @@ -51,7 +49,9 @@ export class DataDrivenColumns extends React.PureComponent { ); } -} +); + +DataDrivenColumns.displayName = 'DataDrivenColumns'; const getMappedNonEcsValue = ({ data, diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx index 1e3f7303c2e1d6..766a75c05f17c6 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; -import VisibilitySensor from 'react-visibility-sensor'; +import React, { useEffect, useRef, useState } from 'react'; import uuid from 'uuid'; +import VisibilitySensor from 'react-visibility-sensor'; import { BrowserFields } from '../../../../containers/source'; import { TimelineDetailsComponentQuery } from '../../../../containers/timeline/details'; @@ -35,24 +35,18 @@ interface Props { columnRenderers: ColumnRenderer[]; event: TimelineItem; eventIdToNoteIds: Readonly>; - isEventViewer?: boolean; getNotesByIds: (noteIds: string[]) => Note[]; + isEventViewer?: boolean; + maxDelay?: number; onColumnResized: OnColumnResized; onPinEvent: OnPinEvent; - onUpdateColumns: OnUpdateColumns; onUnPinEvent: OnUnPinEvent; + onUpdateColumns: OnUpdateColumns; pinnedEventIds: Readonly>; rowRenderers: RowRenderer[]; timelineId: string; toggleColumn: (column: ColumnHeader) => void; updateNote: UpdateNote; - maxDelay?: number; -} - -interface State { - expanded: { [eventId: string]: boolean }; - showNotes: { [eventId: string]: boolean }; - initialRender: boolean; } export const getNewNoteId = (): string => uuid.v4(); @@ -105,69 +99,86 @@ const Attributes = React.memo(({ children }) => { ); }); -export class StatefulEvent extends React.Component { - private _isMounted: boolean = false; +export const StatefulEvent = React.memo( + ({ + actionsColumnWidth, + addNoteToEvent, + browserFields, + columnHeaders, + columnRenderers, + event, + eventIdToNoteIds, + getNotesByIds, + isEventViewer = false, + maxDelay = 0, + onColumnResized, + onPinEvent, + onUnPinEvent, + onUpdateColumns, + pinnedEventIds, + rowRenderers, + timelineId, + toggleColumn, + updateNote, + }) => { + const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({}); + const [initialRender, setInitialRender] = useState(false); + const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); - public readonly state: State = { - expanded: {}, - showNotes: {}, - initialRender: false, - }; + const divElement = useRef(null); - public divElement: HTMLDivElement | null = null; + const onToggleShowNotes = (eventId: string): (() => void) => () => { + setShowNotes({ ...showNotes, [eventId]: !showNotes[eventId] }); + }; - /** - * Incrementally loads the events when it mounts by trying to - * see if it resides within a window frame and if it is it will - * indicate to React that it should render its self by setting - * its initialRender to true. - */ - public componentDidMount() { - this._isMounted = true; + const onToggleExpanded = (eventId: string): (() => void) => () => { + setExpanded({ + ...expanded, + [eventId]: !expanded[eventId], + }); + }; - requestIdleCallbackViaScheduler( - () => { - if (!this.state.initialRender && this._isMounted) { - this.setState({ initialRender: true }); - } - }, - { timeout: this.props.maxDelay ? this.props.maxDelay : 0 } - ); - } + const associateNote = ( + eventId: string, + addNoteToEventChild: AddNoteToEvent, + onPinEventChild: OnPinEvent + ): ((noteId: string) => void) => (noteId: string) => { + addNoteToEventChild({ eventId, noteId }); + if (!eventIsPinned({ eventId, pinnedEventIds })) { + onPinEventChild(eventId); // pin the event, because it has notes + } + }; - componentWillUnmount() { - this._isMounted = false; - } + /** + * Incrementally loads the events when it mounts by trying to + * see if it resides within a window frame and if it is it will + * indicate to React that it should render its self by setting + * its initialRender to true. + */ - public render() { - const { - actionsColumnWidth, - addNoteToEvent, - browserFields, - columnHeaders, - columnRenderers, - event, - eventIdToNoteIds, - getNotesByIds, - isEventViewer = false, - onColumnResized, - onPinEvent, - onUpdateColumns, - onUnPinEvent, - pinnedEventIds, - rowRenderers, - timelineId, - toggleColumn, - updateNote, - } = this.props; + useEffect(() => { + let _isMounted = true; + + requestIdleCallbackViaScheduler( + () => { + if (!initialRender && _isMounted) { + setInitialRender(true); + } + }, + { timeout: maxDelay } + ); + return () => { + _isMounted = false; + }; + }, []); // Number of current columns plus one for actions. const columnCount = columnHeaders.length + 1; // If we are not ready to render yet, just return null - // see componentDidMount() for when it schedules the first + // see useEffect() for when it schedules the first // time this stateful component should be rendered. - if (!this.state.initialRender) { + if (!initialRender) { return ; } @@ -184,7 +195,7 @@ export class StatefulEvent extends React.Component { sourceId="default" indexName={event._index!} eventId={event._id} - executeQuery={!!this.state.expanded[event._id]} + executeQuery={!!expanded[event._id]} > {({ detailsData, loading }) => ( { data-test-subj="event" innerRef={c => { if (c != null) { - this.divElement = c; + divElement.current = c; } }} > @@ -201,26 +212,26 @@ export class StatefulEvent extends React.Component { data: event.ecs, children: ( ), @@ -231,9 +242,9 @@ export class StatefulEvent extends React.Component { { } else { // Height place holder for visibility detection as well as re-rendering sections. const height = - this.divElement != null ? this.divElement.clientHeight + 'px' : DEFAULT_ROW_HEIGHT; + divElement.current != null + ? `${divElement.current.clientHeight}px` + : DEFAULT_ROW_HEIGHT; // height is being inlined directly in here because of performance with StyledComponents // involving quick and constant changes to the DOM. @@ -257,33 +270,6 @@ export class StatefulEvent extends React.Component { ); } +); - private onToggleShowNotes = (eventId: string): (() => void) => () => { - this.setState(state => ({ - showNotes: { - ...state.showNotes, - [eventId]: !state.showNotes[eventId], - }, - })); - }; - - private onToggleExpanded = (eventId: string): (() => void) => () => { - this.setState(state => ({ - expanded: { - ...state.expanded, - [eventId]: !state.expanded[eventId], - }, - })); - }; - - private associateNote = ( - eventId: string, - addNoteToEvent: AddNoteToEvent, - onPinEvent: OnPinEvent - ): ((noteId: string) => void) => (noteId: string) => { - addNoteToEvent({ eventId, noteId }); - if (!eventIsPinned({ eventId, pinnedEventIds: this.props.pinnedEventIds })) { - onPinEvent(eventId); // pin the event, because it has notes - } - }; -} +StatefulEvent.displayName = 'StatefulEvent'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx index 871a60d18404ad..d93446b2af95b3 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx @@ -84,8 +84,10 @@ type StatefulBodyComponentProps = OwnProps & ReduxProps & DispatchProps; export const emptyColumnHeaders: ColumnHeader[] = []; -class StatefulBodyComponent extends React.Component { - public shouldComponentUpdate({ +const StatefulBodyComponent = React.memo( + ({ + addNoteToEvent, + applyDeltaToColumnWidth, browserFields, columnHeaders, data, @@ -93,45 +95,46 @@ class StatefulBodyComponent extends React.Component getNotesByIds, height, id, - isEventViewer, + isEventViewer = false, + pinEvent, pinnedEventIds, range, + removeColumn, sort, - }: StatefulBodyComponentProps) { - return ( - browserFields !== this.props.browserFields || - columnHeaders !== this.props.columnHeaders || - data !== this.props.data || - eventIdToNoteIds !== this.props.eventIdToNoteIds || - getNotesByIds !== this.props.getNotesByIds || - height !== this.props.height || - id !== this.props.id || - isEventViewer !== this.props.isEventViewer || - pinnedEventIds !== this.props.pinnedEventIds || - range !== this.props.range || - sort !== this.props.sort - ); - } + toggleColumn, + unPinEvent, + updateColumns, + updateNote, + updateSort, + }) => { + const onAddNoteToEvent: AddNoteToEvent = ({ + eventId, + noteId, + }: { + eventId: string; + noteId: string; + }) => addNoteToEvent!({ id, eventId, noteId }); + + const onColumnSorted: OnColumnSorted = sorted => { + updateSort!({ id, sort: sorted }); + }; - public render() { - const { - browserFields, - columnHeaders, - data, - eventIdToNoteIds, - getNotesByIds, - height, - id, - isEventViewer = false, - pinnedEventIds, - range, - sort, - toggleColumn, - } = this.props; + const onColumnRemoved: OnColumnRemoved = columnId => removeColumn!({ id, columnId }); + + const onColumnResized: OnColumnResized = ({ columnId, delta }) => + applyDeltaToColumnWidth!({ id, columnId, delta }); + + const onPinEvent: OnPinEvent = eventId => pinEvent!({ id, eventId }); + + const onUnPinEvent: OnUnPinEvent = eventId => unPinEvent!({ id, eventId }); + + const onUpdateNote: UpdateNote = (note: Note) => updateNote!({ note }); + + const onUpdateColumns: OnUpdateColumns = columns => updateColumns!({ id, columns }); return ( height={height} id={id} isEventViewer={isEventViewer} - onColumnResized={this.onColumnResized} - onColumnRemoved={this.onColumnRemoved} - onColumnSorted={this.onColumnSorted} + onColumnRemoved={onColumnRemoved} + onColumnResized={onColumnResized} + onColumnSorted={onColumnSorted} onFilterChange={noop} // TODO: this is the callback for column filters, which is out scope for this phase of delivery - onPinEvent={this.onPinEvent} - onUpdateColumns={this.onUpdateColumns} - onUnPinEvent={this.onUnPinEvent} + onPinEvent={onPinEvent} + onUnPinEvent={onUnPinEvent} + onUpdateColumns={onUpdateColumns} pinnedEventIds={pinnedEventIds} range={range!} rowRenderers={rowRenderers} sort={sort} toggleColumn={toggleColumn} - updateNote={this.onUpdateNote} + updateNote={onUpdateNote} /> ); + }, + (prevProps, nextProps) => { + return ( + prevProps.browserFields === nextProps.browserFields && + prevProps.columnHeaders === nextProps.columnHeaders && + prevProps.data === nextProps.data && + prevProps.eventIdToNoteIds === nextProps.eventIdToNoteIds && + prevProps.getNotesByIds === nextProps.getNotesByIds && + prevProps.height === nextProps.height && + prevProps.id === nextProps.id && + prevProps.isEventViewer === nextProps.isEventViewer && + prevProps.pinnedEventIds === nextProps.pinnedEventIds && + prevProps.range === nextProps.range && + prevProps.sort === nextProps.sort + ); } +); - private onAddNoteToEvent: AddNoteToEvent = ({ - eventId, - noteId, - }: { - eventId: string; - noteId: string; - }) => this.props.addNoteToEvent!({ id: this.props.id, eventId, noteId }); - - private onColumnSorted: OnColumnSorted = sorted => { - this.props.updateSort!({ id: this.props.id, sort: sorted }); - }; - - private onColumnRemoved: OnColumnRemoved = columnId => - this.props.removeColumn!({ id: this.props.id, columnId }); - - private onColumnResized: OnColumnResized = ({ columnId, delta }) => - this.props.applyDeltaToColumnWidth!({ id: this.props.id, columnId, delta }); - - private onPinEvent: OnPinEvent = eventId => this.props.pinEvent!({ id: this.props.id, eventId }); - - private onUnPinEvent: OnUnPinEvent = eventId => - this.props.unPinEvent!({ id: this.props.id, eventId }); - - private onUpdateNote: UpdateNote = (note: Note) => this.props.updateNote!({ note }); - - private onUpdateColumns: OnUpdateColumns = columns => - this.props.updateColumns!({ id: this.props.id, columns }); -} +StatefulBodyComponent.displayName = 'StatefulBodyComponent'; const makeMapStateToProps = () => { const memoizedColumnHeaders: ( @@ -201,9 +193,9 @@ const makeMapStateToProps = () => { return { columnHeaders: memoizedColumnHeaders(columns, browserFields), - id, eventIdToNoteIds, getNotesByIds: getNotesByIds(state), + id, pinnedEventIds, }; }; @@ -215,12 +207,12 @@ export const StatefulBody = connect( { addNoteToEvent: timelineActions.addNoteToEvent, applyDeltaToColumnWidth: timelineActions.applyDeltaToColumnWidth, - unPinEvent: timelineActions.unPinEvent, - updateColumns: timelineActions.updateColumns, - updateSort: timelineActions.updateSort, pinEvent: timelineActions.pinEvent, removeColumn: timelineActions.removeColumn, removeProvider: timelineActions.removeProvider, + unPinEvent: timelineActions.unPinEvent, + updateColumns: timelineActions.updateColumns, updateNote: appActions.updateNote, + updateSort: timelineActions.updateSort, } )(StatefulBodyComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx index 29417bd0b578b4..98cf0a78b1d1f8 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx @@ -5,7 +5,7 @@ */ import { noop } from 'lodash/fp'; -import React, { PureComponent } from 'react'; +import React, { useState } from 'react'; import { BrowserFields } from '../../../containers/source'; @@ -32,30 +32,42 @@ interface ProviderItemBadgeProps { val: string | number; } -interface OwnState { - isPopoverOpen: boolean; -} +export const ProviderItemBadge = React.memo( + ({ + andProviderId, + browserFields, + deleteProvider, + field, + kqlQuery, + isEnabled, + isExcluded, + onDataProviderEdited, + operator, + providerId, + timelineId, + toggleEnabledProvider, + toggleExcludedProvider, + val, + }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + function togglePopover() { + setIsPopoverOpen(!isPopoverOpen); + } -export class ProviderItemBadge extends PureComponent { - public readonly state = { - isPopoverOpen: false, - }; + function closePopover() { + setIsPopoverOpen(false); + } - public render() { - const { - andProviderId, - browserFields, - deleteProvider, - field, - kqlQuery, - isEnabled, - isExcluded, - onDataProviderEdited, - operator, - providerId, - timelineId, - val, - } = this.props; + function onToggleEnabledProvider() { + toggleEnabledProvider(); + closePopover(); + } + + function onToggleExcludedProvider() { + toggleExcludedProvider(); + closePopover(); + } return ( @@ -71,51 +83,31 @@ export class ProviderItemBadge extends PureComponent } - closePopover={this.closePopover} + closePopover={closePopover} deleteProvider={deleteProvider} field={field} kqlQuery={kqlQuery} isEnabled={isEnabled} isExcluded={isExcluded} isLoading={isLoading} - isOpen={this.state.isPopoverOpen} + isOpen={isPopoverOpen} onDataProviderEdited={onDataProviderEdited} operator={operator} providerId={providerId} timelineId={timelineId} - toggleEnabledProvider={this.toggleEnabledProvider} - toggleExcludedProvider={this.toggleExcludedProvider} + toggleEnabledProvider={onToggleEnabledProvider} + toggleExcludedProvider={onToggleExcludedProvider} value={val} /> )} ); } +); - private togglePopover = () => { - this.setState(prevState => ({ - isPopoverOpen: !prevState.isPopoverOpen, - })); - }; - - private closePopover = () => { - this.setState({ - isPopoverOpen: false, - }); - }; - - private toggleEnabledProvider = () => { - this.props.toggleEnabledProvider(); - this.closePopover(); - }; - - private toggleExcludedProvider = () => { - this.props.toggleExcludedProvider(); - this.closePopover(); - }; -} +ProviderItemBadge.displayName = 'ProviderItemBadge'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx index 79f85103077b70..6e8a0e8cfb17fc 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx @@ -11,7 +11,7 @@ import * as React from 'react'; import { TestProviders } from '../../../mock/test_providers'; -import { Footer } from './index'; +import { Footer, PagingControl } from './index'; import { mockData } from './mock'; describe('Footer Timeline Component', () => { @@ -93,38 +93,36 @@ describe('Footer Timeline Component', () => { }); test('it renders the Loading... in the more load button when fetching new data', () => { - const wrapper = mount( - -