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

* tGrid EuiDataGrid toolbar replace utilityBar

* tgrid new prop in observability

* types and translations fixes

* bulkActions props and encapsulation

* update limits

* code cleaning

* load lazy and remove export from public

* add memoization to bulk_actions

* icon change and test fixed

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2021-08-03 20:02:44 +02:00 committed by GitHub
parent 1e1d669650
commit b5e8db2443
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 799 additions and 159 deletions

View file

@ -107,7 +107,7 @@ pageLoadAssetSize:
dataVisualizer: 27530
banners: 17946
mapsEms: 26072
timelines: 330000
timelines: 327300
screenshotMode: 17856
visTypePie: 35583
expressionRevealImage: 25675

View file

@ -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';
@ -213,6 +218,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) {
sortDirection: 'desc',
},
],
filterStatus: status as AlertStatus,
leadingControlColumns,
trailingControlColumns,
unit: (totalAlerts: number) =>

View file

@ -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 { Status } from '../../../../common/detection_engine/schemas/common/schemas';
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?: Status;
onRuleChange?: () => void;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
@ -83,6 +85,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
itemsPerPageOptions,
kqlMode,
pageFilters,
currentFilter,
onRuleChange,
query,
renderCellValue,
@ -160,6 +163,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
sort,
utilityBar,
graphEventId,
filterStatus: currentFilter,
leadingControlColumns,
trailingControlColumns,
})

View file

@ -390,6 +390,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
pageFilters={defaultFiltersMemo}
defaultModel={defaultTimelineModel}
end={to}
currentFilter={filterGroup}
headerFilterGroup={headerFilterGroup}
id={timelineId}
onRuleChange={onRuleChange}

View file

@ -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';

View file

@ -90,3 +90,14 @@ export type ControlColumnProps = Omit<
keyof AdditionalControlColumnProps
> &
Partial<AdditionalControlColumnProps>;
export type OnAlertStatusActionSuccess = (status: AlertStatus) => void;
export type OnAlertStatusActionFailure = (status: AlertStatus, error: string) => void;
export interface BulkActionsObjectProp {
alertStatusActions?: boolean;
onAlertStatusActionSuccess?: OnAlertStatusActionSuccess;
onAlertStatusActionFailure?: OnAlertStatusActionFailure;
}
export type BulkActionsProp = boolean | BulkActionsObjectProp;
export type AlertStatus = 'open' | 'closed' | 'in-progress';

View file

