mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Security solution] Grouping package - state management (#152627)
This commit is contained in:
parent
36e8f0d169
commit
8e2ea3ebea
37 changed files with 614 additions and 417 deletions
|
@ -8,24 +8,30 @@
|
|||
|
||||
import React from 'react';
|
||||
import {
|
||||
GroupSelectorProps,
|
||||
Grouping,
|
||||
GroupingProps,
|
||||
GroupSelector,
|
||||
GroupSelectorProps,
|
||||
RawBucket,
|
||||
getGroupingQuery,
|
||||
isNoneGroup,
|
||||
useGrouping,
|
||||
} from './src';
|
||||
import type {
|
||||
GroupOption,
|
||||
GroupingAggregation,
|
||||
GroupingFieldTotalAggregation,
|
||||
NamedAggregation,
|
||||
} from './src';
|
||||
import type { NamedAggregation, GroupingFieldTotalAggregation, GroupingAggregation } from './src';
|
||||
|
||||
export const getGrouping = <T,>(props: GroupingProps<T>): React.ReactElement<GroupingProps<T>> => (
|
||||
<Grouping {...props} />
|
||||
);
|
||||
|
||||
export const getGroupSelector = (
|
||||
props: GroupSelectorProps
|
||||
): React.ReactElement<GroupSelectorProps> => <GroupSelector {...props} />;
|
||||
|
||||
export { isNoneGroup, getGroupingQuery };
|
||||
export { getGroupingQuery, isNoneGroup, useGrouping };
|
||||
|
||||
export type { GroupingAggregation, GroupingFieldTotalAggregation, NamedAggregation, RawBucket };
|
||||
export type {
|
||||
GroupOption,
|
||||
GroupingAggregation,
|
||||
GroupingFieldTotalAggregation,
|
||||
NamedAggregation,
|
||||
RawBucket,
|
||||
};
|
||||
|
|
|
@ -48,7 +48,7 @@ export const EmptyGroupingComponent: React.FC<{ height?: keyof typeof heights }>
|
|||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiImage size="200" alt="" src={noResultsIllustrationLight} />
|
||||
<EuiImage size="200px" alt="" src={noResultsIllustrationLight} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
|
@ -51,10 +51,10 @@ export class CustomFieldPanel extends React.PureComponent<Props, State> {
|
|||
<div data-test-subj="custom-field-panel" style={{ padding: 16 }}>
|
||||
<EuiForm>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('grouping.groupsSelector.customGroupByFieldLabel', {
|
||||
label={i18n.translate('grouping.groupSelector.customGroupByFieldLabel', {
|
||||
defaultMessage: 'Field',
|
||||
})}
|
||||
helpText={i18n.translate('grouping.groupsSelector.customGroupByHelpText', {
|
||||
helpText={i18n.translate('grouping.groupSelector.customGroupByHelpText', {
|
||||
defaultMessage: 'This is the field used for the terms aggregation',
|
||||
})}
|
||||
display="rowCompressed"
|
||||
|
@ -63,7 +63,7 @@ export class CustomFieldPanel extends React.PureComponent<Props, State> {
|
|||
<EuiComboBox
|
||||
data-test-subj="groupByCustomField"
|
||||
placeholder={i18n.translate(
|
||||
'grouping.groupsSelector.customGroupByDropdownPlacehoder',
|
||||
'grouping.groupSelector.customGroupByDropdownPlacehoder',
|
||||
{
|
||||
defaultMessage: 'Select one',
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ import { createGroupFilter } from './accordion_panel/helpers';
|
|||
import type { BadgeMetric, CustomMetric } from './accordion_panel';
|
||||
import { GroupPanel } from './accordion_panel';
|
||||
import { GroupStats } from './accordion_panel/group_stats';
|
||||
import { EmptyGroupingComponent } from './empty_resuls_panel';
|
||||
import { EmptyGroupingComponent } from './empty_results_panel';
|
||||
import { groupingContainerCss, groupsUnitCountCss } from './styles';
|
||||
import { GROUPS_UNIT } from './translations';
|
||||
import type { GroupingAggregation, GroupingFieldTotalAggregation, RawBucket } from './types';
|
||||
|
@ -30,7 +30,7 @@ export interface GroupingProps<T> {
|
|||
customMetricStats?: (fieldBucket: RawBucket<T>) => CustomMetric[];
|
||||
data?: GroupingAggregation<T> & GroupingFieldTotalAggregation;
|
||||
groupPanelRenderer?: (fieldBucket: RawBucket<T>) => JSX.Element | undefined;
|
||||
groupsSelector?: JSX.Element;
|
||||
groupSelector?: JSX.Element;
|
||||
inspectButton?: JSX.Element;
|
||||
isLoading: boolean;
|
||||
pagination: {
|
||||
|
@ -51,7 +51,7 @@ const GroupingComponent = <T,>({
|
|||
customMetricStats,
|
||||
data,
|
||||
groupPanelRenderer,
|
||||
groupsSelector,
|
||||
groupSelector,
|
||||
inspectButton,
|
||||
isLoading,
|
||||
pagination,
|
||||
|
@ -163,7 +163,7 @@ const GroupingComponent = <T,>({
|
|||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="xs">
|
||||
{inspectButton && <EuiFlexItem>{inspectButton}</EuiFlexItem>}
|
||||
<EuiFlexItem>{groupsSelector}</EuiFlexItem>
|
||||
<EuiFlexItem>{groupSelector}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -12,4 +12,4 @@ export * from './group_selector';
|
|||
export * from './types';
|
||||
export * from './grouping';
|
||||
|
||||
export const isNoneGroup = (groupKey: string) => groupKey === NONE_GROUP_KEY;
|
||||
export const isNoneGroup = (groupKey: string | null) => groupKey === NONE_GROUP_KEY;
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import { EMPTY_GROUP_BY_ID, GroupModel, GroupsById, Storage } from './hooks/types';
|
||||
import * as i18n from './components/translations';
|
||||
|
||||
/**
|
||||
|
@ -30,3 +31,23 @@ export function firstNonNullValue<T>(valueOrCollection: ECSField<T>): T | undefi
|
|||
}
|
||||
|
||||
export const defaultUnit = (n: number) => i18n.ALERTS_UNIT(n);
|
||||
|
||||
const LOCAL_STORAGE_GROUPING_KEY = 'groups';
|
||||
export const getAllGroupsInStorage = (storage: Storage): GroupsById => {
|
||||
const allGroups = storage.getItem(LOCAL_STORAGE_GROUPING_KEY);
|
||||
if (!allGroups) {
|
||||
return EMPTY_GROUP_BY_ID;
|
||||
}
|
||||
return JSON.parse(allGroups);
|
||||
};
|
||||
|
||||
export const addGroupsToStorage = (storage: Storage, groupingId: string, group: GroupModel) => {
|
||||
const groups = getAllGroupsInStorage(storage);
|
||||
storage.setItem(
|
||||
LOCAL_STORAGE_GROUPING_KEY,
|
||||
JSON.stringify({
|
||||
...groups,
|
||||
[groupingId]: group,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
ActionType,
|
||||
GroupOption,
|
||||
UpdateActiveGroup,
|
||||
UpdateGroupActivePage,
|
||||
UpdateGroupItemsPerPage,
|
||||
UpdateGroupOptions,
|
||||
} from '../types';
|
||||
|
||||
const updateActiveGroup = ({
|
||||
activeGroup,
|
||||
id,
|
||||
}: {
|
||||
activeGroup: string;
|
||||
id: string;
|
||||
}): UpdateActiveGroup => ({
|
||||
payload: {
|
||||
activeGroup,
|
||||
id,
|
||||
},
|
||||
type: ActionType.updateActiveGroup,
|
||||
});
|
||||
|
||||
const updateGroupActivePage = ({
|
||||
activePage,
|
||||
id,
|
||||
}: {
|
||||
activePage: number;
|
||||
id: string;
|
||||
}): UpdateGroupActivePage => ({
|
||||
payload: {
|
||||
activePage,
|
||||
id,
|
||||
},
|
||||
type: ActionType.updateGroupActivePage,
|
||||
});
|
||||
|
||||
const updateGroupItemsPerPage = ({
|
||||
itemsPerPage,
|
||||
id,
|
||||
}: {
|
||||
itemsPerPage: number;
|
||||
id: string;
|
||||
}): UpdateGroupItemsPerPage => ({
|
||||
payload: {
|
||||
itemsPerPage,
|
||||
id,
|
||||
},
|
||||
type: ActionType.updateGroupItemsPerPage,
|
||||
});
|
||||
|
||||
const updateGroupOptions = ({
|
||||
newOptionList,
|
||||
id,
|
||||
}: {
|
||||
newOptionList: GroupOption[];
|
||||
id: string;
|
||||
}): UpdateGroupOptions => ({
|
||||
payload: {
|
||||
newOptionList,
|
||||
id,
|
||||
},
|
||||
type: ActionType.updateGroupOptions,
|
||||
});
|
||||
|
||||
export const groupActions = {
|
||||
updateActiveGroup,
|
||||
updateGroupActivePage,
|
||||
updateGroupItemsPerPage,
|
||||
updateGroupOptions,
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export * from './actions';
|
||||
export * from './reducer';
|
||||
export * from './selectors';
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
Action,
|
||||
ActionType,
|
||||
defaultGroup,
|
||||
EMPTY_GROUP_BY_ID,
|
||||
GroupMap,
|
||||
GroupsById,
|
||||
Storage,
|
||||
} from '../types';
|
||||
import { addGroupsToStorage, getAllGroupsInStorage } from '../..';
|
||||
|
||||
const storage: Storage = window.localStorage;
|
||||
|
||||
export const initialState: GroupMap = {
|
||||
groupById: EMPTY_GROUP_BY_ID,
|
||||
};
|
||||
|
||||
const groupsReducer = (state: GroupMap, action: Action, groupsById: GroupsById) => {
|
||||
switch (action.type) {
|
||||
case ActionType.updateActiveGroup: {
|
||||
const { id, activeGroup } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
groupById: {
|
||||
...groupsById,
|
||||
[id]: {
|
||||
...groupsById[id],
|
||||
activeGroup,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case ActionType.updateGroupActivePage: {
|
||||
const { id, activePage } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
groupById: {
|
||||
...groupsById,
|
||||
[id]: {
|
||||
...groupsById[id],
|
||||
activePage,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case ActionType.updateGroupItemsPerPage: {
|
||||
const { id, itemsPerPage } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
groupById: {
|
||||
...groupsById,
|
||||
[id]: {
|
||||
...groupsById[id],
|
||||
itemsPerPage,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case ActionType.updateGroupOptions: {
|
||||
const { id, newOptionList } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
groupById: {
|
||||
...groupsById,
|
||||
[id]: {
|
||||
...defaultGroup,
|
||||
...groupsById[id],
|
||||
options: newOptionList,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
throw Error(`Unknown grouping action`);
|
||||
};
|
||||
export const groupsReducerWithStorage = (state: GroupMap, action: Action) => {
|
||||
let groupsInStorage = {};
|
||||
if (storage) {
|
||||
groupsInStorage = getAllGroupsInStorage(storage);
|
||||
}
|
||||
|
||||
const groupsById: GroupsById = {
|
||||
...state.groupById,
|
||||
...groupsInStorage,
|
||||
};
|
||||
|
||||
const newState = groupsReducer(state, action, groupsById);
|
||||
|
||||
if (storage) {
|
||||
const groupId: string = action.payload.id;
|
||||
addGroupsToStorage(storage, groupId, newState.groupById[groupId]);
|
||||
}
|
||||
|
||||
return newState;
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { GroupsById, GroupState } from '../types';
|
||||
|
||||
const selectGroupById = (state: GroupState): GroupsById => state.groups.groupById;
|
||||
|
||||
export const groupByIdSelector = (state: GroupState, id: string) => selectGroupById(state)[id];
|
80
packages/kbn-securitysolution-grouping/src/hooks/types.ts
Normal file
80
packages/kbn-securitysolution-grouping/src/hooks/types.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
// action types
|
||||
export enum ActionType {
|
||||
updateActiveGroup = 'UPDATE_ACTIVE_GROUP',
|
||||
updateGroupActivePage = 'UPDATE_GROUP_ACTIVE_PAGE',
|
||||
updateGroupItemsPerPage = 'UPDATE_GROUP_ITEMS_PER_PAGE',
|
||||
updateGroupOptions = 'UPDATE_GROUP_OPTIONS',
|
||||
}
|
||||
|
||||
export interface UpdateActiveGroup {
|
||||
type: ActionType.updateActiveGroup;
|
||||
payload: { activeGroup: string; id: string };
|
||||
}
|
||||
|
||||
export interface UpdateGroupActivePage {
|
||||
type: ActionType.updateGroupActivePage;
|
||||
payload: { activePage: number; id: string };
|
||||
}
|
||||
export interface UpdateGroupItemsPerPage {
|
||||
type: ActionType.updateGroupItemsPerPage;
|
||||
payload: { itemsPerPage: number; id: string };
|
||||
}
|
||||
export interface UpdateGroupOptions {
|
||||
type: ActionType.updateGroupOptions;
|
||||
payload: { newOptionList: GroupOption[]; id: string };
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| UpdateActiveGroup
|
||||
| UpdateGroupActivePage
|
||||
| UpdateGroupItemsPerPage
|
||||
| UpdateGroupOptions;
|
||||
|
||||
// state
|
||||
|
||||
export interface GroupOption {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GroupModel {
|
||||
activeGroup: string;
|
||||
options: GroupOption[];
|
||||
activePage: number;
|
||||
itemsPerPage: number;
|
||||
}
|
||||
|
||||
export interface GroupsById {
|
||||
[id: string]: GroupModel;
|
||||
}
|
||||
|
||||
export interface GroupMap {
|
||||
groupById: GroupsById;
|
||||
}
|
||||
|
||||
export interface GroupState {
|
||||
groups: GroupMap;
|
||||
}
|
||||
|
||||
export interface Storage<T = any, S = void> {
|
||||
getItem: (key: string) => T | null;
|
||||
setItem: (key: string, value: T) => S;
|
||||
removeItem: (key: string) => T | null;
|
||||
clear: () => void;
|
||||
}
|
||||
export const EMPTY_GROUP_BY_ID: GroupsById = {};
|
||||
|
||||
export const defaultGroup: GroupModel = {
|
||||
activePage: 0,
|
||||
itemsPerPage: 25,
|
||||
activeGroup: 'none',
|
||||
options: [],
|
||||
};
|
|
@ -1,34 +1,36 @@
|
|||
/*
|
||||
* 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.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { FieldSpec } from '@kbn/data-views-plugin/common';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getGroupSelector, isNoneGroup } from '@kbn/securitysolution-grouping';
|
||||
import type { TableId } from '../../../../../common/types';
|
||||
import { getDefaultGroupingOptions } from '../../../../detections/components/alerts_table/grouping_settings';
|
||||
import type { State } from '../../../store';
|
||||
import { defaultGroup } from '../../../store/grouping/defaults';
|
||||
import type { GroupOption } from '../../../store/grouping';
|
||||
import { groupActions, groupSelectors } from '../../../store/grouping';
|
||||
|
||||
import { getGroupSelector, isNoneGroup } from '../..';
|
||||
import { groupActions, groupByIdSelector } from './state';
|
||||
import type { GroupOption } from './types';
|
||||
import { Action, defaultGroup, GroupMap } from './types';
|
||||
|
||||
export interface UseGetGroupSelectorArgs {
|
||||
defaultGroupingOptions: GroupOption[];
|
||||
dispatch: React.Dispatch<Action>;
|
||||
fields: FieldSpec[];
|
||||
groupingId: string;
|
||||
tableId: TableId;
|
||||
groupingState: GroupMap;
|
||||
}
|
||||
|
||||
export const useGetGroupSelector = ({ fields, groupingId, tableId }: UseGetGroupSelectorArgs) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const getGroupByIdSelector = groupSelectors.getGroupByIdSelector();
|
||||
|
||||
export const useGetGroupSelector = ({
|
||||
defaultGroupingOptions,
|
||||
dispatch,
|
||||
fields,
|
||||
groupingId,
|
||||
groupingState,
|
||||
}: UseGetGroupSelectorArgs) => {
|
||||
const { activeGroup: selectedGroup, options } =
|
||||
useSelector((state: State) => getGroupByIdSelector(state, groupingId)) ?? defaultGroup;
|
||||
groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup;
|
||||
|
||||
const setGroupsActivePage = useCallback(
|
||||
(activePage: number) => {
|
||||
|
@ -50,7 +52,6 @@ export const useGetGroupSelector = ({ fields, groupingId, tableId }: UseGetGroup
|
|||
},
|
||||
[dispatch, groupingId]
|
||||
);
|
||||
const defaultGroupingOptions = getDefaultGroupingOptions(tableId);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.length > 0) return;
|
||||
|
@ -71,7 +72,7 @@ export const useGetGroupSelector = ({ fields, groupingId, tableId }: UseGetGroup
|
|||
);
|
||||
}, [defaultGroupingOptions, selectedGroup, setOptions, options]);
|
||||
|
||||
const groupsSelector = getGroupSelector({
|
||||
return getGroupSelector({
|
||||
groupSelected: selectedGroup,
|
||||
'data-test-subj': 'alerts-table-group-selector',
|
||||
onGroupChange: (groupSelection: string) => {
|
||||
|
@ -81,7 +82,10 @@ export const useGetGroupSelector = ({ fields, groupingId, tableId }: UseGetGroup
|
|||
setGroupsActivePage(0);
|
||||
setSelectedGroup(groupSelection);
|
||||
|
||||
if (!isNoneGroup(groupSelection) && !options.find((o) => o.key === groupSelection)) {
|
||||
if (
|
||||
!isNoneGroup(groupSelection) &&
|
||||
!options.find((o: GroupOption) => o.key === groupSelection)
|
||||
) {
|
||||
setOptions([
|
||||
...defaultGroupingOptions,
|
||||
{
|
||||
|
@ -96,6 +100,4 @@ export const useGetGroupSelector = ({ fields, groupingId, tableId }: UseGetGroup
|
|||
fields,
|
||||
options,
|
||||
});
|
||||
|
||||
return groupsSelector;
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { useGrouping } from './use_grouping';
|
||||
|
||||
const defaultGroupingOptions = [
|
||||
{ label: 'ruleName', key: 'kibana.alert.rule.name' },
|
||||
{ label: 'userName', key: 'user.name' },
|
||||
{ label: 'hostName', key: 'host.name' },
|
||||
{ label: 'sourceIP', key: 'source.ip' },
|
||||
];
|
||||
const groupingId = 'test-table';
|
||||
|
||||
const defaultArgs = {
|
||||
defaultGroupingOptions,
|
||||
fields: [],
|
||||
groupingId,
|
||||
};
|
||||
|
||||
const groupingArgs = {
|
||||
from: '2020-07-07T08:20:18.966Z',
|
||||
globalFilters: [],
|
||||
hasIndexMaintenance: true,
|
||||
globalQuery: {
|
||||
query: 'query',
|
||||
language: 'language',
|
||||
},
|
||||
hasIndexWrite: true,
|
||||
isLoading: false,
|
||||
renderChildComponent: jest.fn(),
|
||||
runtimeMappings: {},
|
||||
signalIndexName: 'test',
|
||||
tableId: groupingId,
|
||||
takeActionItems: jest.fn(),
|
||||
to: '2020-07-08T08:20:18.966Z',
|
||||
};
|
||||
|
||||
describe('use_grouping', () => {
|
||||
it('Returns the expected default results on initial mount', () => {
|
||||
const { result } = renderHook(() => useGrouping(defaultArgs));
|
||||
expect(result.current.selectedGroup).toEqual('none');
|
||||
expect(result.current.getGrouping(groupingArgs).props.selectedGroup).toEqual('none');
|
||||
expect(result.current.groupSelector.props.options).toEqual(defaultGroupingOptions);
|
||||
expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { FieldSpec } from '@kbn/data-views-plugin/common';
|
||||
import React, { useCallback, useMemo, useReducer } from 'react';
|
||||
import { groupsReducerWithStorage, initialState } from './state/reducer';
|
||||
import { GroupingProps, GroupSelectorProps } from '..';
|
||||
import { useGroupingPagination } from './use_grouping_pagination';
|
||||
import { groupByIdSelector } from './state';
|
||||
import { useGetGroupSelector } from './use_get_group_selector';
|
||||
import { defaultGroup, GroupOption } from './types';
|
||||
import { Grouping as GroupingComponent } from '../components/grouping';
|
||||
|
||||
interface Grouping<T> {
|
||||
getGrouping: (
|
||||
props: Omit<GroupingProps<T>, 'groupSelector' | 'pagination' | 'selectedGroup'>
|
||||
) => React.ReactElement<GroupingProps<T>>;
|
||||
groupSelector: React.ReactElement<GroupSelectorProps>;
|
||||
pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
};
|
||||
selectedGroup: string;
|
||||
}
|
||||
|
||||
interface GroupingArgs {
|
||||
defaultGroupingOptions: GroupOption[];
|
||||
|
||||
fields: FieldSpec[];
|
||||
groupingId: string;
|
||||
}
|
||||
export const useGrouping = <T,>({
|
||||
defaultGroupingOptions,
|
||||
fields,
|
||||
groupingId,
|
||||
}: GroupingArgs): Grouping<T> => {
|
||||
const [groupingState, dispatch] = useReducer(groupsReducerWithStorage, initialState);
|
||||
|
||||
const { activeGroup: selectedGroup } = useMemo(
|
||||
() => groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup,
|
||||
[groupingId, groupingState]
|
||||
);
|
||||
|
||||
const groupSelector = useGetGroupSelector({
|
||||
defaultGroupingOptions,
|
||||
dispatch,
|
||||
fields,
|
||||
groupingId,
|
||||
groupingState,
|
||||
});
|
||||
|
||||
const pagination = useGroupingPagination({ groupingId, groupingState, dispatch });
|
||||
|
||||
const getGrouping = useCallback(
|
||||
(
|
||||
props: Omit<GroupingProps<T>, 'groupSelector' | 'pagination' | 'selectedGroup'>
|
||||
): React.ReactElement<GroupingProps<T>> => (
|
||||
<GroupingComponent
|
||||
{...props}
|
||||
groupSelector={groupSelector}
|
||||
pagination={pagination}
|
||||
selectedGroup={selectedGroup}
|
||||
/>
|
||||
),
|
||||
[groupSelector, pagination, selectedGroup]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
getGrouping,
|
||||
groupSelector,
|
||||
selectedGroup,
|
||||
pagination: {
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
},
|
||||
}),
|
||||
[getGrouping, groupSelector, pagination.pageIndex, pagination.pageSize, selectedGroup]
|
||||
);
|
||||
};
|
|
@ -1,28 +1,28 @@
|
|||
/*
|
||||
* 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.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { tableDefaults } from '../../../store/data_table/defaults';
|
||||
import { groupActions, groupSelectors } from '../../../store/grouping';
|
||||
import type { State } from '../../../store';
|
||||
import { defaultGroup } from '../../../store/grouping/defaults';
|
||||
import { groupActions, groupByIdSelector } from './state';
|
||||
import { Action, defaultGroup, GroupMap } from './types';
|
||||
|
||||
export interface UseGroupingPaginationArgs {
|
||||
dispatch: React.Dispatch<Action>;
|
||||
groupingId: string;
|
||||
groupingState: GroupMap;
|
||||
}
|
||||
|
||||
export const useGroupingPagination = ({ groupingId }: UseGroupingPaginationArgs) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const getGroupByIdSelector = groupSelectors.getGroupByIdSelector();
|
||||
|
||||
export const useGroupingPagination = ({
|
||||
groupingId,
|
||||
groupingState,
|
||||
dispatch,
|
||||
}: UseGroupingPaginationArgs) => {
|
||||
const { activePage, itemsPerPage } =
|
||||
useSelector((state: State) => getGroupByIdSelector(state, groupingId)) ?? defaultGroup;
|
||||
groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup;
|
||||
|
||||
const setGroupsActivePage = useCallback(
|
||||
(newActivePage: number) => {
|
||||
|
@ -40,16 +40,14 @@ export const useGroupingPagination = ({ groupingId }: UseGroupingPaginationArgs)
|
|||
[dispatch, groupingId]
|
||||
);
|
||||
|
||||
const pagination = useMemo(
|
||||
return useMemo(
|
||||
() => ({
|
||||
pageIndex: activePage,
|
||||
pageSize: itemsPerPage,
|
||||
onChangeItemsPerPage: setGroupsItemsPerPage,
|
||||
onChangePage: setGroupsActivePage,
|
||||
itemsPerPageOptions: tableDefaults.itemsPerPageOptions,
|
||||
itemsPerPageOptions: [10, 25, 50, 100],
|
||||
}),
|
||||
[activePage, itemsPerPage, setGroupsActivePage, setGroupsItemsPerPage]
|
||||
);
|
||||
|
||||
return pagination;
|
||||
};
|
|
@ -9,3 +9,5 @@
|
|||
export * from './components';
|
||||
export * from './containers';
|
||||
export * from './helpers';
|
||||
export * from './hooks/use_grouping';
|
||||
export * from './hooks/types';
|
||||
|
|
|
@ -23,6 +23,6 @@
|
|||
"@kbn/i18n-react",
|
||||
"@kbn/kibana-react-plugin",
|
||||
"@kbn/shared-svg",
|
||||
"@kbn/ui-theme"
|
||||
"@kbn/ui-theme",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ import type { State, SubPluginsInitReducer } from '../common/store';
|
|||
import type { Immutable } from '../../common/endpoint/types';
|
||||
import type { AppAction } from '../common/store/actions';
|
||||
import type { TableState } from '../common/store/data_table/types';
|
||||
import type { GroupMap } from '../common/store/grouping';
|
||||
import type { GroupModel } from '../common/store/grouping';
|
||||
|
||||
export { SecurityPageName } from '../../common/constants';
|
||||
|
||||
|
@ -50,7 +50,7 @@ export type SecuritySubPluginRoutes = RouteProps[];
|
|||
export interface SecuritySubPlugin {
|
||||
routes: SecuritySubPluginRoutes;
|
||||
storageDataTables?: Pick<TableState, 'tableById'>;
|
||||
groups?: Pick<GroupMap, 'groupById'>;
|
||||
groups?: GroupModel;
|
||||
exploreDataTables?: {
|
||||
network: Pick<TableState, 'tableById'>;
|
||||
hosts: Pick<TableState, 'tableById'>;
|
||||
|
|
|
@ -45,7 +45,7 @@ import { getScopePatternListSelection } from '../store/sourcerer/helpers';
|
|||
import { mockBrowserFields, mockIndexFields, mockRuntimeMappings } from '../containers/source/mock';
|
||||
import { usersModel } from '../../explore/users/store';
|
||||
import { UsersFields } from '../../../common/search_strategy/security_solution/users/common';
|
||||
import { defaultGroup } from '../store/grouping/defaults';
|
||||
import { initialGroupingState } from '../store/grouping/reducer';
|
||||
|
||||
export const mockSourcererState = {
|
||||
...initialSourcererState,
|
||||
|
@ -415,13 +415,7 @@ export const mockGlobalState: State = {
|
|||
},
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
groupById: {
|
||||
testing: {
|
||||
...defaultGroup,
|
||||
},
|
||||
},
|
||||
},
|
||||
groups: initialGroupingState,
|
||||
sourcerer: {
|
||||
...mockSourcererState,
|
||||
defaultDataView: {
|
||||
|
|
|
@ -15,7 +15,6 @@ import { createTimelineNoteEpic } from '../../timelines/store/timeline/epic_note
|
|||
import { createTimelinePinnedEventEpic } from '../../timelines/store/timeline/epic_pinned_event';
|
||||
import type { TimelineEpicDependencies } from '../../timelines/store/timeline/types';
|
||||
import { createDataTableLocalStorageEpic } from './data_table/epic_local_storage';
|
||||
import { createGroupingLocalStorageEpic } from './grouping/epic_local_storage_epic';
|
||||
|
||||
export const createRootEpic = <State>(): Epic<
|
||||
Action,
|
||||
|
@ -28,6 +27,5 @@ export const createRootEpic = <State>(): Epic<
|
|||
createTimelineFavoriteEpic<State>(),
|
||||
createTimelineNoteEpic<State>(),
|
||||
createTimelinePinnedEventEpic<State>(),
|
||||
createDataTableLocalStorageEpic<State>(),
|
||||
createGroupingLocalStorageEpic<State>()
|
||||
createDataTableLocalStorageEpic<State>()
|
||||
);
|
||||
|
|
|
@ -6,30 +6,14 @@
|
|||
*/
|
||||
|
||||
import actionCreatorFactory from 'typescript-fsa';
|
||||
import type { GroupOption } from './types';
|
||||
import type React from 'react';
|
||||
|
||||
const actionCreator = actionCreatorFactory('x-pack/security_solution/groups');
|
||||
|
||||
export const updateActiveGroup = actionCreator<{
|
||||
id: string;
|
||||
activeGroup: string;
|
||||
}>('UPDATE_ACTIVE_GROUP');
|
||||
export const updateGroupSelector = actionCreator<{
|
||||
groupSelector: React.ReactElement;
|
||||
}>('UPDATE_GROUP_SELECTOR');
|
||||
|
||||
export const updateGroupActivePage = actionCreator<{
|
||||
id: string;
|
||||
activePage: number;
|
||||
}>('UPDATE_GROUP_ACTIVE_PAGE');
|
||||
|
||||
export const updateGroupItemsPerPage = actionCreator<{
|
||||
id: string;
|
||||
itemsPerPage: number;
|
||||
}>('UPDATE_GROUP_ITEMS_PER_PAGE');
|
||||
|
||||
export const updateGroupOptions = actionCreator<{
|
||||
id: string;
|
||||
newOptionList: GroupOption[];
|
||||
}>('UPDATE_GROUP_OPTIONS');
|
||||
|
||||
export const initGrouping = actionCreator<{
|
||||
id: string;
|
||||
}>('INIT_GROUPING');
|
||||
export const updateSelectedGroup = actionCreator<{
|
||||
selectedGroup: string;
|
||||
}>('UPDATE_SELECTED_GROUP');
|
||||
|
|
|
@ -1,10 +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 type { GroupsById } from './types';
|
||||
|
||||
export const EMPTY_GROUP_BY_ID: GroupsById = {};
|
|
@ -1,15 +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 type { GroupModel } from './types';
|
||||
|
||||
export const defaultGroup: GroupModel = {
|
||||
activePage: 0,
|
||||
itemsPerPage: 25,
|
||||
activeGroup: 'none',
|
||||
options: [],
|
||||
};
|
|
@ -1,50 +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 type { Action } from 'redux';
|
||||
import { map, filter, ignoreElements, tap, withLatestFrom, delay } from 'rxjs/operators';
|
||||
import type { Epic } from 'redux-observable';
|
||||
import { get } from 'lodash/fp';
|
||||
|
||||
import {
|
||||
updateGroupOptions,
|
||||
updateActiveGroup,
|
||||
updateGroupItemsPerPage,
|
||||
updateGroupActivePage,
|
||||
initGrouping,
|
||||
} from './actions';
|
||||
import type { TimelineEpicDependencies } from '../../../timelines/store/timeline/types';
|
||||
import { addGroupsToStorage } from '../../../timelines/containers/local_storage/groups';
|
||||
|
||||
export const isNotNull = <T>(value: T | null): value is T => value !== null;
|
||||
|
||||
const groupingActionTypes = [
|
||||
updateActiveGroup.type,
|
||||
updateGroupActivePage.type,
|
||||
updateGroupItemsPerPage.type,
|
||||
updateGroupOptions.type,
|
||||
initGrouping.type,
|
||||
];
|
||||
|
||||
export const createGroupingLocalStorageEpic =
|
||||
<State>(): Epic<Action, Action, State, TimelineEpicDependencies<State>> =>
|
||||
(action$, state$, { groupByIdSelector, storage }) => {
|
||||
const group$ = state$.pipe(map(groupByIdSelector), filter(isNotNull));
|
||||
return action$.pipe(
|
||||
delay(500),
|
||||
withLatestFrom(group$),
|
||||
tap(([action, groupById]) => {
|
||||
if (groupingActionTypes.includes(action.type)) {
|
||||
if (storage) {
|
||||
const groupId: string = get('payload.id', action);
|
||||
addGroupsToStorage(storage, groupId, groupById[groupId]);
|
||||
}
|
||||
}
|
||||
}),
|
||||
ignoreElements()
|
||||
);
|
||||
};
|
|
@ -5,15 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { AnyAction, CombinedState, Reducer } from 'redux';
|
||||
import * as groupActions from './actions';
|
||||
import * as groupSelectors from './selectors';
|
||||
import type { GroupState } from './types';
|
||||
|
||||
export * from './types';
|
||||
|
||||
export { groupActions, groupSelectors };
|
||||
|
||||
export interface GroupsReducer {
|
||||
groups: Reducer<CombinedState<GroupState>, AnyAction>;
|
||||
}
|
||||
|
|
|
@ -6,70 +6,20 @@
|
|||
*/
|
||||
|
||||
import { reducerWithInitialState } from 'typescript-fsa-reducers';
|
||||
import {
|
||||
initGrouping,
|
||||
updateActiveGroup,
|
||||
updateGroupActivePage,
|
||||
updateGroupItemsPerPage,
|
||||
updateGroupOptions,
|
||||
} from './actions';
|
||||
import { EMPTY_GROUP_BY_ID } from './constants';
|
||||
import { defaultGroup } from './defaults';
|
||||
import type { GroupMap } from './types';
|
||||
import { updateGroupSelector, updateSelectedGroup } from './actions';
|
||||
import type { GroupModel } from './types';
|
||||
|
||||
const initialGroupState: GroupMap = {
|
||||
groupById: EMPTY_GROUP_BY_ID,
|
||||
export const initialGroupingState: GroupModel = {
|
||||
groupSelector: null,
|
||||
selectedGroup: null,
|
||||
};
|
||||
|
||||
export const groupsReducer = reducerWithInitialState(initialGroupState)
|
||||
.case(updateActiveGroup, (state, { id, activeGroup }) => ({
|
||||
export const groupsReducer = reducerWithInitialState(initialGroupingState)
|
||||
.case(updateSelectedGroup, (state, { selectedGroup }) => ({
|
||||
...state,
|
||||
groupById: {
|
||||
...state.groupById,
|
||||
[id]: {
|
||||
...state.groupById[id],
|
||||
activeGroup,
|
||||
},
|
||||
},
|
||||
selectedGroup,
|
||||
}))
|
||||
.case(updateGroupActivePage, (state, { id, activePage }) => ({
|
||||
.case(updateGroupSelector, (state, { groupSelector }) => ({
|
||||
...state,
|
||||
groupById: {
|
||||
...state.groupById,
|
||||
[id]: {
|
||||
...state.groupById[id],
|
||||
activePage,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
.case(updateGroupItemsPerPage, (state, { id, itemsPerPage }) => ({
|
||||
...state,
|
||||
groupById: {
|
||||
...state.groupById,
|
||||
[id]: {
|
||||
...state.groupById[id],
|
||||
itemsPerPage,
|
||||
},
|
||||
},
|
||||
}))
|
||||
.case(updateGroupOptions, (state, { id, newOptionList }) => ({
|
||||
...state,
|
||||
groupById: {
|
||||
...state.groupById,
|
||||
[id]: {
|
||||
...state.groupById[id],
|
||||
options: newOptionList,
|
||||
},
|
||||
},
|
||||
}))
|
||||
.case(initGrouping, (state, { id }) => ({
|
||||
...state,
|
||||
groupById: {
|
||||
...state.groupById,
|
||||
[id]: {
|
||||
...defaultGroup,
|
||||
...state.groupById[id],
|
||||
},
|
||||
},
|
||||
groupSelector,
|
||||
}));
|
||||
|
|
|
@ -6,16 +6,12 @@
|
|||
*/
|
||||
|
||||
import { createSelector } from 'reselect';
|
||||
import type { GroupModel, GroupsById, GroupState } from './types';
|
||||
import type { GroupState } from './types';
|
||||
|
||||
const selectGroupByEntityId = (state: GroupState): GroupsById => state.groups.groupById;
|
||||
const groupSelector = (state: GroupState) => state.groups.groupSelector;
|
||||
|
||||
export const groupByIdSelector = createSelector(
|
||||
selectGroupByEntityId,
|
||||
(groupsByEntityId) => groupsByEntityId
|
||||
);
|
||||
export const getGroupSelector = () => createSelector(groupSelector, (selector) => selector);
|
||||
|
||||
export const selectGroup = (state: GroupState, entityId: string): GroupModel =>
|
||||
state.groups.groupById[entityId];
|
||||
export const selectedGroup = (state: GroupState) => state.groups.selectedGroup;
|
||||
|
||||
export const getGroupByIdSelector = () => createSelector(selectGroup, (group) => group);
|
||||
export const getSelectedGroup = () => createSelector(selectedGroup, (group) => group);
|
||||
|
|
|
@ -5,26 +5,11 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export interface GroupOption {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GroupModel {
|
||||
activeGroup: string;
|
||||
options: GroupOption[];
|
||||
activePage: number;
|
||||
itemsPerPage: number;
|
||||
}
|
||||
|
||||
export interface GroupsById {
|
||||
[id: string]: GroupModel;
|
||||
}
|
||||
|
||||
export interface GroupMap {
|
||||
groupById: GroupsById;
|
||||
groupSelector: React.ReactElement | null;
|
||||
selectedGroup: string | null;
|
||||
}
|
||||
|
||||
export interface GroupState {
|
||||
groups: GroupMap;
|
||||
groups: GroupModel;
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import { mockIndexPattern, mockSourcererState } from '../mock';
|
|||
import { useSourcererDataView } from '../containers/sourcerer';
|
||||
import { useDeepEqualSelector } from '../hooks/use_selector';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { initialGroupingState } from './grouping/reducer';
|
||||
|
||||
jest.mock('../hooks/use_selector');
|
||||
jest.mock('../lib/kibana', () => ({
|
||||
|
@ -45,7 +46,7 @@ describe('createInitialState', () => {
|
|||
dataTable: { tableById: {} },
|
||||
},
|
||||
{
|
||||
groups: { groupById: {} },
|
||||
groups: initialGroupingState,
|
||||
}
|
||||
);
|
||||
beforeEach(() => {
|
||||
|
@ -82,9 +83,7 @@ describe('createInitialState', () => {
|
|||
},
|
||||
},
|
||||
{
|
||||
groups: {
|
||||
groupById: {},
|
||||
},
|
||||
groups: initialGroupingState,
|
||||
}
|
||||
);
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation((cb) => cb(state));
|
||||
|
|
|
@ -24,6 +24,8 @@ import { BehaviorSubject, pluck } from 'rxjs';
|
|||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import reduceReducers from 'reduce-reducers';
|
||||
import { initialGroupingState } from './grouping/reducer';
|
||||
import type { GroupState } from './grouping/types';
|
||||
import {
|
||||
DEFAULT_DATA_VIEW_ID,
|
||||
DEFAULT_INDEX_KEY,
|
||||
|
@ -47,8 +49,6 @@ import { initDataView } from './sourcerer/model';
|
|||
import type { AppObservableLibs, StartedSubPlugins, StartPlugins } from '../../types';
|
||||
import type { ExperimentalFeatures } from '../../../common/experimental_features';
|
||||
import { createSourcererDataView } from '../containers/sourcerer/create_sourcerer_data_view';
|
||||
import type { GroupState } from './grouping/types';
|
||||
import { groupSelectors } from './grouping';
|
||||
|
||||
type ComposeType = typeof compose;
|
||||
declare global {
|
||||
|
@ -130,12 +130,7 @@ export const createStoreFactory = async (
|
|||
};
|
||||
|
||||
const groupsInitialState: GroupState = {
|
||||
groups: {
|
||||
groupById: {
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
...subPlugins.alerts.groups!.groupById,
|
||||
},
|
||||
},
|
||||
groups: initialGroupingState,
|
||||
};
|
||||
|
||||
const timelineReducer = reduceReducers(
|
||||
|
@ -190,7 +185,6 @@ export const createStore = (
|
|||
timelineByIdSelector: timelineSelectors.timelineByIdSelector,
|
||||
timelineTimeRangeSelector: inputsSelectors.timelineTimeRangeSelector,
|
||||
tableByIdSelector: dataTableSelectors.tableByIdSelector,
|
||||
groupByIdSelector: groupSelectors.groupByIdSelector,
|
||||
storage,
|
||||
};
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import { isEmpty } from 'lodash/fp';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Filter, Query } from '@kbn/es-query';
|
||||
import { buildEsQuery } from '@kbn/es-query';
|
||||
|
@ -18,12 +18,9 @@ import type {
|
|||
GroupingAggregation,
|
||||
RawBucket,
|
||||
} from '@kbn/securitysolution-grouping';
|
||||
import { getGrouping, isNoneGroup } from '@kbn/securitysolution-grouping';
|
||||
import { isNoneGroup, useGrouping } from '@kbn/securitysolution-grouping';
|
||||
import type { AlertsGroupingAggregation } from './grouping_settings/types';
|
||||
import { useGetGroupSelector } from '../../../common/containers/grouping/hooks/use_get_group_selector';
|
||||
import type { Status } from '../../../../common/detection_engine/schemas/common';
|
||||
import { defaultGroup } from '../../../common/store/grouping/defaults';
|
||||
import { groupSelectors } from '../../../common/store/grouping';
|
||||
import { InspectButton } from '../../../common/components/inspect';
|
||||
import { defaultUnit } from '../../../common/components/toolbar/unit';
|
||||
import { useGlobalTime } from '../../../common/containers/use_global_time';
|
||||
|
@ -32,7 +29,6 @@ import type { TableIdLiteral } from '../../../../common/types';
|
|||
import { useSourcererDataView } from '../../../common/containers/sourcerer';
|
||||
import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import type { State } from '../../../common/store';
|
||||
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
|
||||
import { useInspectButton } from '../alerts_kpis/common/hooks';
|
||||
|
||||
|
@ -42,13 +38,13 @@ import { useQueryAlerts } from '../../containers/detection_engine/alerts/use_que
|
|||
import { ALERTS_QUERY_NAMES } from '../../containers/detection_engine/alerts/constants';
|
||||
import {
|
||||
getAlertsGroupingQuery,
|
||||
getDefaultGroupingOptions,
|
||||
getSelectedGroupBadgeMetrics,
|
||||
getSelectedGroupButtonContent,
|
||||
getSelectedGroupCustomMetrics,
|
||||
useGroupTakeActionsItems,
|
||||
} from './grouping_settings';
|
||||
import { initGrouping } from '../../../common/store/grouping/actions';
|
||||
import { useGroupingPagination } from '../../../common/containers/grouping/hooks/use_grouping_pagination';
|
||||
import { updateGroupSelector, updateSelectedGroup } from '../../../common/store/grouping/actions';
|
||||
|
||||
const ALERTS_GROUPING_ID = 'alerts-grouping';
|
||||
|
||||
|
@ -86,13 +82,6 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
renderChildComponent,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const groupingId = tableId;
|
||||
|
||||
const getGroupByIdSelector = groupSelectors.getGroupByIdSelector();
|
||||
|
||||
const { activeGroup: selectedGroup } =
|
||||
useSelector((state: State) => getGroupByIdSelector(state, groupingId)) ?? defaultGroup;
|
||||
|
||||
const { browserFields, indexPattern, selectedPatterns } = useSourcererDataView(
|
||||
SourcererScopeName.detections
|
||||
);
|
||||
|
@ -121,6 +110,20 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
[browserFields, defaultFilters, globalFilters, globalQuery, indexPattern, kibana, to, from]
|
||||
);
|
||||
|
||||
const { groupSelector, getGrouping, selectedGroup, pagination } = useGrouping({
|
||||
defaultGroupingOptions: getDefaultGroupingOptions(tableId),
|
||||
groupingId: tableId,
|
||||
fields: indexPattern.fields,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(updateGroupSelector({ groupSelector }));
|
||||
}, [dispatch, groupSelector]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(updateSelectedGroup({ selectedGroup }));
|
||||
}, [dispatch, selectedGroup]);
|
||||
|
||||
useInvalidFilterQuery({
|
||||
id: tableId,
|
||||
filterQuery: getGlobalQuery([])?.filterQuery,
|
||||
|
@ -130,10 +133,6 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
endDate: to,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(initGrouping({ id: tableId }));
|
||||
}, [dispatch, tableId]);
|
||||
|
||||
const { deleteQuery, setQuery } = useGlobalTime(false);
|
||||
// create a unique, but stable (across re-renders) query id
|
||||
const uniqueQueryId = useMemo(() => `${ALERTS_GROUPING_ID}-${uuidv4()}`, []);
|
||||
|
@ -151,10 +150,6 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
}
|
||||
}, [defaultFilters, globalFilters, globalQuery]);
|
||||
|
||||
const pagination = useGroupingPagination({
|
||||
groupingId,
|
||||
});
|
||||
|
||||
const queryGroups = useMemo(
|
||||
() =>
|
||||
getAlertsGroupingQuery({
|
||||
|
@ -217,12 +212,6 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
[uniqueQueryId]
|
||||
);
|
||||
|
||||
const groupsSelector = useGetGroupSelector({
|
||||
tableId,
|
||||
groupingId,
|
||||
fields: indexPattern.fields,
|
||||
});
|
||||
|
||||
const takeActionItems = useGroupTakeActionsItems({
|
||||
indexName: indexPattern.title,
|
||||
currentStatus: currentAlertStatusFilterValue,
|
||||
|
@ -247,23 +236,19 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
data: alertsGroupsData?.aggregations,
|
||||
groupPanelRenderer: (fieldBucket: RawBucket<AlertsGroupingAggregation>) =>
|
||||
getSelectedGroupButtonContent(selectedGroup, fieldBucket),
|
||||
groupsSelector,
|
||||
inspectButton: inspect,
|
||||
isLoading: loading || isLoadingGroups,
|
||||
pagination,
|
||||
renderChildComponent,
|
||||
selectedGroup,
|
||||
takeActionItems: getTakeActionItems,
|
||||
unit: defaultUnit,
|
||||
}),
|
||||
[
|
||||
alertsGroupsData?.aggregations,
|
||||
getGrouping,
|
||||
getTakeActionItems,
|
||||
groupsSelector,
|
||||
inspect,
|
||||
isLoadingGroups,
|
||||
loading,
|
||||
pagination,
|
||||
renderChildComponent,
|
||||
selectedGroup,
|
||||
]
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { GroupOption } from '@kbn/securitysolution-grouping';
|
||||
import { TableId } from '../../../../../common/types';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
|
@ -12,7 +13,7 @@ export * from './group_panel_renderers';
|
|||
export * from './group_take_action_items';
|
||||
export * from './query_builder';
|
||||
|
||||
export const getDefaultGroupingOptions = (tableId: TableId) => {
|
||||
export const getDefaultGroupingOptions = (tableId: TableId): GroupOption[] => {
|
||||
if (tableId === TableId.alertsOnAlertsPage) {
|
||||
return [
|
||||
{
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { waitFor, render, fireEvent } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import useResizeObserver from 'use-resize-observer/polyfilled';
|
||||
|
||||
|
@ -29,7 +29,9 @@ import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_ma
|
|||
import type { State } from '../../../common/store';
|
||||
import { createStore } from '../../../common/store';
|
||||
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
|
||||
import { defaultGroup } from '../../../common/store/grouping/defaults';
|
||||
import { isNoneGroup, useGrouping } from '@kbn/securitysolution-grouping';
|
||||
|
||||
jest.mock('@kbn/securitysolution-grouping');
|
||||
|
||||
jest.mock('../../../common/containers/sourcerer');
|
||||
jest.mock('../../../common/containers/use_global_time', () => ({
|
||||
|
@ -44,22 +46,10 @@ jest.mock('../../../common/containers/use_global_time', () => ({
|
|||
jest.mock('./grouping_settings', () => ({
|
||||
getAlertsGroupingQuery: jest.fn(),
|
||||
getDefaultGroupingOptions: () => [
|
||||
{
|
||||
label: 'ruleName',
|
||||
key: 'kibana.alert.rule.name',
|
||||
},
|
||||
{
|
||||
label: 'userName',
|
||||
key: 'user.name',
|
||||
},
|
||||
{
|
||||
label: 'hostName',
|
||||
key: 'host.name',
|
||||
},
|
||||
{
|
||||
label: 'sourceIP',
|
||||
key: 'source.ip',
|
||||
},
|
||||
{ label: 'ruleName', key: 'kibana.alert.rule.name' },
|
||||
{ label: 'userName', key: 'user.name' },
|
||||
{ label: 'hostName', key: 'host.name' },
|
||||
{ label: 'sourceIP', key: 'source.ip' },
|
||||
],
|
||||
getSelectedGroupBadgeMetrics: jest.fn(),
|
||||
getSelectedGroupButtonContent: jest.fn(),
|
||||
|
@ -153,7 +143,8 @@ const groupingStore = createStore(
|
|||
{
|
||||
...state,
|
||||
groups: {
|
||||
groupById: { [`${TableId.test}`]: { ...defaultGroup, activeGroup: 'host.name' } },
|
||||
groupSelector: <></>,
|
||||
selectedGroup: 'host.name',
|
||||
},
|
||||
},
|
||||
SUB_PLUGINS_REDUCER,
|
||||
|
@ -198,12 +189,35 @@ const testProps: AlertsTableComponentProps = {
|
|||
};
|
||||
|
||||
describe('GroupedAlertsTable', () => {
|
||||
const getGrouping = jest.fn().mockReturnValue(<span data-test-subj={'grouping-table'} />);
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
selectedPatterns: ['myFakebeat-*'],
|
||||
});
|
||||
(isNoneGroup as jest.Mock).mockReturnValue(true);
|
||||
(useGrouping as jest.Mock).mockReturnValue({
|
||||
groupSelector: <></>,
|
||||
getGrouping,
|
||||
selectedGroup: 'host.name',
|
||||
pagination: { pageSize: 1, pageIndex: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the proper initial dispatch actions for groups', () => {
|
||||
render(
|
||||
<TestProviders store={store}>
|
||||
<GroupedAlertsTableComponent {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
expect(mockDispatch.mock.calls[0][0].type).toEqual(
|
||||
'x-pack/security_solution/groups/UPDATE_GROUP_SELECTOR'
|
||||
);
|
||||
expect(mockDispatch.mock.calls[1][0].type).toEqual(
|
||||
'x-pack/security_solution/groups/UPDATE_SELECTED_GROUP'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders alerts table when no group selected', () => {
|
||||
|
@ -216,7 +230,9 @@ describe('GroupedAlertsTable', () => {
|
|||
expect(queryByTestId('grouping-table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders grouped alerts when group selected', () => {
|
||||
it('renders grouped alerts when group selected', async () => {
|
||||
(isNoneGroup as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<TestProviders store={groupingStore}>
|
||||
<GroupedAlertsTableComponent {...testProps} />
|
||||
|
@ -224,46 +240,16 @@ describe('GroupedAlertsTable', () => {
|
|||
);
|
||||
expect(getByTestId('grouping-table')).toBeInTheDocument();
|
||||
expect(queryByTestId('alerts-table')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('is-loading-grouping-table')).not.toBeInTheDocument();
|
||||
expect(getGrouping.mock.calls[0][0].isLoading).toEqual(false);
|
||||
});
|
||||
|
||||
it('renders loading when expected', () => {
|
||||
const { getByTestId } = render(
|
||||
(isNoneGroup as jest.Mock).mockReturnValue(false);
|
||||
render(
|
||||
<TestProviders store={groupingStore}>
|
||||
<GroupedAlertsTableComponent {...testProps} loading={true} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(getByTestId('is-loading-grouping-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Not a valid test as of now.. because, table is used from trigger actions..
|
||||
// Need to find a better way to test grouping
|
||||
// Need to make grouping_alerts independent of Alerts Table.
|
||||
it.skip('it renders groupping fields options when the grouping field is selected', async () => {
|
||||
const { getByTestId, getAllByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
<GroupedAlertsTableComponent
|
||||
tableId={TableId.test}
|
||||
hasIndexWrite
|
||||
hasIndexMaintenance
|
||||
from={'2020-07-07T08:20:18.966Z'}
|
||||
loading={false}
|
||||
to={'2020-07-08T08:20:18.966Z'}
|
||||
globalQuery={{
|
||||
query: 'query',
|
||||
language: 'language',
|
||||
}}
|
||||
globalFilters={[]}
|
||||
runtimeMappings={{}}
|
||||
signalIndexName={'test'}
|
||||
renderChildComponent={() => <></>}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('[data-test-subj="group-selector-dropdown"]')).toBeVisible();
|
||||
fireEvent.click(getAllByTestId('group-selector-dropdown')[0]);
|
||||
expect(getByTestId('[data-test-subj="panel-kibana.alert.rule.name"]')).toBeVisible();
|
||||
});
|
||||
expect(getGrouping.mock.calls[0][0].isLoading).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,11 +8,7 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { isNoneGroup } from '@kbn/securitysolution-grouping';
|
||||
import { useGetGroupSelector } from '../../../common/containers/grouping/hooks/use_get_group_selector';
|
||||
import { defaultGroup } from '../../../common/store/grouping/defaults';
|
||||
import type { State } from '../../../common/store';
|
||||
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
|
||||
import { useSourcererDataView } from '../../../common/containers/sourcerer';
|
||||
import { useDataTableFilters } from '../../../common/hooks/use_data_table_filters';
|
||||
import { dataTableSelectors } from '../../../common/store/data_table';
|
||||
import { changeViewMode } from '../../../common/store/data_table/actions';
|
||||
|
@ -26,18 +22,12 @@ import { groupSelectors } from '../../../common/store/grouping';
|
|||
export const getPersistentControlsHook = (tableId: TableId) => {
|
||||
const usePersistentControls = () => {
|
||||
const dispatch = useDispatch();
|
||||
const getGroupbyIdSelector = groupSelectors.getGroupByIdSelector();
|
||||
const getGroupSelector = groupSelectors.getGroupSelector();
|
||||
|
||||
const { activeGroup: selectedGroup } =
|
||||
useSelector((state: State) => getGroupbyIdSelector(state, tableId)) ?? defaultGroup;
|
||||
const groupSelector = useSelector((state: State) => getGroupSelector(state));
|
||||
const getSelectedGroup = groupSelectors.getSelectedGroup();
|
||||
|
||||
const { indexPattern: indexPatterns } = useSourcererDataView(SourcererScopeName.detections);
|
||||
|
||||
const groupsSelector = useGetGroupSelector({
|
||||
fields: indexPatterns.fields,
|
||||
groupingId: tableId,
|
||||
tableId,
|
||||
});
|
||||
const selectedGroup = useSelector((state: State) => getSelectedGroup(state));
|
||||
|
||||
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
|
||||
|
||||
|
@ -94,10 +84,10 @@ export const getPersistentControlsHook = (tableId: TableId) => {
|
|||
hasRightOffset={false}
|
||||
additionalFilters={additionalFiltersComponent}
|
||||
showInspect={false}
|
||||
additionalMenuOptions={isNoneGroup(selectedGroup) ? [groupsSelector] : []}
|
||||
additionalMenuOptions={isNoneGroup(selectedGroup) ? [groupSelector] : []}
|
||||
/>
|
||||
),
|
||||
[tableView, handleChangeTableView, additionalFiltersComponent, groupsSelector, selectedGroup]
|
||||
[tableView, handleChangeTableView, additionalFiltersComponent, groupSelector, selectedGroup]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -11,7 +11,6 @@ import { TableId } from '../../common/types';
|
|||
import { getDataTablesInStorageByIds } from '../timelines/containers/local_storage';
|
||||
import { routes } from './routes';
|
||||
import type { SecuritySubPlugin } from '../app/types';
|
||||
import { getAllGroupsInStorage } from '../timelines/containers/local_storage/groups';
|
||||
|
||||
export const DETECTIONS_TABLE_IDS: TableIdLiteral[] = [
|
||||
TableId.alertsOnRuleDetailsPage,
|
||||
|
@ -26,9 +25,6 @@ export class Detections {
|
|||
storageDataTables: {
|
||||
tableById: getDataTablesInStorageByIds(storage, DETECTIONS_TABLE_IDS),
|
||||
},
|
||||
groups: {
|
||||
groupById: getAllGroupsInStorage(storage),
|
||||
},
|
||||
routes,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,30 +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 type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { GroupModel } from '../../../common/store/grouping';
|
||||
const LOCAL_STORAGE_GROUPING_KEY = 'groups';
|
||||
|
||||
const EMPTY_GROUP = {} as {
|
||||
[K: string]: GroupModel;
|
||||
};
|
||||
|
||||
export const getAllGroupsInStorage = (storage: Storage) => {
|
||||
const allGroups = storage.get(LOCAL_STORAGE_GROUPING_KEY);
|
||||
if (!allGroups) {
|
||||
return EMPTY_GROUP;
|
||||
}
|
||||
return allGroups;
|
||||
};
|
||||
|
||||
export const addGroupsToStorage = (storage: Storage, groupingId: string, group: GroupModel) => {
|
||||
const groups = getAllGroupsInStorage(storage);
|
||||
storage.set(LOCAL_STORAGE_GROUPING_KEY, {
|
||||
...groups,
|
||||
[groupingId]: group,
|
||||
});
|
||||
};
|
|
@ -11,7 +11,6 @@ import type { Observable } from 'rxjs';
|
|||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import type { FilterManager } from '@kbn/data-plugin/public';
|
||||
import type { GroupsById } from '../../../common/store/grouping';
|
||||
import type {
|
||||
ColumnHeaderOptions,
|
||||
RowRendererId,
|
||||
|
@ -64,7 +63,6 @@ export interface TimelineEpicDependencies<State> {
|
|||
selectAllTimelineQuery: () => (state: State, id: string) => inputsModel.GlobalQuery;
|
||||
selectNotesByIdSelector: (state: State) => NotesById;
|
||||
tableByIdSelector: (state: State) => TableById;
|
||||
groupByIdSelector: (state: State) => GroupsById;
|
||||
kibana$: Observable<CoreStart>;
|
||||
storage: Storage;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue