Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RAC] [TGrid] Bulk actions to EuiDataGrid toolbar #107141

Merged
merged 14 commits into from
Aug 3, 2021
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import {
import type { TimelinesUIStart } from '../../../../timelines/public';
import type { TopAlert } from './';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import type { ActionProps, ColumnHeaderOptions, RowRenderer } from '../../../../timelines/common';
import type {
ActionProps,
AlertStatus,
ColumnHeaderOptions,
RowRenderer,
} from '../../../../timelines/common';

import { getRenderCellValue } from './render_cell_value';
import { usePluginContext } from '../../hooks/use_plugin_context';
Expand Down Expand Up @@ -216,8 +221,14 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) {
sortDirection: 'desc',
},
],
filterStatus: status as AlertStatus,
leadingControlColumns,
trailingControlColumns,
// batchActions: true,
// batchActions: {
// defaultActions: false,
// additionalActions: <>Additional actions</>,
// },
unit: (totalAlerts: number) =>
i18n.translate('xpack.observability.alertsTable.showingAlertsTitle', {
values: { totalAlerts },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -83,6 +85,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
itemsPerPageOptions,
kqlMode,
pageFilters,
currentFilter,
onRuleChange,
query,
renderCellValue,
Expand Down Expand Up @@ -160,6 +163,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
sort,
utilityBar,
graphEventId,
filterStatus: currentFilter as AlertStatus,
leadingControlColumns,
trailingControlColumns,
})
Expand Down
7 changes: 7 additions & 0 deletions x-pack/plugins/timelines/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,12 @@ export type ControlColumnProps = Omit<
keyof AdditionalControlColumnProps
> &
Partial<AdditionalControlColumnProps>;

export type BulkActionsProp =
| boolean
| {
defaultActions: boolean;
additionalActions: JSX.Element;
};

export type AlertStatus = 'open' | 'closed' | 'in-progress';
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,11 @@ describe('Body', () => {
showCheckboxes: false,
tabType: TimelineTabs.query,
totalPages: 1,
totalItems: 1,
leadingControlColumns: [],
trailingControlColumns: [],
filterStatus: 'open',
refetch: jest.fn(),
};

describe('rendering', () => {
Expand Down
204 changes: 183 additions & 21 deletions x-pack/plugins/timelines/public/components/t_grid/body/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +24,7 @@ import type {
ColumnHeaderOptions,
ControlColumnProps,
RowRenderer,
AlertStatus,
} from '../../../../common/types/timeline';
import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy/timeline';

Expand All @@ -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;
Expand All @@ -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 =>
Expand Down Expand Up @@ -169,28 +186,43 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
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!({
Expand All @@ -210,16 +242,125 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
// 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({
semd marked this conversation as resolved.
Show resolved Hide resolved
currentStatus: filterStatus,
eventIds: Object.keys(selectedEventIds),
setEventsLoading,
setEventsDeleted,
onUpdateSuccess: onAlertStatusUpdateSuccess,
onUpdateFailure: onAlertStatusUpdateFailure,
});

const toolbarVisibility: EuiDataGridToolBarVisibilityOptions = useMemo(
() => ({
additionalControls: (
<>
{additionalControls ?? null}
{
additionalControls:
selectedCount > 0 ? (
<>
<AlertCount>{subtitle}</AlertCount>
<BulkActions
data-test-subj="bulk-actions"
timelineId={id}
selectedCount={selectedCount}
totalItems={totalItems}
showClearSelection={showClearSelection}
onSelectAll={onSelectAll}
onClearSelection={onClearSelection}
bulkActionItems={statusBulkActionItems}
/>
{additionalControls ?? null}
</>
) : (
<>
<AlertCount>{subtitle}</AlertCount>
{additionalControls ?? null}
<StatefulFieldsBrowser
data-test-subj="field-browser"
height={FIELD_BROWSER_HEIGHT}
Expand All @@ -228,13 +369,34 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
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([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}}`,
});
Loading