@ -75,8 +75,11 @@ describe('Body', () => {
showCheckboxes: false,
tabType: TimelineTabs.query,
totalPages: 1,
totalItems: 1,
leadingControlColumns: [],
trailingControlColumns: [],
filterStatus: 'open',
refetch: jest.fn(),
};
describe('rendering', () => {

View file

@ -11,19 +11,34 @@ import {
EuiDataGridControlColumn,
EuiDataGridStyle,
EuiDataGridToolBarVisibilityOptions,
EuiLoadingSpinner,
} from '@elastic/eui';
import { getOr } from 'lodash/fp';
import memoizeOne from 'memoize-one';
import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react';
import React, {
ComponentType,
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { SortColumnTimeline, TimelineId, TimelineTabs } from '../../../../common/types/timeline';
import {
TimelineId,
TimelineTabs,
BulkActionsProp,
SortColumnTimeline,
} from '../../../../common/types/timeline';
import type {
CellValueElementProps,
ColumnHeaderOptions,
ControlColumnProps,
RowRenderer,
AlertStatus,
} from '../../../../common/types/timeline';
import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy/timeline';
@ -32,15 +47,21 @@ 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 { StatefulFieldsBrowser } from '../../../';
import { tGridActions, TGridModel, tGridSelectors, TimelineState } from '../../../store/t_grid';
import { useDeepEqualSelector } from '../../../hooks/use_selector';
import { RowAction } from './row_action';
import * as i18n from './translations';
import { AlertCount } from '../styles';
import { checkBoxControlColumn } from './control_columns';
const StatefulAlertStatusBulkActions = lazy(
() => import('../toolbar/bulk_actions/alert_status_bulk_actions')
);
interface OwnProps {
activePage: number;
additionalControls?: React.ReactNode;
@ -48,16 +69,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;
bulkActions?: BulkActionsProp;
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 =>
@ -200,31 +227,42 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
sort,
tabType,
totalPages,
totalItems,
filterStatus,
bulkActions = true,
unit = basicUnit,
leadingControlColumns = EMPTY_CONTROL_COLUMNS,
trailingControlColumns = EMPTY_CONTROL_COLUMNS,
refetch,
}) => {
const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []);
const { queryFields, selectAll } = useDeepEqualSelector((state) =>
getManageTimeline(state, id)
);
const alertCountText = 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!({
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 onSelectPage: OnSelectAll = useCallback(
({ isSelected }: { isSelected: boolean }) =>
isSelected
? setSelected!({
? setSelected({
id,
eventIds: getEventIdToDataMapping(
data,
@ -234,7 +272,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
isSelected,
isSelectAllChecked: isSelected,
})
: clearSelected!({ id }),
: clearSelected({ id }),
[setSelected, clearSelected, id, data, queryFields]
);
@ -245,25 +283,87 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
}
}, [isSelectAllChecked, onSelectPage, selectAll]);
const onAlertStatusActionSuccess = useMemo(() => {
if (bulkActions && bulkActions !== true) {
return bulkActions.onAlertStatusActionSuccess;
}
}, [bulkActions]);
const onAlertStatusActionFailure = useMemo(() => {
if (bulkActions && bulkActions !== true) {
return bulkActions.onAlertStatusActionFailure;
}
}, [bulkActions]);
const showBulkActions = useMemo(() => {
if (selectedCount === 0 || !showCheckboxes) {
return false;
}
if (typeof bulkActions === 'boolean') {
return bulkActions;
}
return bulkActions.alertStatusActions ?? true;
}, [selectedCount, showCheckboxes, bulkActions]);
const toolbarVisibility: EuiDataGridToolBarVisibilityOptions = useMemo(
() => ({
additionalControls: (
<>
{additionalControls ?? null}
{
<StatefulFieldsBrowser
data-test-subj="field-browser"
browserFields={browserFields}
timelineId={id}
columnHeaders={columnHeaders}
/>
}
<AlertCount>{alertCountText}</AlertCount>
{showBulkActions ? (
<>
<Suspense fallback={<EuiLoadingSpinner />}>
<StatefulAlertStatusBulkActions
data-test-subj="bulk-actions"
id={id}
totalItems={totalItems}
filterStatus={filterStatus}
onActionSuccess={onAlertStatusActionSuccess}
onActionFailure={onAlertStatusActionFailure}
refetch={refetch}
/>
</Suspense>
{additionalControls ?? null}
</>
) : (
<>
{additionalControls ?? null}
<StatefulFieldsBrowser
data-test-subj="field-browser"
browserFields={browserFields}
timelineId={id}
columnHeaders={columnHeaders}
/>
</>
)}
</>
),
showColumnSelector: { allowHide: false, allowReorder: true },
...(showBulkActions
? {
showColumnSelector: false,
showSortSelector: false,
showFullScreenSelector: false,
}
: {
showColumnSelector: { allowHide: true, allowReorder: true },
showSortSelector: true,
showFullScreenSelector: true,
}),
showStyleSelector: false,
}),
[additionalControls, browserFields, columnHeaders, id]
[
id,
alertCountText,
totalItems,
filterStatus,
browserFields,
columnHeaders,
additionalControls,
showBulkActions,
onAlertStatusActionSuccess,
onAlertStatusActionFailure,
refetch,
]
);
const [sortingColumns, setSortingColumns] = useState([]);

View file

@ -216,3 +216,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}}`,
});

View file

@ -22,6 +22,7 @@ import type {
ControlColumnProps,
DataProvider,
RowRenderer,
AlertStatus,
} from '../../../../common/types/timeline';
import {
esQuery,
@ -40,20 +41,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;
`;
@ -114,6 +110,7 @@ export interface TGridIntegratedProps {
filters: Filter[];
globalFullScreen: boolean;
headerFilterGroup?: React.ReactNode;
filterStatus?: AlertStatus;
height?: number;
id: TimelineId;
indexNames: string[];
@ -133,8 +130,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;
}
@ -148,6 +145,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
filters,
globalFullScreen,
headerFilterGroup,
filterStatus,
id,
indexNames,
indexPattern,
@ -255,13 +253,6 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
[deletedEventIds.length, totalCount]
);
const subtitle = useMemo(
() => `${totalCountMinusDeleted.toLocaleString()} ${unit && unit(totalCountMinusDeleted)}`,
[totalCountMinusDeleted, unit]
);
const additionalControls = useMemo(() => <AlertCount>{subtitle}</AlertCount>, [subtitle]);
const nonDeletedEvents = useMemo(() => events.filter((e) => !deletedEventIds.includes(e._id)), [
deletedEventIds,
events,
@ -301,9 +292,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
>
{HeaderSectionContent}
</HeaderSection>
{utilityBar && !resolverIsShowing(graphEventId) && (
<UtilityBar>{utilityBar?.(refetch, totalCountMinusDeleted)}</UtilityBar>
)}
<EventsContainerLoading
data-timeline-id={id}
data-test-subj={`events-container-loading-${loading}`}
@ -318,7 +307,6 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
<ScrollableFlexItem grow={1}>
<StatefulBody
activePage={pageInfo.activePage}
additionalControls={additionalControls}
browserFields={browserFields}
data={nonDeletedEvents}
id={id}
@ -332,8 +320,12 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
itemsCount: totalCountMinusDeleted,
itemsPerPage,
})}
totalItems={totalCountMinusDeleted}
unit={unit}
filterStatus={filterStatus}
leadingControlColumns={leadingControlColumns}
trailingControlColumns={trailingControlColumns}
refetch={refetch}
/>
<Footer
activePage={pageInfo.activePage}

View file

@ -1,42 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SHOWING = i18n.translate('xpack.timelines.eventsViewer.showingLabel', {
defaultMessage: 'Showing',
});
export const ERROR_FETCHING_EVENTS_DATA = i18n.translate(
'xpack.timelines.eventsViewer.errorFetchingEventsData',
{
defaultMessage: 'Failed to query events data',
}
);
export const EVENTS = i18n.translate('xpack.timelines.eventsViewer.eventsLabel', {
defaultMessage: 'Events',
});
export const LOADING_EVENTS = i18n.translate(
'xpack.timelines.eventsViewer.footer.loadingEventsDataLabel',
{
defaultMessage: 'Loading Events',
}
);
export const UNIT = (totalCount: number) =>
i18n.translate('xpack.timelines.eventsViewer.unit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {event} other {events}}`,
});
export const ALERTS_UNIT = (totalCount: number) =>
i18n.translate('xpack.timelines.eventsViewer.alertsUnit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {alert} other {alerts}}`,
});

