From 1b449394b69b9bc70854b97fcba293ee03350335 Mon Sep 17 00:00:00 2001 From: semd Date: Thu, 29 Jul 2021 13:20:29 +0200 Subject: [PATCH 1/9] tGrid EuiDataGrid toolbar replace utilityBar --- .../common/components/events_viewer/index.tsx | 6 +- x-pack/plugins/timelines/common/constants.ts | 7 + .../common/types/timeline/actions/index.ts | 9 + .../components/t_grid/body/index.test.tsx | 3 + .../public/components/t_grid/body/index.tsx | 204 ++++++++++++++++-- .../components/t_grid/body/translations.ts | 6 + .../components/t_grid/integrated/index.tsx | 32 ++- .../t_grid/integrated/translations.ts | 42 ---- .../components/t_grid/standalone/index.tsx | 33 +-- .../t_grid/standalone/translations.ts | 36 ---- .../public/components/t_grid/styles.tsx | 5 +- .../t_grid/toolbar/bulk_actions/index.tsx | 113 ++++++++++ .../toolbar/bulk_actions/translations.ts | 29 +++ .../t_grid/toolbar/bulk_actions/types.ts | 33 +++ .../t_grid/toolbar/fields_browser/index.tsx | 2 +- .../public/components/t_grid/translations.ts | 96 +++++++++ .../public/container/use_update_alerts.ts | 38 ++++ .../hooks/use_status_bulk_action_items.tsx | 109 ++++++++++ x-pack/plugins/timelines/public/index.ts | 1 + .../timelines/public/store/t_grid/model.ts | 2 +- 20 files changed, 671 insertions(+), 135 deletions(-) delete mode 100644 x-pack/plugins/timelines/public/components/t_grid/integrated/translations.ts delete mode 100644 x-pack/plugins/timelines/public/components/t_grid/standalone/translations.ts create mode 100644 x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/index.tsx create mode 100644 x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/translations.ts create mode 100644 x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/types.ts create mode 100644 x-pack/plugins/timelines/public/container/use_update_alerts.ts create mode 100644 x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index c4da4e8d4506a..423ad910989da 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -15,7 +15,8 @@ import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; import { timelineSelectors, timelineActions } from '../../../timelines/store/timeline'; -import { SubsetTimelineModel, TimelineModel } from '../../../timelines/store/timeline/model'; +import type { SubsetTimelineModel, TimelineModel } from '../../../timelines/store/timeline/model'; +import type { AlertStatus } from '../../../../../timelines/common'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { InspectButtonContainer } from '../inspect'; import { useGlobalFullScreen } from '../../containers/use_full_screen'; @@ -54,6 +55,7 @@ export interface OwnProps { showTotalCount?: boolean; headerFilterGroup?: React.ReactNode; pageFilters?: Filter[]; + currentFilter: string; onRuleChange?: () => void; renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; @@ -83,6 +85,7 @@ const StatefulEventsViewerComponent: React.FC = ({ itemsPerPageOptions, kqlMode, pageFilters, + currentFilter, onRuleChange, query, renderCellValue, @@ -160,6 +163,7 @@ const StatefulEventsViewerComponent: React.FC = ({ sort, utilityBar, graphEventId, + filterStatus: currentFilter as AlertStatus, leadingControlColumns, trailingControlColumns, }) diff --git a/x-pack/plugins/timelines/common/constants.ts b/x-pack/plugins/timelines/common/constants.ts index 86ff9d501f148..8c4e1700a54dd 100644 --- a/x-pack/plugins/timelines/common/constants.ts +++ b/x-pack/plugins/timelines/common/constants.ts @@ -5,4 +5,11 @@ * 2.0. */ +import { AlertStatus } from './types/timeline/actions'; + export const DEFAULT_MAX_TABLE_QUERY_SIZE = 10000; +export const DEFAULT_NUMBER_FORMAT = 'format:number:defaultPattern'; + +export const FILTER_OPEN: AlertStatus = 'open'; +export const FILTER_CLOSED: AlertStatus = 'closed'; +export const FILTER_IN_PROGRESS: AlertStatus = 'in-progress'; diff --git a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts index 8d3f212fd6bcc..1d1df57539334 100644 --- a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts @@ -90,3 +90,12 @@ export type ControlColumnProps = Omit< keyof AdditionalControlColumnProps > & Partial; + +export type BulkActionsProp = + | boolean + | { + defaultActions: boolean; + additionalActions: JSX.Element; + }; + +export type AlertStatus = 'open' | 'closed' | 'in-progress'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx index a3de8654ec1c5..81fe117e08ccd 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx @@ -75,8 +75,11 @@ describe('Body', () => { showCheckboxes: false, tabType: TimelineTabs.query, totalPages: 1, + totalItems: 1, leadingControlColumns: [], trailingControlColumns: [], + filterStatus: 'open', + refetch: jest.fn(), }; describe('rendering', () => { diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index 3b81bdc2774f0..869f889a7f624 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -15,7 +15,7 @@ import { import { getOr } from 'lodash/fp'; import memoizeOne from 'memoize-one'; import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { connect, ConnectedProps, useDispatch } from 'react-redux'; import { TimelineId, TimelineTabs } from '../../../../common/types/timeline'; // eslint-disable-next-line no-duplicate-imports @@ -24,6 +24,7 @@ import type { ColumnHeaderOptions, ControlColumnProps, RowRenderer, + AlertStatus, } from '../../../../common/types/timeline'; import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; @@ -32,14 +33,24 @@ import { getEventIdToDataMapping } from './helpers'; import { Sort } from './sort'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; -import { BrowserFields } from '../../../../common/search_strategy/index_fields'; -import { OnRowSelected, OnSelectAll } from '../types'; -import { StatefulFieldsBrowser, tGridActions } from '../../../'; -import { TGridModel, tGridSelectors, TimelineState } from '../../../store/t_grid'; +import type { BrowserFields } from '../../../../common/search_strategy/index_fields'; +import type { OnRowSelected, OnSelectAll } from '../types'; +import type { Refetch } from '../../../store/t_grid/inputs'; +import type { + SetEventsDeletedProps, + SetEventsLoadingProps, +} from '../../../hooks/use_status_bulk_action_items'; +// eslint-disable-next-line no-duplicate-imports +import { useStatusBulkActionItems } from '../../../hooks/use_status_bulk_action_items'; +import { StatefulFieldsBrowser, BulkActions } from '../../../'; +import { tGridActions, TGridModel, tGridSelectors, TimelineState } from '../../../store/t_grid'; import { useDeepEqualSelector } from '../../../hooks/use_selector'; import { RowAction } from './row_action'; import { FIELD_BROWSER_HEIGHT, FIELD_BROWSER_WIDTH } from '../toolbar/fields_browser/helpers'; import * as i18n from './translations'; +import * as i18nTimeline from '../translations'; +import { AlertCount } from '../styles'; +import { useAppToasts } from '../../../hooks/use_app_toasts'; interface OwnProps { activePage: number; @@ -48,16 +59,22 @@ interface OwnProps { data: TimelineItem[]; id: string; isEventViewer?: boolean; - leadingControlColumns: ControlColumnProps[]; renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; sort: Sort[]; tabType: TimelineTabs; - trailingControlColumns: ControlColumnProps[]; + leadingControlColumns?: ControlColumnProps[]; + trailingControlColumns?: ControlColumnProps[]; totalPages: number; + totalItems: number; + additionalBulkActions?: JSX.Element[]; + filterStatus: AlertStatus; + unit?: (total: number) => React.ReactNode; onRuleChange?: () => void; + refetch: Refetch; } +const basicUnit = (n: number) => i18n.UNIT(n); const NUM_OF_ICON_IN_TIMELINE_ROW = 2; export const hasAdditionalActions = (id: TimelineId): boolean => @@ -169,28 +186,43 @@ export const BodyComponent = React.memo( sort, tabType, totalPages, + totalItems, + filterStatus, + additionalBulkActions, + unit = basicUnit, leadingControlColumns = EMPTY_CONTROL_COLUMNS, trailingControlColumns = EMPTY_CONTROL_COLUMNS, + refetch, }) => { + const dispatch = useDispatch(); + const { addSuccess, addError, addWarning } = useAppToasts(); + const [showClearSelection, setShowClearSelection] = useState(false); + const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []); const { queryFields, selectAll } = useDeepEqualSelector((state) => getManageTimeline(state, id) ); + const subtitle = useMemo(() => `${totalItems.toLocaleString()} ${unit(totalItems)}`, [ + totalItems, + unit, + ]); + + const selectedCount = useMemo(() => Object.keys(selectedEventIds).length, [selectedEventIds]); + const onRowSelected: OnRowSelected = useCallback( ({ eventIds, isSelected }: { eventIds: string[]; isSelected: boolean }) => { setSelected!({ id, eventIds: getEventIdToDataMapping(data, eventIds, queryFields), isSelected, - isSelectAllChecked: - isSelected && Object.keys(selectedEventIds).length + 1 === data.length, + isSelectAllChecked: isSelected && selectedCount + 1 === data.length, }); }, - [setSelected, id, data, selectedEventIds, queryFields] + [setSelected, id, data, selectedCount, queryFields] ); - const onSelectAll: OnSelectAll = useCallback( + const onSelectPage: OnSelectAll = useCallback( ({ isSelected }: { isSelected: boolean }) => isSelected ? setSelected!({ @@ -210,16 +242,125 @@ export const BodyComponent = React.memo( // Sync to selectAll so parent components can select all events useEffect(() => { if (selectAll && !isSelectAllChecked) { - onSelectAll({ isSelected: true }); + onSelectPage({ isSelected: true }); + } + }, [isSelectAllChecked, onSelectPage, selectAll]); + + // Catches state change isSelectAllChecked->false (page checkbox) upon user selection change to reset toolbar select all + useEffect(() => { + if (isSelectAllChecked) { + dispatch(tGridActions.setTGridSelectAll({ id, selectAll: false })); + } else { + setShowClearSelection(false); } - }, [isSelectAllChecked, onSelectAll, selectAll]); + }, [dispatch, isSelectAllChecked, id]); + + // Callback for selecting all events on all pages from toolbar + // Dispatches to stateful_body's selectAll via TimelineTypeContext props + // as scope of response data required to actually set selectedEvents + const onSelectAll = useCallback(() => { + dispatch(tGridActions.setTGridSelectAll({ id, selectAll: true })); + setShowClearSelection(true); + }, [dispatch, id]); + + // Callback for clearing entire selection from toolbar + const onClearSelection = useCallback(() => { + clearSelected!({ id }); + dispatch(tGridActions.setTGridSelectAll({ id, selectAll: false })); + setShowClearSelection(false); + }, [clearSelected, dispatch, id]); + + const onAlertStatusUpdateSuccess = useCallback( + (updated: number, conflicts: number, newStatus: AlertStatus) => { + if (conflicts > 0) { + // Partial failure + addWarning({ + title: i18nTimeline.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18nTimeline.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } else { + let title: string; + switch (newStatus) { + case 'closed': + title = i18nTimeline.CLOSED_ALERT_SUCCESS_TOAST(updated); + break; + case 'open': + title = i18nTimeline.OPENED_ALERT_SUCCESS_TOAST(updated); + break; + case 'in-progress': + title = i18nTimeline.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated); + } + addSuccess({ title }); + } + refetch(); + }, + [addSuccess, addWarning, refetch] + ); + + const onAlertStatusUpdateFailure = useCallback( + (newStatus: AlertStatus, error: Error) => { + let title: string; + switch (newStatus) { + case 'closed': + title = i18nTimeline.CLOSED_ALERT_FAILED_TOAST; + break; + case 'open': + title = i18nTimeline.OPENED_ALERT_FAILED_TOAST; + break; + case 'in-progress': + title = i18nTimeline.IN_PROGRESS_ALERT_FAILED_TOAST; + } + addError(error.message, { title }); + refetch(); + }, + [addError, refetch] + ); + + const setEventsLoading = useCallback( + ({ eventIds, isLoading }: SetEventsLoadingProps) => { + dispatch(tGridActions.setEventsLoading({ id, eventIds, isLoading })); + }, + [dispatch, id] + ); + + const setEventsDeleted = useCallback( + ({ eventIds, isDeleted }: SetEventsDeletedProps) => { + dispatch(tGridActions.setEventsDeleted({ id, eventIds, isDeleted })); + }, + [dispatch, id] + ); + + const statusBulkActionItems = useStatusBulkActionItems({ + currentStatus: filterStatus, + eventIds: Object.keys(selectedEventIds), + setEventsLoading, + setEventsDeleted, + onUpdateSuccess: onAlertStatusUpdateSuccess, + onUpdateFailure: onAlertStatusUpdateFailure, + }); const toolbarVisibility: EuiDataGridToolBarVisibilityOptions = useMemo( () => ({ - additionalControls: ( - <> - {additionalControls ?? null} - { + additionalControls: + selectedCount > 0 ? ( + <> + {subtitle} + + {additionalControls ?? null} + + ) : ( + <> + {subtitle} + {additionalControls ?? null} ( timelineId={id} columnHeaders={columnHeaders} /> + + ), + ...(selectedCount > 0 + ? { + showColumnSelector: false, + showSortSelector: false, + showFullScreenSelector: false, } - - ), - showColumnSelector: { allowHide: false, allowReorder: true }, + : { + showColumnSelector: { allowHide: true, allowReorder: true }, + showSortSelector: true, + showFullScreenSelector: true, + }), showStyleSelector: false, }), - [additionalControls, browserFields, columnHeaders, id] + [ + selectedCount, + id, + subtitle, + totalItems, + browserFields, + columnHeaders, + additionalControls, + statusBulkActionItems, + showClearSelection, + onSelectAll, + onClearSelection, + ] ); const [sortingColumns, setSortingColumns] = useState([]); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/translations.ts b/x-pack/plugins/timelines/public/components/t_grid/body/translations.ts index c45a00a0516f4..a084e3c3f5cbf 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/translations.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/translations.ts @@ -231,3 +231,9 @@ export const INVESTIGATE_IN_RESOLVER_DISABLED = i18n.translate( defaultMessage: 'This event cannot be analyzed since it has incompatible field mappings', } ); + +export const UNIT = (totalCount: number) => + i18n.translate('xpack.timelines.timeline.body.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {event} other {events}}`, + }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx index a6ded88fae96b..219fd0aa3ea5b 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx @@ -24,6 +24,7 @@ import type { ControlColumnProps, DataProvider, RowRenderer, + AlertStatus, } from '../../../../common/types/timeline'; import { esQuery, @@ -42,20 +43,15 @@ import { HeaderSection } from '../header_section'; import { StatefulBody } from '../body'; import { Footer, footerHeight } from '../footer'; import { LastUpdatedAt } from '../..'; -import { AlertCount, SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexItem } from '../styles'; -import * as i18n from './translations'; +import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexItem } from '../styles'; +import * as i18n from '../translations'; import { ExitFullScreen } from '../../exit_full_screen'; import { Sort } from '../body/sort'; import { InspectButtonContainer } from '../../inspect'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px -const UTILITY_BAR_HEIGHT = 19; // px const COMPACT_HEADER_HEIGHT = 36; // px -const UtilityBar = styled.div` - height: ${UTILITY_BAR_HEIGHT}px; -`; - const TitleText = styled.span` margin-right: 12px; `; @@ -116,6 +112,7 @@ export interface TGridIntegratedProps { filters: Filter[]; globalFullScreen: boolean; headerFilterGroup?: React.ReactNode; + filterStatus: AlertStatus; height?: number; id: TimelineId; indexNames: string[]; @@ -135,8 +132,8 @@ export interface TGridIntegratedProps { utilityBar?: (refetch: Refetch, totalCount: number) => React.ReactNode; // If truthy, the graph viewer (Resolver) is showing graphEventId: string | undefined; - leadingControlColumns: ControlColumnProps[]; - trailingControlColumns: ControlColumnProps[]; + leadingControlColumns?: ControlColumnProps[]; + trailingControlColumns?: ControlColumnProps[]; data?: DataPublicPluginStart; } @@ -150,6 +147,7 @@ const TGridIntegratedComponent: React.FC = ({ filters, globalFullScreen, headerFilterGroup, + filterStatus, id, indexNames, indexPattern, @@ -257,13 +255,6 @@ const TGridIntegratedComponent: React.FC = ({ [deletedEventIds.length, totalCount] ); - const subtitle = useMemo( - () => `${totalCountMinusDeleted.toLocaleString()} ${unit && unit(totalCountMinusDeleted)}`, - [totalCountMinusDeleted, unit] - ); - - const additionalControls = useMemo(() => {subtitle}, [subtitle]); - const nonDeletedEvents = useMemo(() => events.filter((e) => !deletedEventIds.includes(e._id)), [ deletedEventIds, events, @@ -303,9 +294,7 @@ const TGridIntegratedComponent: React.FC = ({ > {HeaderSectionContent} - {utilityBar && !resolverIsShowing(graphEventId) && ( - {utilityBar?.(refetch, totalCountMinusDeleted)} - )} + = ({ = ({ itemsCount: totalCountMinusDeleted, itemsPerPage, })} + totalItems={totalCountMinusDeleted} + unit={unit} + filterStatus={filterStatus} leadingControlColumns={leadingControlColumns} trailingControlColumns={trailingControlColumns} + refetch={refetch} />