View file

@ -21,6 +21,8 @@ import type {
DataProvider,
RowRenderer,
SortColumnTimeline,
BulkActionsProp,
AlertStatus,
} from '../../../../common/types/timeline';
import {
esQuery,
@ -29,7 +31,6 @@ import {
DataPublicPluginStart,
} from '../../../../../../../src/plugins/data/public';
import { useDeepEqualSelector } from '../../../hooks/use_selector';
import { Refetch } from '../../../store/t_grid/inputs';
import { defaultHeaders } from '../body/column_headers/default_headers';
import { calculateTotalPages, combineQueries, resolverIsShowing } from '../helpers';
import { tGridActions, tGridSelectors } from '../../../store/t_grid';
@ -38,21 +39,16 @@ 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 { InspectButtonContainer } from '../../inspect';
import { useFetchIndex } from '../../../container/source';
export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px
const UTILITY_BAR_HEIGHT = 19; // px
const COMPACT_HEADER_HEIGHT = 36; // px
const STANDALONE_ID = 'standalone-t-grid';
const EMPTY_DATA_PROVIDERS: DataProvider[] = [];
const UtilityBar = styled.div`
height: ${UTILITY_BAR_HEIGHT}px;
`;
const TitleText = styled.span`
margin-right: 12px;
`;
@ -108,6 +104,7 @@ export interface TGridStandaloneProps {
filters: Filter[];
footerText: React.ReactNode;
headerFilterGroup?: React.ReactNode;
filterStatus: AlertStatus;
height?: number;
indexNames: string[];
itemsPerPage: number;
@ -119,10 +116,10 @@ export interface TGridStandaloneProps {
setRefetch: (ref: () => void) => void;
start: string;
sort: SortColumnTimeline[];
utilityBar?: (refetch: Refetch, totalCount: number) => React.ReactNode;
graphEventId?: string;
leadingControlColumns: ControlColumnProps[];
trailingControlColumns: ControlColumnProps[];
bulkActions?: BulkActionsProp;
data?: DataPublicPluginStart;
unit: (total: number) => React.ReactNode;
}
@ -136,6 +133,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
filters,
footerText,
headerFilterGroup,
filterStatus,
indexNames,
itemsPerPage,
itemsPerPageOptions,
@ -146,7 +144,6 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
setRefetch,
start,
sort,
utilityBar,
graphEventId,
leadingControlColumns,
trailingControlColumns,
@ -166,6 +163,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
queryFields,
title,
} = useDeepEqualSelector((state) => getTGrid(state, STANDALONE_ID ?? ''));
useEffect(() => {
dispatch(tGridActions.updateIsLoading({ id: STANDALONE_ID, isLoading: isQueryLoading }));
}, [dispatch, isQueryLoading]);
@ -237,13 +235,6 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
[deletedEventIds.length, totalCount]
);
const subtitle = useMemo(
() => `${totalCountMinusDeleted.toLocaleString()} ${unit && unit(totalCountMinusDeleted)}`,
[totalCountMinusDeleted, unit]
);
const additionalControls = useMemo(() => <AlertCount>{subtitle}</AlertCount>, [subtitle]);
const nonDeletedEvents = useMemo(() => events.filter((e) => !deletedEventIds.includes(e._id)), [
deletedEventIds,
events,
@ -310,9 +301,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
>
{HeaderSectionContent}
</HeaderSection>
{utilityBar && !resolverIsShowing(graphEventId) && (
<UtilityBar>{utilityBar?.(refetch, totalCountMinusDeleted)}</UtilityBar>
)}
<EventsContainerLoading
data-timeline-id={STANDALONE_ID}
data-test-subj={`events-container-loading-${loading}`}
@ -327,7 +316,6 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
<ScrollableFlexItem grow={1}>
<StatefulBody
activePage={pageInfo.activePage}
additionalControls={additionalControls}
browserFields={browserFields}
data={nonDeletedEvents}
id={STANDALONE_ID}
@ -341,8 +329,12 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
itemsCount: totalCountMinusDeleted,
itemsPerPage: itemsPerPageStore,
})}
totalItems={totalCountMinusDeleted}
unit={unit}
filterStatus={filterStatus}
leadingControlColumns={leadingControlColumns}
trailingControlColumns={trailingControlColumns}
refetch={refetch}
/>
<Footer
activePage={pageInfo.activePage}

View file

@ -1,36 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SHOWING = i18n.translate('xpack.timelines.eventsViewer.showingLabel', {
defaultMessage: 'Showing',
});
export const ERROR_FETCHING_EVENTS_DATA = i18n.translate(
'xpack.timelines.eventsViewer.errorFetchingEventsData',
{
defaultMessage: 'Failed to query events data',
}
);
export const EVENTS = i18n.translate('xpack.timelines.eventsViewer.eventsLabel', {
defaultMessage: 'Events',
});
export const LOADING_EVENTS = i18n.translate(
'xpack.timelines.eventsViewer.footer.loadingEventsDataLabel',
{
defaultMessage: 'Loading Events',
}
);
export const UNIT = (totalCount: number) =>
i18n.translate('xpack.timelines.eventsViewer.unit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {event} other {events}}`,
});

View file

@ -468,6 +468,9 @@ export const UpdatedFlexItem = styled(EuiFlexItem)<{ show: boolean }>`
`;
export const AlertCount = styled.span`
color: ${({ theme }) => theme.eui.euiTextColors.subdued};
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
border-right: ${({ theme }) => theme.eui.euiBorderThin};
margin-right: ${({ theme }) => theme.eui.paddingSizes.s};
padding-right: ${({ theme }) => theme.eui.paddingSizes.m};
`;

View file

@ -0,0 +1,198 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { connect, ConnectedProps, useDispatch } from 'react-redux';
import type {
AlertStatus,
OnAlertStatusActionSuccess,
OnAlertStatusActionFailure,
} from '../../../../../common';
import type { Refetch } from '../../../../store/t_grid/inputs';
import { tGridActions, TGridModel, tGridSelectors, TimelineState } from '../../../../store/t_grid';
import { BulkActions } from './';
import { useAppToasts } from '../../../../hooks/use_app_toasts';
import * as i18n from '../../translations';
import {
SetEventsDeletedProps,
SetEventsLoadingProps,
useStatusBulkActionItems,
} from '../../../../hooks/use_status_bulk_action_items';
interface OwnProps {
id: string;
totalItems: number;
filterStatus?: AlertStatus;
onActionSuccess?: OnAlertStatusActionSuccess;
onActionFailure?: OnAlertStatusActionFailure;
refetch: Refetch;
}
export type StatefulAlertStatusBulkActionsProps = OwnProps & PropsFromRedux;
/**
* Component to render status bulk actions
*/
export const AlertStatusBulkActionsComponent = React.memo<StatefulAlertStatusBulkActionsProps>(
({
id,
totalItems,
filterStatus,
selectedEventIds,
isSelectAllChecked,
clearSelected,
onActionSuccess,
onActionFailure,
refetch,
}) => {
const dispatch = useDispatch();
const { addSuccess, addError, addWarning } = useAppToasts();
const [showClearSelection, setShowClearSelection] = useState(false);
// 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);
}
}, [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: i18n.UPDATE_ALERT_STATUS_FAILED(conflicts),
text: i18n.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts),
});
} else {
let title: string;
switch (newStatus) {
case 'closed':
title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated);
break;
case 'open':
title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated);
break;
case 'in-progress':
title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated);
}
addSuccess({ title });
}
refetch();
if (onActionSuccess) {
onActionSuccess(newStatus);
}
},
[addSuccess, addWarning, onActionSuccess, refetch]
);
const onAlertStatusUpdateFailure = useCallback(
(newStatus: AlertStatus, error: Error) => {
let title: string;
switch (newStatus) {
case 'closed':
title = i18n.CLOSED_ALERT_FAILED_TOAST;
break;
case 'open':
title = i18n.OPENED_ALERT_FAILED_TOAST;
break;
case 'in-progress':
title = i18n.IN_PROGRESS_ALERT_FAILED_TOAST;
}
addError(error.message, { title });
refetch();
if (onActionFailure) {
onActionFailure(newStatus, error.message);
}
},
[addError, onActionFailure, 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,
});
return (
<BulkActions
data-test-subj="bulk-actions"
timelineId={id}
selectedCount={Object.keys(selectedEventIds).length}
totalItems={totalItems}
showClearSelection={showClearSelection}
onSelectAll={onSelectAll}
onClearSelection={onClearSelection}
bulkActionItems={statusBulkActionItems}
/>
);
}
);
AlertStatusBulkActionsComponent.displayName = 'AlertStatusBulkActionsComponent';
const makeMapStateToProps = () => {
const getTGrid = tGridSelectors.getTGridByIdSelector();
const mapStateToProps = (state: TimelineState, { id }: OwnProps) => {
const timeline: TGridModel = getTGrid(state, id);
const { selectedEventIds, isSelectAllChecked } = timeline;
return {
isSelectAllChecked,
selectedEventIds,
};
};
return mapStateToProps;
};
const mapDispatchToProps = {
clearSelected: tGridActions.clearSelected,
};
const connector = connect(makeMapStateToProps, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;
export const StatefulAlertStatusBulkActions = connector(AlertStatusBulkActionsComponent);
// eslint-disable-next-line import/no-default-export
export { StatefulAlertStatusBulkActions as default };

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiPopover, EuiButtonEmpty, EuiContextMenuPanel } from '@elastic/eui';
import numeral from '@elastic/numeral';
import React, { useState, useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public';
import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants';
import * as i18n from './translations';
interface BulkActionsProps {
timelineId: string;
totalItems: number;
selectedCount: number;
showClearSelection: boolean;
onSelectAll: () => void;
onClearSelection: () => void;
bulkActionItems?: JSX.Element[];
}
const BulkActionsContainer = styled.div`
display: inline-block;
position: relative;
`;
BulkActionsContainer.displayName = 'BulkActionsContainer';
/**
* Stateless component integrating the bulk actions menu and the select all button
*/
const BulkActionsComponent: React.FC<BulkActionsProps> = ({
selectedCount,
totalItems,
showClearSelection,
onSelectAll,
onClearSelection,
bulkActionItems,
}) => {
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false);
const [defaultNumberFormat] = useUiSetting$<string>(DEFAULT_NUMBER_FORMAT);
const formattedTotalCount = useMemo(() => numeral(totalItems).format(defaultNumberFormat), [
defaultNumberFormat,
totalItems,
]);
const formattedSelectedEventsCount = useMemo(
() => numeral(selectedCount).format(defaultNumberFormat),
[defaultNumberFormat, selectedCount]
);
const toggleIsActionOpen = useCallback(() => {
setIsActionsPopoverOpen((currentIsOpen) => !currentIsOpen);
}, [setIsActionsPopoverOpen]);
const closeActionPopover = useCallback(() => {
setIsActionsPopoverOpen(false);
}, [setIsActionsPopoverOpen]);
const toggleSelectAll = useCallback(() => {
if (!showClearSelection) {
onSelectAll();
} else {
onClearSelection();
}
}, [onClearSelection, onSelectAll, showClearSelection]);
const selectedAlertsText = useMemo(
() =>
showClearSelection
? i18n.SELECTED_ALERTS(formattedTotalCount, totalItems)
: i18n.SELECTED_ALERTS(formattedSelectedEventsCount, selectedCount),
[
showClearSelection,
formattedTotalCount,
formattedSelectedEventsCount,
totalItems,
selectedCount,
]
);
const selectClearAllAlertsText = useMemo(
() =>
showClearSelection
? i18n.CLEAR_SELECTION
: i18n.SELECT_ALL_ALERTS(formattedTotalCount, totalItems),
[showClearSelection, formattedTotalCount, totalItems]
);
return (
<BulkActionsContainer data-test-subj="bulk-actions-button-container">
<EuiPopover
isOpen={isActionsPopoverOpen}
anchorPosition="upCenter"
panelPaddingSize="s"
button={
<EuiButtonEmpty
aria-label="selectedShowBulkActions"
data-test-subj="selectedShowBulkActionsButton"
size="xs"
iconType="arrowDown"
iconSide="right"
color="primary"
onClick={toggleIsActionOpen}
>
{selectedAlertsText}
</EuiButtonEmpty>
}
closePopover={closeActionPopover}
>
<EuiContextMenuPanel size="s" items={bulkActionItems} />
</EuiPopover>
<EuiButtonEmpty
size="xs"
aria-label="selectAllAlerts"
data-test-subj="selectAllAlertsButton"
iconType={showClearSelection ? 'cross' : 'pagesSelect'}
onClick={toggleSelectAll}
>
{selectClearAllAlertsText}
</EuiButtonEmpty>
</BulkActionsContainer>
);
};
export const BulkActions = React.memo(BulkActionsComponent);

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SELECTED_ALERTS = (selectedAlertsFormatted: string, selectedAlerts: number) =>
i18n.translate('xpack.timelines.toolbar.bulkActions.selectedAlertsTitle', {
values: { selectedAlertsFormatted, selectedAlerts },
defaultMessage:
'Selected {selectedAlertsFormatted} {selectedAlerts, plural, =1 {alert} other {alerts}}',
});
export const SELECT_ALL_ALERTS = (totalAlertsFormatted: string, totalAlerts: number) =>
i18n.translate('xpack.timelines.toolbar.bulkActions.selectAllAlertsTitle', {
values: { totalAlertsFormatted, totalAlerts },
defaultMessage:
'Select all {totalAlertsFormatted} {totalAlerts, plural, =1 {alert} other {alerts}}',
});
export const CLEAR_SELECTION = i18n.translate(
'xpack.timelines.toolbar.bulkActions.clearSelectionTitle',
{
defaultMessage: 'Clear selection',
}
);

View file

@ -127,7 +127,7 @@ export const StatefulFieldsBrowserComponent: React.FC<FieldBrowserProps> = ({
className={FIELDS_BUTTON_CLASS_NAME}
color="text"
data-test-subj="show-field-browser"
iconType="listAdd"
iconType="tableOfContents"
onClick={onShow}
size="xs"
>

View file

@ -18,3 +18,99 @@ export const EVENTS_TABLE_ARIA_LABEL = ({
values: { activePage, totalPages },
defaultMessage: 'events; Page {activePage} of {totalPages}',
});
export const UNIT = (totalCount: number) =>
i18n.translate('xpack.timelines.timeline.unit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {event} other {events}}`,
});
export const ALERTS_UNIT = (totalCount: number) =>
i18n.translate('xpack.timelines.timeline.alertsUnit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {alert} other {alerts}}`,
});
export const BULK_ACTION_OPEN_SELECTED = i18n.translate(
'xpack.timelines.timeline.openSelectedTitle',
{
defaultMessage: 'Open selected',
}
);
export const BULK_ACTION_CLOSE_SELECTED = i18n.translate(
'xpack.timelines.timeline.closeSelectedTitle',
{
defaultMessage: 'Close selected',
}
);
export const BULK_ACTION_IN_PROGRESS_SELECTED = i18n.translate(
'xpack.timelines.timeline.inProgressSelectedTitle',
{
defaultMessage: 'Mark in progress',
}
);
export const BULK_ACTION_FAILED_SINGLE_ALERT = i18n.translate(
'xpack.timelines.timeline.updateAlertStatusFailedSingleAlert',
{
defaultMessage: 'Failed to update alert because it was already being modified.',
}
);
export const CLOSED_ALERT_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.timelines.timeline.closedAlertSuccessToastMessage', {
values: { totalAlerts },
defaultMessage:
'Successfully closed {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.',
});
export const OPENED_ALERT_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.timelines.timeline.openedAlertSuccessToastMessage', {
values: { totalAlerts },
defaultMessage:
'Successfully opened {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.',
});
export const IN_PROGRESS_ALERT_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.timelines.timeline.inProgressAlertSuccessToastMessage', {
values: { totalAlerts },
defaultMessage:
'Successfully marked {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}} as in progress.',
});
export const CLOSED_ALERT_FAILED_TOAST = i18n.translate(
'xpack.timelines.timeline.closedAlertFailedToastMessage',
{
defaultMessage: 'Failed to close alert(s).',
}
);
export const OPENED_ALERT_FAILED_TOAST = i18n.translate(
'xpack.timelines.timeline.openedAlertFailedToastMessage',
{
defaultMessage: 'Failed to open alert(s)',
}
);
export const IN_PROGRESS_ALERT_FAILED_TOAST = i18n.translate(
'xpack.timelines.timeline.inProgressAlertFailedToastMessage',
{
defaultMessage: 'Failed to mark alert(s) as in progress',
}
);
export const UPDATE_ALERT_STATUS_FAILED = (conflicts: number) =>
i18n.translate('xpack.timelines.timeline.updateAlertStatusFailed', {
values: { conflicts },
defaultMessage:
'Failed to update { conflicts } {conflicts, plural, =1 {alert} other {alerts}}.',
});
export const UPDATE_ALERT_STATUS_FAILED_DETAILED = (updated: number, conflicts: number) =>
i18n.translate('xpack.timelines.timeline.updateAlertStatusFailedDetailed', {
values: { updated, conflicts },
defaultMessage: `{ updated } {updated, plural, =1 {alert was} other {alerts were}} updated successfully, but { conflicts } failed to update
because { conflicts, plural, =1 {it was} other {they were}} already being modified.`,
});

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { UpdateDocumentByQueryResponse } from 'elasticsearch';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import { AlertStatus } from '../../../timelines/common';
export const DETECTION_ENGINE_SIGNALS_STATUS_URL = '/api/detection_engine/signals/status';
/**
* Update alert status by query
*
* @param query of alerts to update
* @param status to update to('open' / 'closed' / 'in-progress')
* @param signal AbortSignal for cancelling request
*
* @throws An error if response is not OK
*/
export const useUpdateAlertsStatus = (): {
updateAlertStatus: (params: {
query: object;
status: AlertStatus;
}) => Promise<UpdateDocumentByQueryResponse>;
} => {
const { http } = useKibana().services;
return {
updateAlertStatus: ({ query, status }) =>
http!.fetch(DETECTION_ENGINE_SIGNALS_STATUS_URL, {
method: 'POST',
body: JSON.stringify({ status, query }),
}),
};
};

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useCallback } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { FILTER_CLOSED, FILTER_IN_PROGRESS, FILTER_OPEN } from '../../common/constants';
import * as i18n from '../components/t_grid/translations';
import type { AlertStatus } from '../../common/types/timeline';
import { useUpdateAlertsStatus } from '../container/use_update_alerts';
export interface SetEventsLoadingProps {
eventIds: string[];
isLoading: boolean;
}
export interface SetEventsDeletedProps {
eventIds: string[];
isDeleted: boolean;
}
export interface StatusBulkActionsProps {
eventIds: string[];
currentStatus?: AlertStatus;
query?: string;
setEventsLoading: (param: SetEventsLoadingProps) => void;
setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void;
onUpdateSuccess: (updated: number, conflicts: number, status: AlertStatus) => void;
onUpdateFailure: (status: AlertStatus, error: Error) => void;
}
export const getUpdateAlertsQuery = (eventIds: Readonly<string[]>) => {
return { bool: { filter: { terms: { _id: eventIds } } } };
};
export const useStatusBulkActionItems = ({
eventIds,
currentStatus,
query,
setEventsLoading,
setEventsDeleted,
onUpdateSuccess,
onUpdateFailure,
}: StatusBulkActionsProps) => {
const { updateAlertStatus } = useUpdateAlertsStatus();
const onClickUpdate = useCallback(
async (status: AlertStatus) => {
try {
setEventsLoading({ eventIds, isLoading: true });
const queryObject = query ? JSON.parse(query) : getUpdateAlertsQuery(eventIds);
const response = await updateAlertStatus({ query: queryObject, status });
// TODO: Only delete those that were successfully updated from updatedRules
setEventsDeleted({ eventIds, isDeleted: true });
if (response.version_conflicts > 0 && eventIds.length === 1) {
throw new Error(i18n.BULK_ACTION_FAILED_SINGLE_ALERT);
}
onUpdateSuccess(response.updated, response.version_conflicts, status);
} catch (error) {
onUpdateFailure(status, error);
} finally {
setEventsLoading({ eventIds, isLoading: false });
}
},
[
eventIds,
query,
setEventsLoading,
updateAlertStatus,
setEventsDeleted,
onUpdateSuccess,
onUpdateFailure,
]
);
const items = useMemo(() => {
const actionItems = [];
if (currentStatus !== FILTER_OPEN) {
actionItems.push(
<EuiContextMenuItem key="open" onClick={() => onClickUpdate(FILTER_OPEN)}>
{i18n.BULK_ACTION_OPEN_SELECTED}
</EuiContextMenuItem>
);
}
if (currentStatus !== FILTER_IN_PROGRESS) {
actionItems.push(
<EuiContextMenuItem key="progress" onClick={() => onClickUpdate(FILTER_IN_PROGRESS)}>
{i18n.BULK_ACTION_IN_PROGRESS_SELECTED}
</EuiContextMenuItem>
);
}
if (currentStatus !== FILTER_CLOSED) {
actionItems.push(
<EuiContextMenuItem key="close" onClick={() => onClickUpdate(FILTER_CLOSED)}>
{i18n.BULK_ACTION_CLOSE_SELECTED}
</EuiContextMenuItem>
);
}
return actionItems;
}, [currentStatus, onClickUpdate]);
return items;
};

View file

@ -75,7 +75,7 @@ export interface TGridModel extends TGridModelSettings {
showCheckboxes: boolean;
/** Specifies which column the timeline is sorted on, and the direction (ascending / descending) */
sort: SortColumnTimeline[];
/** Events selected on this timeline -- eventId to TimelineNonEcsData[] mapping of data required for batch actions **/
/** Events selected on this timeline -- eventId to TimelineNonEcsData[] mapping of data required for bulk actions **/
selectedEventIds: Record<string, TimelineNonEcsData[]>;
savedObjectId: string | null;
version: string | null;

View file

@ -23897,10 +23897,6 @@
"xpack.timelines.draggables.field.typeLabel": "型",
"xpack.timelines.draggables.field.viewCategoryTooltip": "カテゴリーを表示します",
"xpack.timelines.eventDetails.copyToClipboardTooltip": "クリップボードにコピー",
"xpack.timelines.eventsViewer.errorFetchingEventsData": "イベントデータをクエリできませんでした",
"xpack.timelines.eventsViewer.eventsLabel": "イベント",
"xpack.timelines.eventsViewer.footer.loadingEventsDataLabel": "イベントを読み込み中",
"xpack.timelines.eventsViewer.showingLabel": "表示中",
"xpack.timelines.exitFullScreenButton": "全画面を終了",
"xpack.timelines.footer.autoRefreshActiveDescription": "自動更新アクション",
"xpack.timelines.footer.autoRefreshActiveTooltip": "自動更新が有効な間、タイムラインはクエリに一致する最新の {numberOfItems} 件のイベントを表示します。",

View file

@ -24449,11 +24449,6 @@
"xpack.timelines.draggables.field.typeLabel": "类型",
"xpack.timelines.draggables.field.viewCategoryTooltip": "查看类别",
"xpack.timelines.eventDetails.copyToClipboardTooltip": "复制到剪贴板",
"xpack.timelines.eventsViewer.errorFetchingEventsData": "无法查询事件数据",
"xpack.timelines.eventsViewer.eventsLabel": "事件",
"xpack.timelines.eventsViewer.footer.loadingEventsDataLabel": "正在加载事件",
"xpack.timelines.eventsViewer.showingLabel": "正在显示",
"xpack.timelines.eventsViewer.unit": "{totalCount, plural, other {个事件}}",
"xpack.timelines.exitFullScreenButton": "退出全屏",
"xpack.timelines.footer.autoRefreshActiveDescription": "自动刷新已启用",
"xpack.timelines.footer.autoRefreshActiveTooltip": "自动刷新已启用时,时间线将显示匹配查询的最近 {numberOfItems} 个事件。",

View file

@ -82,6 +82,7 @@ const AppRoot = React.memo(
setRefetch,
start: '',
rowRenderers: [],
filterStatus: 'open',
unit: (n: number) => `${n}`,
})) ??
null}