[Security Solutions] Migrate security inline actions to CellActions (#149120)

Epic: https://github.com/elastic/kibana/issues/144943

## Summary

### API changes
It brings back the `aggregable` field property. It is necessary for
show_top_n. Firstly I thought I could derive this information from the
`field.type` but that isn't possible for all cases.


### New Action
Creates a new trigger for flyouts that contain one extra action named
`toggleColumn`. The action toggles a column in the table that opened the
flyout.
<img width="277" alt="Screenshot 2023-02-02 at 15 39 16"
src="https://user-images.githubusercontent.com/1490444/216354526-13e4ea1c-c0a6-444e-9640-c1e66d4f917c.png">


### Migration
Migrate security solution inline actions (actions that show inlined and
not inside a hover popover) to use CellActions.
It includes: 


* Entity analytics page: Risk table
* Event flyout: Highlighted events and summary overview
* Event details Table
* Event details enrichment summary

<img width="1166" alt="Screenshot 2023-01-25 at 13 51 28"
src="https://user-images.githubusercontent.com/1490444/214568144-3e9b2372-9882-4eff-8055-22a4ff52a7bd.png">
<img width="1783" alt="Screenshot 2023-01-25 at 13 52 00"
src="https://user-images.githubusercontent.com/1490444/214568151-0a79e87d-5f87-438f-8365-46be7c43ac31.png">
<img width="905" alt="Screenshot 2023-02-02 at 15 35 07"
src="https://user-images.githubusercontent.com/1490444/216353625-371d7cbc-94b7-4324-a177-4027917801f4.png">
<img width="894" alt="Screenshot 2023-02-02 at 15 35 21"
src="https://user-images.githubusercontent.com/1490444/216353634-e9a02eba-e139-40b4-990b-12d547425fbd.png">


Todo:

- [x] Check with Paul the best position for the new action


### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Pablo Machado 2023-02-10 16:30:22 +01:00 committed by GitHub
parent 2816d2bf8c
commit c3adc5b29c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 925 additions and 922 deletions

View file

@ -7,6 +7,7 @@
*/
import React, { useMemo, useRef } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { InlineActions } from './inline_actions';
import { HoverActionsPopover } from './hover_actions_popover';
import { CellActionsMode, type CellActionsProps, type CellActionExecutionContext } from '../types';
@ -18,7 +19,9 @@ export const CellActions: React.FC<CellActionsProps> = ({
mode,
showActionTooltips = true,
visibleCellActions = 3,
disabledActions = [],
metadata,
className,
}) => {
const extraContentNodeRef = useRef<HTMLDivElement | null>(null);
const nodeRef = useRef<HTMLDivElement | null>(null);
@ -35,14 +38,14 @@ export const CellActions: React.FC<CellActionsProps> = ({
);
const dataTestSubj = `cellActions-renderContent-${field.name}`;
if (mode === CellActionsMode.HOVER) {
return (
<div ref={nodeRef} data-test-subj={dataTestSubj}>
<div className={className} ref={nodeRef} data-test-subj={dataTestSubj}>
<HoverActionsPopover
actionContext={actionContext}
showActionTooltips={showActionTooltips}
visibleCellActions={visibleCellActions}
disabledActions={disabledActions}
>
{children}
</HoverActionsPopover>
@ -53,14 +56,23 @@ export const CellActions: React.FC<CellActionsProps> = ({
}
return (
<div ref={nodeRef} data-test-subj={dataTestSubj}>
{children}
<InlineActions
actionContext={actionContext}
showActionTooltips={showActionTooltips}
visibleCellActions={visibleCellActions}
/>
<div ref={extraContentNodeRef} />
</div>
<EuiFlexGroup
responsive={false}
alignItems="center"
ref={nodeRef}
gutterSize="none"
justifyContent="flexStart"
>
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
<EuiFlexItem grow={false} className={className} data-test-subj={dataTestSubj}>
<InlineActions
actionContext={actionContext}
showActionTooltips={showActionTooltips}
visibleCellActions={visibleCellActions}
disabledActions={disabledActions}
/>
<div ref={extraContentNodeRef} />
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -111,6 +111,7 @@ const ExtraActionsPopOverContent: React.FC<ExtraActionsPopOverContentProps> = ({
key={action.id}
icon={action.getIconType(actionContext)}
aria-label={action.getDisplayName(actionContext)}
data-test-subj={`actionItem-${action.id}`}
onClick={() => {
closePopOver();
action.execute(actionContext);

View file

@ -8,12 +8,16 @@
import { act, fireEvent, render } from '@testing-library/react';
import React from 'react';
import { makeAction, makeActionContext } from '../mocks/helpers';
import { CellActionsProvider } from '../context';
import { makeAction } from '../mocks/helpers';
import { CellActionExecutionContext } from '../types';
import { HoverActionsPopover } from './hover_actions_popover';
import { CellActionsProvider } from '../context/cell_actions_context';
describe('HoverActionsPopover', () => {
const actionContext = makeActionContext();
const actionContext = {
trigger: { id: 'triggerId' },
field: { name: 'fieldName' },
} as CellActionExecutionContext;
const TestComponent = () => <span data-test-subj="test-component" />;
jest.useFakeTimers();
@ -22,6 +26,7 @@ describe('HoverActionsPopover', () => {
const { queryByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActions={[]}
children={null}
visibleCellActions={4}
actionContext={actionContext}
@ -40,6 +45,7 @@ describe('HoverActionsPopover', () => {
const { queryByLabelText, getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActions={[]}
visibleCellActions={4}
actionContext={actionContext}
showActionTooltips={false}
@ -65,6 +71,7 @@ describe('HoverActionsPopover', () => {
const { queryByLabelText, getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActions={[]}
visibleCellActions={4}
actionContext={actionContext}
showActionTooltips={false}
@ -95,6 +102,7 @@ describe('HoverActionsPopover', () => {
const { getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActions={[]}
visibleCellActions={1}
actionContext={actionContext}
showActionTooltips={false}
@ -120,6 +128,7 @@ describe('HoverActionsPopover', () => {
const { getByTestId, getByLabelText } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActions={[]}
visibleCellActions={1}
actionContext={actionContext}
showActionTooltips={false}
@ -154,6 +163,7 @@ describe('HoverActionsPopover', () => {
const { getByTestId, queryByLabelText } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
disabledActions={[]}
visibleCellActions={2}
actionContext={actionContext}
showActionTooltips={false}

View file

@ -40,6 +40,7 @@ interface Props {
visibleCellActions: number;
actionContext: CellActionExecutionContext;
showActionTooltips: boolean;
disabledActions: string[];
}
export const HoverActionsPopover: React.FC<Props> = ({
@ -47,12 +48,13 @@ export const HoverActionsPopover: React.FC<Props> = ({
visibleCellActions,
actionContext,
showActionTooltips,
disabledActions,
}) => {
const contentRef = useRef<HTMLDivElement>(null);
const [isExtraActionsPopoverOpen, setIsExtraActionsPopoverOpen] = useState(false);
const [showHoverContent, setShowHoverContent] = useState(false);
const [{ value: actions }, loadActions] = useLoadActionsFn();
const [{ value: actions }, loadActions] = useLoadActionsFn({ disabledActions });
const { visibleActions, extraActions } = useMemo(
() => partitionActions(actions ?? [], visibleCellActions),

View file

@ -8,19 +8,20 @@
import { act, render } from '@testing-library/react';
import React from 'react';
import { makeAction, makeActionContext } from '../mocks/helpers';
import { makeAction } from '../mocks/helpers';
import { InlineActions } from './inline_actions';
import { CellActionsProvider } from '../context/cell_actions_context';
import { CellActionExecutionContext } from '../types';
import { CellActionsProvider } from '../context';
describe('InlineActions', () => {
const actionContext = makeActionContext();
const actionContext = { trigger: { id: 'triggerId' } } as CellActionExecutionContext;
it('renders', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;
const { queryByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<InlineActions
disabledActions={[]}
visibleCellActions={5}
actionContext={actionContext}
showActionTooltips={false}
@ -47,6 +48,7 @@ describe('InlineActions', () => {
const { queryAllByRole } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<InlineActions
disabledActions={[]}
visibleCellActions={5}
actionContext={actionContext}
showActionTooltips={false}

View file

@ -7,6 +7,7 @@
*/
import React, { useCallback, useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { ActionItem } from './cell_action_item';
import { usePartitionActions } from '../hooks/actions';
import { ExtraActionsPopOver } from './extra_actions_popover';
@ -18,18 +19,18 @@ interface InlineActionsProps {
actionContext: CellActionExecutionContext;
showActionTooltips: boolean;
visibleCellActions: number;
disabledActions: string[];
}
export const InlineActions: React.FC<InlineActionsProps> = ({
actionContext,
showActionTooltips,
visibleCellActions,
disabledActions,
}) => {
const { value: allActions } = useLoadActions(actionContext);
const { extraActions, visibleActions } = usePartitionActions(
allActions ?? [],
visibleCellActions
);
const { value: actions } = useLoadActions(actionContext, { disabledActions });
const { extraActions, visibleActions } = usePartitionActions(actions ?? [], visibleCellActions);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const togglePopOver = useCallback(() => setIsPopoverOpen((isOpen) => !isOpen), []);
const closePopOver = useCallback(() => setIsPopoverOpen(false), []);
@ -39,24 +40,34 @@ export const InlineActions: React.FC<InlineActionsProps> = ({
);
return (
<span data-test-subj="inlineActions">
<EuiFlexGroup
responsive={false}
alignItems="flexStart"
gutterSize="none"
data-test-subj="inlineActions"
className={`inlineActions ${isPopoverOpen ? 'inlineActions-popoverOpen' : ''}`}
>
{visibleActions.map((action, index) => (
<ActionItem
key={`action-item-${index}`}
action={action}
actionContext={actionContext}
showTooltip={showActionTooltips}
/>
<EuiFlexItem grow={false}>
<ActionItem
key={`action-item-${index}`}
action={action}
actionContext={actionContext}
showTooltip={showActionTooltips}
/>
</EuiFlexItem>
))}
{extraActions.length > 0 ? (
<ExtraActionsPopOver
actions={extraActions}
actionContext={actionContext}
button={button}
closePopOver={closePopOver}
isOpen={isPopoverOpen}
/>
<EuiFlexItem grow={false}>
<ExtraActionsPopOver
actions={extraActions}
actionContext={actionContext}
button={button}
closePopOver={closePopOver}
isOpen={isPopoverOpen}
/>
</EuiFlexItem>
) : null}
</span>
</EuiFlexGroup>
);
};

View file

@ -25,13 +25,14 @@ interface BulkField extends Pick<CellActionField, 'name' | 'type'> {
}
export interface UseDataGridColumnsCellActionsProps
extends Pick<CellActionsProps, 'triggerId' | 'metadata'> {
extends Pick<CellActionsProps, 'triggerId' | 'metadata' | 'disabledActions'> {
fields: BulkField[];
}
export const useDataGridColumnsCellActions = ({
fields,
triggerId,
metadata,
disabledActions = [],
}: UseDataGridColumnsCellActionsProps): EuiDataGridColumnCellAction[][] => {
const bulkContexts: CellActionCompatibilityContext[] = useMemo(
() =>
@ -43,7 +44,7 @@ export const useDataGridColumnsCellActions = ({
[fields, triggerId, metadata]
);
const { loading, value: columnsActions } = useBulkLoadActions(bulkContexts);
const { loading, value: columnsActions } = useBulkLoadActions(bulkContexts, { disabledActions });
const columnsCellActions = useMemo<EuiDataGridColumnCellAction[][]>(() => {
if (loading) {

View file

@ -11,7 +11,7 @@ import { makeAction, makeActionContext } from '../mocks/helpers';
import { useBulkLoadActions, useLoadActions, useLoadActionsFn } from './use_load_actions';
const action = makeAction('action-1', 'icon', 1);
const mockGetActions = jest.fn(async () => [action]);
const mockGetActions = jest.fn();
jest.mock('../context/cell_actions_context', () => ({
useCellActionsContext: () => ({ getActions: mockGetActions }),
}));
@ -21,6 +21,7 @@ describe('loadActions hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetActions.mockResolvedValue([action]);
});
describe('useLoadActions', () => {
it('should load actions when called', async () => {
@ -50,6 +51,20 @@ describe('loadActions hooks', () => {
expect(result.error?.message).toEqual(message);
});
it('filters out disabled actions', async () => {
const actionEnabled = makeAction('action-enabled');
const actionDisabled = makeAction('action-disabled');
mockGetActions.mockResolvedValue([actionEnabled, actionDisabled]);
const { result, waitForNextUpdate } = renderHook(() =>
useLoadActions(actionContext, { disabledActions: [actionDisabled.id] })
);
await waitForNextUpdate();
expect(result.current.value).toEqual([actionEnabled]);
});
});
describe('useLoadActionsFn', () => {
@ -94,6 +109,25 @@ describe('loadActions hooks', () => {
expect(result.error?.message).toEqual(message);
});
it('filters out disabled actions', async () => {
const actionEnabled = makeAction('action-enabled');
const actionDisabled = makeAction('action-disabled');
mockGetActions.mockResolvedValue([actionEnabled, actionDisabled]);
const { result, waitForNextUpdate } = renderHook(() =>
useLoadActionsFn({ disabledActions: [actionDisabled.id] })
);
const [_, loadActions] = result.current;
act(() => {
loadActions(actionContext);
});
await waitForNextUpdate();
const [{ value: valueAfterUpdate }] = result.current;
expect(valueAfterUpdate).toEqual([actionEnabled]);
});
});
describe('useBulkLoadActions', () => {
@ -128,5 +162,19 @@ describe('loadActions hooks', () => {
expect(result.error?.message).toEqual(message);
});
it('filters out disabled actions', async () => {
const actionEnabled = makeAction('action-enabled');
const actionDisabled = makeAction('action-disabled');
mockGetActions.mockResolvedValue([actionEnabled, actionDisabled]);
const { result, waitForNextUpdate } = renderHook(() =>
useBulkLoadActions(actionContexts, { disabledActions: [actionDisabled.id] })
);
await waitForNextUpdate();
expect(result.current.value).toEqual([[actionEnabled], [actionEnabled]]);
});
});
});

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { useMemo } from 'react';
import useAsync from 'react-use/lib/useAsync';
import useAsyncFn, { type AsyncState } from 'react-use/lib/useAsyncFn';
import { useCellActionsContext } from '../context/cell_actions_context';
@ -22,34 +23,57 @@ const useThrowError = (error?: Error) => {
/**
* Performs the getActions async call and returns its value
*/
export const useLoadActions = (context: CellActionCompatibilityContext): AsyncActions => {
export const useLoadActions = (
context: CellActionCompatibilityContext,
options: LoadActionsOptions = {}
): AsyncActions => {
const { getActions } = useCellActionsContext();
const { error, ...actionsState } = useAsync(() => getActions(context), []);
const { error, value, loading } = useAsync(() => getActions(context), []);
const filteredActions = useFilteredActions(value, options.disabledActions);
useThrowError(error);
return actionsState;
return { value: filteredActions, loading };
};
/**
* Returns a function to perform the getActions async call
*/
export const useLoadActionsFn = (): [AsyncActions, GetActions] => {
export const useLoadActionsFn = (options: LoadActionsOptions = {}): [AsyncActions, GetActions] => {
const { getActions } = useCellActionsContext();
const [{ error, ...actionsState }, loadActions] = useAsyncFn(getActions, []);
const [{ error, value, loading }, loadActions] = useAsyncFn(getActions, []);
const filteredActions = useFilteredActions(value, options.disabledActions);
useThrowError(error);
return [actionsState, loadActions];
return [{ value: filteredActions, loading }, loadActions];
};
interface LoadActionsOptions {
disabledActions?: string[];
}
/**
* Groups getActions calls for an array of contexts in one async bulk operation
*/
export const useBulkLoadActions = (
contexts: CellActionCompatibilityContext[]
contexts: CellActionCompatibilityContext[],
options: LoadActionsOptions = {}
): AsyncActions<CellAction[][]> => {
const { getActions } = useCellActionsContext();
const { error, ...actionsState } = useAsync(
() => Promise.all(contexts.map((context) => getActions(context))),
() =>
Promise.all(
contexts.map((context) =>
getActions(context).then(
(actions) => filteredActions(actions, options.disabledActions) ?? []
)
)
),
[]
);
useThrowError(error);
return actionsState;
};
const useFilteredActions = (actions: CellAction[] | undefined, disabledActions?: string[]) =>
useMemo(() => filteredActions(actions, disabledActions), [actions, disabledActions]);
const filteredActions = (actions: CellAction[] | undefined, disabledActions: string[] = []) =>
actions ? actions.filter(({ id }) => !disabledActions?.includes(id)) : undefined;

View file

@ -36,6 +36,14 @@ export interface CellActionField {
* Example: 'My-Laptop'
*/
value: string | string[] | null | undefined;
/**
* When true the field supports aggregations.
*
* It defaults to false.
*
* You can verify if a field is aggregatable on kibana/management/kibana/dataViews.
*/
aggregatable?: boolean;
}
export enum CellActionsMode {
@ -69,14 +77,22 @@ export interface CellActionsProps {
* It shows 'more actions' button when the number of actions is bigger than this parameter.
*/
visibleCellActions?: number;
/**
* List of Actions ids that shouldn't be displayed inside cell actions.
*/
disabledActions?: string[];
/**
* Custom set of properties used by some actions.
* An action might require a specific set of metadata properties to render.
* This data is sent directly to actions.
*/
metadata?: Record<string, unknown>;
className?: string;
}
type Metadata = Record<string, unknown> | undefined;
export interface CellActionExecutionContext extends ActionExecutionContext {
field: CellActionField;
/**
@ -92,18 +108,19 @@ export interface CellActionExecutionContext extends ActionExecutionContext {
/**
* Extra configurations for actions.
*/
metadata?: Record<string, unknown>;
metadata?: Metadata;
}
export interface CellActionCompatibilityContext extends ActionExecutionContext {
export interface CellActionCompatibilityContext<M extends Metadata = Metadata>
extends ActionExecutionContext {
/**
* The object containing the field name and type, needed for the compatibility check
*/
field: Pick<CellActionField, 'name' | 'type'>;
field: Pick<CellActionField, 'name' | 'type' | 'aggregatable'>;
/**
* Extra configurations for actions.
*/
metadata?: Record<string, unknown>;
metadata?: M;
}
export interface CellAction<C extends CellActionExecutionContext = CellActionExecutionContext>
@ -112,7 +129,7 @@ export interface CellAction<C extends CellActionExecutionContext = CellActionExe
* Returns a promise that resolves to true if this action is compatible given the context,
* otherwise resolves to false.
*/
isCompatible(context: CellActionCompatibilityContext): Promise<boolean>;
isCompatible(context: CellActionCompatibilityContext<C['metadata']>): Promise<boolean>;
}
export type GetActions = (context: CellActionCompatibilityContext) => Promise<CellAction[]>;

View file

@ -501,3 +501,4 @@ export const DEFAULT_DETECTION_PAGE_FILTERS = [
export const CELL_ACTIONS_DEFAULT_TRIGGER = 'security-solution-default-cellActions';
export const CELL_ACTIONS_TIMELINE_TRIGGER = 'security-solution-timeline-cellActions';
export const CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER = 'security-solution-details-flyout-cellActions';

View file

@ -60,9 +60,10 @@ export const HOST_KPI = '[data-test-subj="siem-timeline-host-kpi"]';
export const ID_HEADER_FIELD = '[data-test-subj="timeline"] [data-test-subj="header-text-_id"]';
export const ID_TOGGLE_FIELD = '[data-test-subj="toggle-field-_id"]';
export const ID_TOGGLE_FIELD = '[data-test-subj="actionItem-security_toggleColumn"]';
export const ID_HOVER_ACTION_OVERFLOW_BTN = '[data-test-subj="more-actions-_id"]';
export const ID_HOVER_ACTION_OVERFLOW_BTN =
'[data-test-subj="event-fields-table-row-_id"] [data-test-subj="showExtraActionsButton"]';
export const LOCKED_ICON = '[data-test-subj="timeline-date-picker-lock-button"]';
@ -237,7 +238,7 @@ export const TIMELINE_TITLE_INPUT = '[data-test-subj="save-timeline-title"]';
export const TIMESTAMP_HEADER_FIELD = '[data-test-subj="header-text-@timestamp"]';
export const TIMESTAMP_TOGGLE_FIELD = '[data-test-subj="toggle-field-@timestamp"]';
export const TIMESTAMP_TOGGLE_FIELD = '[data-test-subj="actionItem-security_toggleColumn"]';
export const TOGGLE_TIMELINE_EXPAND_EVENT = '[data-test-subj="expand-event"]';
@ -278,7 +279,8 @@ export const TIMELINE_TAB_CONTENT_EQL = '[data-test-subj="timeline-tab-content-e
export const TIMELINE_TAB_CONTENT_GRAPHS_NOTES =
'[data-test-subj="timeline-tab-content-graph-notes"]';
export const TIMESTAMP_HOVER_ACTION_OVERFLOW_BTN = '[data-test-subj="more-actions-@timestamp"]';
export const TIMESTAMP_HOVER_ACTION_OVERFLOW_BTN =
'[data-test-subj="event-fields-table-row-@timestamp"] [data-test-subj="showExtraActionsButton"]';
export const USER_KPI = '[data-test-subj="siem-timeline-user-kpi"]';

View file

@ -14,12 +14,12 @@ import { fieldHasCellActions } from '../../utils';
export const FILTER_IN = i18n.translate('xpack.securitySolution.actions.filterIn', {
defaultMessage: 'Filter In',
});
const ID = 'security_filterIn';
export const ACTION_ID = 'security_filterIn';
const ICON = 'plusInCircle';
export const createFilterInAction = ({ order }: { order?: number }): CellAction => ({
id: ID,
type: ID,
id: ACTION_ID,
type: ACTION_ID,
order,
getIconType: (): string => ICON,
getDisplayName: () => FILTER_IN,

View file

@ -14,12 +14,12 @@ import { fieldHasCellActions } from '../../utils';
export const FILTER_OUT = i18n.translate('xpack.securitySolution.actions.filterOut', {
defaultMessage: 'Filter Out',
});
const ID = 'security_filterOut';
export const ACTION_ID = 'security_filterOut';
const ICON = 'minusInCircle';
export const createFilterOutAction = ({ order }: { order?: number }): CellAction => ({
id: ID,
type: ID,
id: ACTION_ID,
type: ACTION_ID,
order,
getIconType: (): string => ICON,
getDisplayName: () => FILTER_OUT,

View file

@ -24,8 +24,10 @@ import { createLensAddToTimelineAction, createDefaultAddToTimelineAction } from
import { createDefaultShowTopNAction } from './show_top_n';
import {
CELL_ACTIONS_DEFAULT_TRIGGER,
CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER,
CELL_ACTIONS_TIMELINE_TRIGGER,
} from '../../common/constants';
import { createDefaultToggleColumnAction } from './toggle_column';
export const registerUIActions = (
plugins: StartPlugins,
@ -36,6 +38,7 @@ export const registerUIActions = (
registerLensActions(plugins.uiActions, store);
registerDefaultActions(plugins.uiActions, store, history, services);
registerTimelineActions(plugins.uiActions, store, history, services);
registerTableFlyoutActions(plugins.uiActions, store, history, services);
};
const registerLensActions = (uiActions: UiActionsStart, store: SecurityAppStore) => {
@ -53,14 +56,14 @@ const registerDefaultActions = (
services: StartServices
) => {
const filterInAction = createDefaultFilterInAction({
order: 1,
order: 10,
});
const filterOutAction = createDefaultFilterOutAction({
order: 2,
order: 20,
});
const addToTimeline = createDefaultAddToTimelineAction({ store, order: 3 });
const showTopNAction = createDefaultShowTopNAction({ store, history, services, order: 4 });
const copyAction = createDefaultCopyToClipboardAction({ order: 5 });
const addToTimeline = createDefaultAddToTimelineAction({ store, order: 30 });
const showTopNAction = createDefaultShowTopNAction({ store, history, services, order: 40 });
const copyAction = createDefaultCopyToClipboardAction({ order: 50 });
uiActions.registerTrigger({
id: CELL_ACTIONS_DEFAULT_TRIGGER,
@ -81,15 +84,15 @@ const registerTimelineActions = (
) => {
const filterInAction = createTimelineFilterInAction({
store,
order: 1,
order: 10,
});
const filterOutAction = createTimelineFilterOutAction({
store,
order: 2,
order: 20,
});
const addToTimeline = createDefaultAddToTimelineAction({ store, order: 3 });
const showTopNAction = createDefaultShowTopNAction({ store, history, services, order: 4 });
const copyAction = createDefaultCopyToClipboardAction({ order: 5 });
const addToTimeline = createDefaultAddToTimelineAction({ store, order: 30 });
const showTopNAction = createDefaultShowTopNAction({ store, history, services, order: 40 });
const copyAction = createDefaultCopyToClipboardAction({ order: 50 });
uiActions.registerTrigger({
id: CELL_ACTIONS_TIMELINE_TRIGGER,
@ -101,3 +104,36 @@ const registerTimelineActions = (
uiActions.addTriggerAction(CELL_ACTIONS_TIMELINE_TRIGGER, showTopNAction);
uiActions.addTriggerAction(CELL_ACTIONS_TIMELINE_TRIGGER, addToTimeline);
};
/**
* This actions show up in when a details flyout is open from a table field.
*/
const registerTableFlyoutActions = (
uiActions: UiActionsStart,
store: SecurityAppStore,
history: H.History,
services: StartServices
) => {
const filterInAction = createDefaultFilterInAction({
order: 10,
});
const filterOutAction = createDefaultFilterOutAction({
order: 20,
});
const addToTimeline = createDefaultAddToTimelineAction({ store, order: 30 });
const toggleAction = createDefaultToggleColumnAction({ store, order: 35 });
const showTopNAction = createDefaultShowTopNAction({ store, history, services, order: 40 });
const copyAction = createDefaultCopyToClipboardAction({ order: 50 });
uiActions.registerTrigger({
id: CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER,
});
uiActions.addTriggerAction(CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER, copyAction);
uiActions.addTriggerAction(CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER, filterInAction);
uiActions.addTriggerAction(CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER, filterOutAction);
uiActions.addTriggerAction(CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER, showTopNAction);
uiActions.addTriggerAction(CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER, addToTimeline);
uiActions.addTriggerAction(CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER, toggleAction);
};

View file

@ -51,7 +51,7 @@ describe('createShowTopNAction', () => {
services: mockServices,
});
const context = {
field: { name: 'user.name', value: 'the-value', type: 'keyword' },
field: { name: 'user.name', value: 'the-value', type: 'keyword', aggregatable: true },
trigger: { id: 'trigger' },
extraContentNodeRef: {
current: element,
@ -90,6 +90,15 @@ describe('createShowTopNAction', () => {
await showTopNAction.isCompatible({ ...context, field: { ...context.field, type: 'text' } })
).toEqual(false);
});
it('should return false if field is not aggregatable', async () => {
expect(
await showTopNAction.isCompatible({
...context,
field: { ...context.field, aggregatable: false },
})
).toEqual(false);
});
});
describe('execute', () => {

View file

@ -28,7 +28,7 @@ const SHOW_TOP = (fieldName: string) =>
defaultMessage: `Show top {fieldName}`,
});
const ID = 'security_showTopN';
export const ACTION_ID = 'security_showTopN';
const ICON = 'visBarVertical';
const UNSUPPORTED_FIELD_TYPES = ['date', 'text'];
@ -56,8 +56,8 @@ export const createShowTopNAction = ({
});
return {
id: ID,
type: ID,
id: ACTION_ID,
type: ACTION_ID,
order,
getIconType: (): string => ICON,
getDisplayName: ({ field }) => SHOW_TOP(field.name),
@ -65,7 +65,8 @@ export const createShowTopNAction = ({
isCompatible: async ({ field }) =>
isInSecurityApp(currentAppId) &&
fieldHasCellActions(field.name) &&
!UNSUPPORTED_FIELD_TYPES.includes(field.type),
!UNSUPPORTED_FIELD_TYPES.includes(field.type) &&
!!field.aggregatable,
execute: async (context) => {
const node = context.extraContentNodeRef?.current;
if (!node) return;

View file

@ -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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SecurityAppStore } from '../../../common/store/types';
import { KibanaServices } from '../../../common/lib/kibana';
import { TableId } from '../../../../common/types';
import { createToggleColumnAction } from './toggle_column';
import type { CellActionExecutionContext } from '@kbn/cell-actions';
import { mockGlobalState } from '../../../common/mock';
import { dataTableActions } from '../../../common/store/data_table';
jest.mock('../../../common/lib/kibana');
const mockWarningToast = jest.fn();
KibanaServices.get().notifications.toasts.addWarning = mockWarningToast;
const mockDispatch = jest.fn();
const mockGetState = jest.fn().mockReturnValue(mockGlobalState);
const store = {
dispatch: mockDispatch,
getState: mockGetState,
} as unknown as SecurityAppStore;
const value = 'the-value';
const fieldName = 'user.name';
const context = {
field: { name: fieldName, value, type: 'text' },
metadata: {
scopeId: TableId.test,
},
} as unknown as CellActionExecutionContext;
describe('Default createToggleColumnAction', () => {
const toggleColumnAction = createToggleColumnAction({ store, order: 1 });
beforeEach(() => {
jest.clearAllMocks();
});
it('should return display name', () => {
expect(toggleColumnAction.getDisplayName(context)).toEqual('Toggle column in table');
});
it('should return icon type', () => {
expect(toggleColumnAction.getIconType(context)).toEqual('listAdd');
});
describe('isCompatible', () => {
it('should return false if scopeId is undefined', async () => {
expect(
await toggleColumnAction.isCompatible({ ...context, metadata: { scopeId: undefined } })
).toEqual(false);
});
it('should return false if scopeId is different than timeline and dataGrid', async () => {
expect(
await toggleColumnAction.isCompatible({
...context,
metadata: { scopeId: 'test-scopeId-1234' },
})
).toEqual(false);
});
it('should return true if everything is okay', async () => {
expect(await toggleColumnAction.isCompatible(context)).toEqual(true);
});
});
describe('execute', () => {
it('should remove column', async () => {
await toggleColumnAction.execute(context);
expect(mockDispatch).toHaveBeenCalledWith(
dataTableActions.removeColumn({
columnId: fieldName,
id: TableId.test,
})
);
expect(mockWarningToast).not.toHaveBeenCalled();
});
it('should add column', async () => {
const name = 'fake-field-name';
await toggleColumnAction.execute({ ...context, field: { ...context.field, name } });
expect(mockDispatch).toHaveBeenCalledWith(
dataTableActions.upsertColumn({
column: {
columnHeaderType: 'not-filtered',
id: name,
initialWidth: 180,
},
id: TableId.test,
index: 1,
})
);
expect(mockWarningToast).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,104 @@
/*
* 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';
import type { CellAction, CellActionExecutionContext } from '@kbn/cell-actions';
import { fieldHasCellActions } from '../../utils';
import type { SecurityAppStore } from '../../../common/store';
import { getScopedActions, isInTableScope, isTimelineScope } from '../../../helpers';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
import { defaultColumnHeaderType, tableDefaults } from '../../../common/store/data_table/defaults';
import { timelineSelectors } from '../../../timelines/store/timeline';
import { dataTableSelectors } from '../../../common/store/data_table';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants';
export const ACTION_ID = 'security_toggleColumn';
const ICON = 'listAdd';
export interface ShowTopNActionContext extends CellActionExecutionContext {
metadata?: {
scopeId?: string;
isObjectArray?: boolean;
};
}
export const COLUMN_TOGGLE = i18n.translate(
'xpack.securitySolution.actions.toggleColumnToggle.label',
{
defaultMessage: 'Toggle column in table',
}
);
export const NESTED_COLUMN = (field: string) =>
i18n.translate('xpack.securitySolution.actions.toggleColumnToggle.nestedLabel', {
values: { field },
defaultMessage:
'The {field} field is an object, and is broken down into nested fields which can be added as columns',
});
export const createToggleColumnAction = ({
store,
order,
}: {
store: SecurityAppStore;
order?: number;
}): CellAction<ShowTopNActionContext> => ({
id: ACTION_ID,
type: ACTION_ID,
order,
getIconType: (): string => ICON,
getDisplayName: () => COLUMN_TOGGLE,
getDisplayNameTooltip: ({ field, metadata }) =>
metadata?.isObjectArray ? NESTED_COLUMN(field.name) : COLUMN_TOGGLE,
isCompatible: async ({ field, metadata }) => {
return (
fieldHasCellActions(field.name) &&
!!metadata?.scopeId &&
(isTimelineScope(metadata?.scopeId) || isInTableScope(metadata?.scopeId))
);
},
execute: async ({ metadata, field }) => {
const scopeId = metadata?.scopeId;
if (!scopeId) return;
const selector = isTimelineScope(scopeId)
? timelineSelectors.getTimelineByIdSelector()
: dataTableSelectors.getTableByIdSelector();
const defaults = isTimelineScope(scopeId) ? timelineDefaults : tableDefaults;
const { columns } = selector(store.getState(), scopeId) ?? defaults;
const scopedActions = getScopedActions(scopeId);
if (!scopedActions) {
return;
}
if (columns.some((c) => c.id === field.name)) {
store.dispatch(
scopedActions.removeColumn({
columnId: field.name,
id: scopeId,
})
);
} else {
store.dispatch(
scopedActions.upsertColumn({
column: {
columnHeaderType: defaultColumnHeaderType,
id: field.name,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
},
id: scopeId,
index: 1,
})
);
}
},
});

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { createToggleColumnAction as createDefaultToggleColumnAction } from './default/toggle_column';

View file

@ -26,6 +26,24 @@ jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallbac
};
});
jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback', () => {
return {
useRuleWithFallback: jest.fn(),
};
});
jest.mock('@kbn/cell-actions/src/hooks/use_load_actions', () => {
const actual = jest.requireActual('@kbn/cell-actions/src/hooks/use_load_actions');
return {
...actual,
useLoadActions: jest.fn().mockImplementation(() => ({
value: [],
error: undefined,
loading: false,
})),
};
});
const props = {
data: mockAlertDetailsData as TimelineEventsDetailsItem[],
browserFields: mockBrowserFields,
@ -62,7 +80,8 @@ describe('AlertSummaryView', () => {
<AlertSummaryView {...props} />
</TestProviders>
);
expect(getAllByTestId('hover-actions-filter-for').length).toBeGreaterThan(0);
expect(getAllByTestId('inlineActions').length).toBeGreaterThan(0);
});
});
@ -87,7 +106,8 @@ describe('AlertSummaryView', () => {
<AlertSummaryView {...props} scopeId={TimelineId.active} />
</TestProviders>
);
expect(queryAllByTestId('hover-actions-filter-for').length).toEqual(0);
expect(queryAllByTestId('inlineActions').length).toEqual(0);
});
});
@ -98,7 +118,7 @@ describe('AlertSummaryView', () => {
<AlertSummaryView {...{ ...props, isReadOnly: true }} />
</TestProviders>
);
expect(queryAllByTestId('hover-actions-filter-for').length).toEqual(0);
expect(queryAllByTestId('inlineActions').length).toEqual(0);
});
});

View file

@ -11,9 +11,21 @@ import { TestProviders } from '../../mock';
import { useMountAppended } from '../../utils/use_mount_appended';
import { mockBrowserFields } from '../../containers/source/mock';
import type { EventFieldsData } from './types';
import { get } from 'lodash/fp';
jest.mock('../../lib/kibana');
jest.mock('@kbn/cell-actions/src/hooks/use_load_actions', () => {
const actual = jest.requireActual('@kbn/cell-actions/src/hooks/use_load_actions');
return {
...actual,
useLoadActions: jest.fn().mockImplementation(() => ({
value: [],
error: undefined,
loading: false,
})),
};
});
interface Column {
field: string;
name: string | JSX.Element;
@ -59,95 +71,21 @@ describe('getColumns', () => {
actionsColumn = getColumns(defaultProps)[0] as Column;
});
describe('filter in', () => {
test('it renders a filter for (+) button', () => {
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
test('it renders inline actions', () => {
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(wrapper.find('[data-test-subj="hover-actions-filter-for"]').exists()).toBeTruthy();
});
expect(wrapper.find('[data-test-subj="inlineActions"]').exists()).toBeTruthy();
});
describe('filter out', () => {
test('it renders a filter out (-) button', () => {
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
test('it does not render inline actions when readOnly prop is passed', () => {
actionsColumn = getColumns({ ...defaultProps, isReadOnly: true })[0] as Column;
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(wrapper.find('[data-test-subj="hover-actions-filter-out"]').exists()).toBeTruthy();
});
});
describe('overflow button', () => {
test('it renders an overflow button', () => {
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(wrapper.find('[data-test-subj="more-actions-agent.id"]').exists()).toBeTruthy();
});
});
describe('column toggle', () => {
test('it renders a column toggle button', () => {
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(
get(['items', 0, 'key'], wrapper.find('[data-test-subj="more-actions-agent.id"]').props())
).toEqual('hover-actions-toggle-column');
});
});
describe('add to timeline', () => {
test('it renders an add to timeline button', () => {
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(
get(['items', 1, 'key'], wrapper.find('[data-test-subj="more-actions-agent.id"]').props())
).toEqual('hover-actions-add-timeline');
});
});
describe('topN', () => {
test('it renders a show topN button', () => {
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(
get(['items', 2, 'key'], wrapper.find('[data-test-subj="more-actions-agent.id"]').props())
).toEqual('hover-actions-show-top-n');
});
});
describe('copy', () => {
test('it renders a copy button', () => {
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(
get(['items', 3, 'key'], wrapper.find('[data-test-subj="more-actions-agent.id"]').props())
).toEqual('hover-actions-copy-button');
});
});
describe('does not render hover actions when readOnly prop is passed', () => {
test('it renders a filter for (+) button', () => {
actionsColumn = getColumns({ ...defaultProps, isReadOnly: true })[0] as Column;
const wrapper = mount(
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
) as ReactWrapper;
expect(wrapper.find('[data-test-subj="hover-actions-filter-for"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="hover-actions-filter-out"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="more-actions-agent.id"]').exists()).toBeFalsy();
});
expect(wrapper.find('[data-test-subj="inlineActions"]').exists()).toBeFalsy();
});
});
});

View file

@ -10,15 +10,14 @@ import { get } from 'lodash';
import memoizeOne from 'memoize-one';
import React from 'react';
import styled from 'styled-components';
import { CellActions, CellActionsMode } from '@kbn/cell-actions';
import type { BrowserFields } from '../../containers/source';
import type { OnUpdateColumns } from '../../../timelines/components/timeline/events';
import * as i18n from './translations';
import type { EventFieldsData } from './types';
import type { ColumnHeaderOptions } from '../../../../common/types';
import type { BrowserField } from '../../../../common/search_strategy';
import { FieldValueCell } from './table/field_value_cell';
import { FieldNameCell } from './table/field_name_cell';
import { ActionCell } from './table/action_cell';
import { CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER } from '../../../../common/constants';
const HoverActionsContainer = styled(EuiPanel)`
align-items: center;
@ -35,28 +34,23 @@ const HoverActionsContainer = styled(EuiPanel)`
HoverActionsContainer.displayName = 'HoverActionsContainer';
export const getFieldFromBrowserField = memoizeOne(
(keys: string[], browserFields: BrowserFields): BrowserField => get(browserFields, keys),
(keys: string[], browserFields: BrowserFields): BrowserField | undefined =>
get(browserFields, keys),
(newArgs, lastArgs) => newArgs[0].join() === lastArgs[0].join()
);
export const getColumns = ({
browserFields,
columnHeaders,
eventId,
onUpdateColumns,
contextId,
scopeId,
toggleColumn,
getLinkValue,
isDraggable,
isReadOnly,
}: {
browserFields: BrowserFields;
columnHeaders: ColumnHeaderOptions[];
eventId: string;
onUpdateColumns: OnUpdateColumns;
contextId: string;
scopeId: string;
toggleColumn: (column: ColumnHeaderOptions) => void;
getLinkValue: (field: string) => string | null;
isDraggable?: boolean;
isReadOnly?: boolean;
@ -74,24 +68,23 @@ export const getColumns = ({
truncateText: false,
width: '132px',
render: (values: string[] | null | undefined, data: EventFieldsData) => {
const label = data.isObjectArray
? i18n.NESTED_COLUMN(data.field)
: i18n.VIEW_COLUMN(data.field);
const fieldFromBrowserField = getFieldFromBrowserField(
[data.category, 'fields', data.field],
browserFields
);
return (
<ActionCell
aria-label={label}
contextId={contextId}
data={data}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
getLinkValue={getLinkValue}
toggleColumn={toggleColumn}
scopeId={scopeId}
values={values}
<CellActions
field={{
name: data.field,
value: values,
type: data.type,
aggregatable: fieldFromBrowserField?.aggregatable,
}}
triggerId={CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER}
mode={CellActionsMode.INLINE}
visibleCellActions={3}
metadata={{ scopeId, isObjectArray: data.isObjectArray }}
/>
);
},
@ -109,18 +102,7 @@ export const getColumns = ({
sortable: true,
truncateText: false,
render: (field: string, data: EventFieldsData) => {
const fieldFromBrowserField = getFieldFromBrowserField(
[data.category, 'fields', field],
browserFields
);
return (
<FieldNameCell
data={data}
field={field}
fieldMapping={undefined}
fieldFromBrowserField={fieldFromBrowserField}
/>
);
return <FieldNameCell data={data} field={field} fieldMapping={undefined} />;
},
},
{

View file

@ -7,15 +7,15 @@
import styled from 'styled-components';
import { get } from 'lodash/fp';
import React from 'react';
import React, { useMemo } from 'react';
import { EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { partition } from 'lodash';
import { CellActions, CellActionsMode } from '@kbn/cell-actions';
import * as i18n from './translations';
import type { CtiEnrichment } from '../../../../../common/search_strategy/security_solution/cti';
import { getEnrichmentIdentifiers, isInvestigationTimeEnrichment } from './helpers';
import type { FieldsData } from '../types';
import { ActionCell } from '../table/action_cell';
import type {
BrowserField,
BrowserFields,
@ -23,6 +23,7 @@ import type {
} from '../../../../../common/search_strategy';
import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field';
import { EnrichedDataRow, ThreatSummaryPanelHeader } from './threat_summary_view';
import { CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER } from '../../../../../common/constants';
export interface ThreatSummaryDescription {
browserField: BrowserField;
@ -42,16 +43,16 @@ const EnrichmentFieldFeedName = styled.span`
`;
export const StyledEuiFlexGroup = styled(EuiFlexGroup)`
.hoverActions-active {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
.inlineActions {
opacity: 0;
}
.inlineActions-popoverOpen {
opacity: 1;
}
&:hover {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
.inlineActions {
opacity: 1;
}
}
@ -68,8 +69,23 @@ const EnrichmentDescription: React.FC<ThreatSummaryDescription> = ({
isDraggable,
isReadOnly,
}) => {
if (!data || !value) return null;
const metadata = useMemo(() => ({ scopeId }), [scopeId]);
const field = useMemo(
() =>
!data
? null
: {
name: data.field,
value,
type: data.type,
aggregatable: browserField?.aggregatable,
},
[browserField, data, value]
);
if (!data || !value || !field) return null;
const key = `alert-details-value-formatted-field-value-${scopeId}-${eventId}-${data.field}-${value}-${index}-${feedName}`;
return (
<StyledEuiFlexGroup key={key} direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
@ -95,14 +111,12 @@ const EnrichmentDescription: React.FC<ThreatSummaryDescription> = ({
</EuiFlexItem>
<EuiFlexItem>
{value && !isReadOnly && (
<ActionCell
data={data}
contextId={scopeId}
eventId={key}
fieldFromBrowserField={browserField}
scopeId={scopeId}
values={[value]}
applyWidthAndPadding={false}
<CellActions
field={field}
triggerId={CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER}
mode={CellActionsMode.INLINE}
metadata={metadata}
visibleCellActions={3}
/>
)}
</EuiFlexItem>

View file

@ -24,7 +24,6 @@ jest.mock('../../../lib/kibana', () => ({
}),
}));
jest.mock('../table/action_cell', () => ({ ActionCell: () => <></> }));
jest.mock('../table/field_name_cell');
const RISK_SCORE_DATA_ROWS = 2;

View file

@ -14,7 +14,6 @@ import { EventFieldsBrowser } from './event_fields_browser';
import { mockBrowserFields } from '../../containers/source/mock';
import { useMountAppended } from '../../utils/use_mount_appended';
import { TimelineTabs } from '../../../../common/types/timeline';
import { get } from 'lodash/fp';
jest.mock('../../lib/kibana');
@ -26,6 +25,18 @@ jest.mock('@elastic/eui', () => {
};
});
jest.mock('@kbn/cell-actions/src/hooks/use_load_actions', () => {
const actual = jest.requireActual('@kbn/cell-actions/src/hooks/use_load_actions');
return {
...actual,
useLoadActions: jest.fn().mockImplementation(() => ({
value: [],
error: undefined,
loading: false,
})),
};
});
jest.mock('../link_to');
const mockDispatch = jest.fn();
@ -84,7 +95,7 @@ describe('EventFieldsBrowser', () => {
describe('Hover Actions', () => {
const eventId = 'pEMaMmkBUV60JmNWmWVi';
test('it renders a filter for (+) button', () => {
test('it renders inline actions', () => {
const wrapper = mount(
<TestProviders>
<EventFieldsBrowser
@ -96,113 +107,7 @@ describe('EventFieldsBrowser', () => {
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="hover-actions-filter-for"]').exists()).toBeTruthy();
});
test('it renders a filter out (-) button', () => {
const wrapper = mount(
<TestProviders>
<EventFieldsBrowser
browserFields={mockBrowserFields}
data={mockDetailItemData}
eventId={eventId}
scopeId="timeline-test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="hover-actions-filter-out"]').exists()).toBeTruthy();
});
test('it renders an overflow button', () => {
const wrapper = mount(
<TestProviders>
<EventFieldsBrowser
browserFields={mockBrowserFields}
data={mockDetailItemData}
eventId={eventId}
scopeId="timeline-test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="more-actions-@timestamp"]').exists()).toBeTruthy();
});
test('it does not render hover actions when readOnly prop is passed', () => {
const wrapper = mount(
<TestProviders>
<EventFieldsBrowser
browserFields={mockBrowserFields}
data={mockDetailItemData}
eventId={eventId}
scopeId="timeline-test"
timelineTabType={TimelineTabs.query}
isReadOnly
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="hover-actions-filter-for"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="hover-actions-filter-out"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="more-actions-@timestamp"]').exists()).toBeFalsy();
});
test('it renders a column toggle button', () => {
const wrapper = mount(
<TestProviders>
<EventFieldsBrowser
browserFields={mockBrowserFields}
data={mockDetailItemData}
eventId={eventId}
scopeId="timeline-test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
expect(
get(['items', 0, 'key'], wrapper.find('[data-test-subj="more-actions-@timestamp"]').props())
).toEqual('hover-actions-toggle-column');
});
test('it renders an add to timeline button', () => {
const wrapper = mount(
<TestProviders>
<EventFieldsBrowser
browserFields={mockBrowserFields}
data={mockDetailItemData}
eventId={eventId}
scopeId="timeline-test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
expect(
get(['items', 1, 'key'], wrapper.find('[data-test-subj="more-actions-@timestamp"]').props())
).toEqual('hover-actions-add-timeline');
});
test('it renders a copy button', () => {
const wrapper = mount(
<TestProviders>
<EventFieldsBrowser
browserFields={mockBrowserFields}
data={mockDetailItemData}
eventId={eventId}
scopeId="timeline-test"
timelineTabType={TimelineTabs.query}
/>
</TestProviders>
);
expect(
get(['items', 2, 'key'], wrapper.find('[data-test-subj="more-actions-@timestamp"]').props())
).toEqual('hover-actions-copy-button');
expect(wrapper.find('[data-test-subj="inlineActions"]').exists()).toBeTruthy();
});
});

View file

@ -8,7 +8,6 @@
import { getOr, noop, sortBy } from 'lodash/fp';
import { EuiInMemoryTable } from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { rgba } from 'polished';
import styled from 'styled-components';
import {
@ -19,7 +18,7 @@ import {
onKeyDownFocusHandler,
} from '@kbn/timelines-plugin/public';
import { getScopedActions, isInTableScope, isTimelineScope } from '../../../helpers';
import { isInTableScope, isTimelineScope } from '../../../helpers';
import { tableDefaults } from '../../store/data_table/defaults';
import { dataTableSelectors } from '../../store/data_table';
import { ADD_TIMELINE_BUTTON_CLASS_NAME } from '../../../timelines/components/flyout/add_timeline_button';
@ -32,7 +31,7 @@ import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
import { getColumns } from './columns';
import { EVENT_FIELDS_TABLE_CLASS_NAME, onEventDetailsTabKeyPressed, search } from './helpers';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import type { ColumnHeaderOptions, TimelineTabs } from '../../../../common/types/timeline';
import type { TimelineTabs } from '../../../../common/types/timeline';
interface Props {
browserFields: BrowserFields;
@ -92,31 +91,23 @@ const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)`
font: ${({ theme }) => theme.eui.euiFont};
} */
.inlineActions {
opacity: 0;
}
.eventFieldsTable__tableRow {
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
font-family: ${({ theme }) => theme.eui.euiCodeFontFamily};
.hoverActions-active {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
.inlineActions-popoverOpen {
opacity: 1;
}
&:hover {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
.inlineActions {
opacity: 1;
}
}
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
// TODO: Using this logic from discover
/* @include euiBreakpoint('m', 'l', 'xl') {
opacity: 0;
} */
opacity: 0;
}
}
.eventFieldsTable__actionCell,
@ -171,7 +162,6 @@ const useFieldBrowserPagination = () => {
export const EventFieldsBrowser = React.memo<Props>(
({ browserFields, data, eventId, isDraggable, timelineTabType, scopeId, isReadOnly }) => {
const containerElement = useRef<HTMLDivElement | null>(null);
const dispatch = useDispatch();
const getScope = useMemo(() => {
if (isTimelineScope(scopeId)) {
return timelineSelectors.getTimelineByIdSelector();
@ -209,31 +199,6 @@ export const EventFieldsBrowser = React.memo<Props>(
},
[data, columnHeaders]
);
const scopedActions = getScopedActions(scopeId);
const toggleColumn = useCallback(
(column: ColumnHeaderOptions) => {
if (!scopedActions) {
return;
}
if (columnHeaders.some((c) => c.id === column.id)) {
dispatch(
scopedActions.removeColumn({
columnId: column.id,
id: scopeId,
})
);
} else {
dispatch(
scopedActions.upsertColumn({
column,
id: scopeId,
index: 1,
})
);
}
},
[columnHeaders, dispatch, scopeId, scopedActions]
);
const onSetRowProps = useCallback(({ ariaRowindex, field }: TimelineEventsDetailsItem) => {
const rowIndex = ariaRowindex != null ? { 'data-rowindex': ariaRowindex } : {};
@ -244,41 +209,18 @@ export const EventFieldsBrowser = React.memo<Props>(
};
}, []);
const onUpdateColumns = useCallback(
(columns) => {
if (scopedActions) {
dispatch(scopedActions.updateColumns({ id: scopeId, columns }));
}
},
[dispatch, scopeId, scopedActions]
);
const columns = useMemo(
() =>
getColumns({
browserFields,
columnHeaders,
eventId,
onUpdateColumns,
contextId: `event-fields-browser-for-${scopeId}-${timelineTabType}`,
scopeId,
toggleColumn,
getLinkValue,
isDraggable,
isReadOnly,
}),
[
browserFields,
columnHeaders,
eventId,
onUpdateColumns,
scopeId,
timelineTabType,
toggleColumn,
getLinkValue,
isDraggable,
isReadOnly,
]
[browserFields, eventId, scopeId, timelineTabType, getLinkValue, isDraggable, isReadOnly]
);
const focusSearchInput = useCallback(() => {

View file

@ -2,45 +2,11 @@
exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
<DocumentFragment>
.c6 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.c6:focus-within .timelines__hoverActionButton,
.c6:focus-within .securitySolution__hoverActionButton {
opacity: 1;
}
.c6:hover .timelines__hoverActionButton,
.c6:hover .securitySolution__hoverActionButton {
opacity: 1;
}
.c6 .timelines__hoverActionButton,
.c6 .securitySolution__hoverActionButton {
opacity: 0;
}
.c6 .timelines__hoverActionButton:focus,
.c6 .securitySolution__hoverActionButton:focus {
opacity: 1;
}
.c3 {
.c3 {
text-transform: capitalize;
}
.c5 {
width: 0;
-webkit-transform: translate(6px);
-ms-transform: translate(6px);
transform: translate(6px);
-webkit-transition: -webkit-transform 50ms ease-in-out;
-webkit-transition: transform 50ms ease-in-out;
transition: transform 50ms ease-in-out;
.c4 {
margin-left: 8px;
}
@ -50,17 +16,27 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
height: 78px;
}
.c1 .hoverActions-active .timelines__hoverActionButton,
.c1 .hoverActions-active .securitySolution__hoverActionButton {
.c1:hover .inlineActions {
opacity: 1;
width: auto;
-webkit-transform: translate(0);
-ms-transform: translate(0);
transform: translate(0);
}
.c1:hover .timelines__hoverActionButton,
.c1:hover .securitySolution__hoverActionButton {
opacity: 1;
.c1 .inlineActions {
opacity: 0;
width: 0;
-webkit-transform: translate(6px);
-ms-transform: translate(6px);
transform: translate(6px);
-webkit-transition: -webkit-transform 50ms ease-in-out;
-webkit-transition: transform 50ms ease-in-out;
transition: transform 50ms ease-in-out;
}
.c1:hover .c4 {
.c1 .inlineActions.inlineActions-popoverOpen {
opacity: 1;
width: auto;
-webkit-transform: translate(0);
-ms-transform: translate(0);
@ -116,7 +92,6 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
aria-label="Click to change alert status"
class="euiBadge c3 emotion-euiBadge-clickable"
style="background-color: rgb(121, 170, 217); color: rgb(0, 0, 0);"
title="open"
>
<span
class="euiBadge__content emotion-euiBadge__content"
@ -137,45 +112,23 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
</div>
</div>
<div
class="c4 c5"
class="c4"
>
<div
data-eui="EuiFocusTrap"
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-center-row"
>
<div
class="c6"
class="euiFlexItem emotion-euiFlexItem-growZero"
/>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
data-test-subj="cellActions-renderContent-kibana.alert.workflow_status"
>
<p
class="emotion-euiScreenReaderOnly"
>
You are in a dialog, containing options for field kibana.alert.workflow_status. Press tab to navigate options. Press escape to exit.
</p>
<div
data-test-subj="hover-actions-filter-for"
>
<span
data-test-subj="test-filter-for"
>
Filter button
</span>
</div>
<div
data-test-subj="hover-actions-filter-out"
>
<span
data-test-subj="test-filter-out"
>
Filter out button
</span>
</div>
<div
data-test-subj="more-actions-kibana.alert.workflow_status"
field="kibana.alert.workflow_status"
items="[object Object],[object Object],[object Object]"
value="open"
>
Overflow button
</div>
class="euiFlexGroup inlineActions emotion-euiFlexGroup-none-flexStart-flexStart-row"
data-test-subj="inlineActions"
/>
<div />
</div>
</div>
</div>
@ -206,45 +159,23 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
47
</div>
<div
class="c4 c5"
class="c4"
>
<div
data-eui="EuiFocusTrap"
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-center-row"
>
<div
class="c6"
class="euiFlexItem emotion-euiFlexItem-growZero"
/>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
data-test-subj="cellActions-renderContent-kibana.alert.risk_score"
>
<p
class="emotion-euiScreenReaderOnly"
>
You are in a dialog, containing options for field kibana.alert.risk_score. Press tab to navigate options. Press escape to exit.
</p>
<div
data-test-subj="hover-actions-filter-for"
>
<span
data-test-subj="test-filter-for"
>
Filter button
</span>
</div>
<div
data-test-subj="hover-actions-filter-out"
>
<span
data-test-subj="test-filter-out"
>
Filter out button
</span>
</div>
<div
data-test-subj="more-actions-kibana.alert.risk_score"
field="kibana.alert.risk_score"
items="[object Object],[object Object],[object Object]"
value="47"
>
Overflow button
</div>
class="euiFlexGroup inlineActions emotion-euiFlexGroup-none-flexStart-flexStart-row"
data-test-subj="inlineActions"
/>
<div />
</div>
</div>
</div>
@ -287,45 +218,23 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
</button>
</div>
<div
class="c4 c5"
class="c4"
>
<div
data-eui="EuiFocusTrap"
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-center-row"
>
<div
class="c6"
class="euiFlexItem emotion-euiFlexItem-growZero"
/>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
data-test-subj="cellActions-renderContent-kibana.alert.rule.name"
>
<p
class="emotion-euiScreenReaderOnly"
>
You are in a dialog, containing options for field kibana.alert.rule.name. Press tab to navigate options. Press escape to exit.
</p>
<div
data-test-subj="hover-actions-filter-for"
>
<span
data-test-subj="test-filter-for"
>
Filter button
</span>
</div>
<div
data-test-subj="hover-actions-filter-out"
>
<span
data-test-subj="test-filter-out"
>
Filter out button
</span>
</div>
<div
data-test-subj="more-actions-kibana.alert.rule.name"
field="kibana.alert.rule.name"
items="[object Object],[object Object],[object Object]"
value="More than one event with user name"
>
Overflow button
</div>
class="euiFlexGroup inlineActions emotion-euiFlexGroup-none-flexStart-flexStart-row"
data-test-subj="inlineActions"
/>
<div />
</div>
</div>
</div>

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { act, render } from '@testing-library/react';
import { Overview } from '.';
import { TestProviders } from '../../../mock';
@ -23,55 +23,63 @@ jest.mock(
);
describe('Event Details Overview Cards', () => {
it('renders all cards', () => {
const { getByText } = render(
<TestProviders>
<Overview {...props} />
</TestProviders>
);
it('renders all cards', async () => {
await act(async () => {
const { getByText } = render(
<TestProviders>
<Overview {...props} />
</TestProviders>
);
getByText('Status');
getByText('Severity');
getByText('Risk Score');
getByText('Rule');
getByText('Status');
getByText('Severity');
getByText('Risk Score');
getByText('Rule');
});
});
it('renders only readOnly cards', () => {
const { getByText, queryByText } = render(
<TestProviders>
<Overview {...propsWithReadOnly} />
</TestProviders>
);
it('renders only readOnly cards', async () => {
await act(async () => {
const { getByText, queryByText } = render(
<TestProviders>
<Overview {...propsWithReadOnly} />
</TestProviders>
);
getByText('Severity');
getByText('Risk Score');
getByText('Severity');
getByText('Risk Score');
expect(queryByText('Status')).not.toBeInTheDocument();
expect(queryByText('Rule')).not.toBeInTheDocument();
expect(queryByText('Status')).not.toBeInTheDocument();
expect(queryByText('Rule')).not.toBeInTheDocument();
});
});
it('renders all cards it has data for', () => {
const { getByText, queryByText } = render(
<TestProviders>
<Overview {...propsWithoutSeverity} />
</TestProviders>
);
it('renders all cards it has data for', async () => {
await act(async () => {
const { getByText, queryByText } = render(
<TestProviders>
<Overview {...propsWithoutSeverity} />
</TestProviders>
);
getByText('Status');
getByText('Risk Score');
getByText('Rule');
getByText('Status');
getByText('Risk Score');
getByText('Rule');
expect(queryByText('Severity')).not.toBeInTheDocument();
expect(queryByText('Severity')).not.toBeInTheDocument();
});
});
it('renders rows and spacers correctly', () => {
const { asFragment } = render(
<TestProviders>
<Overview {...propsWithoutSeverity} />
</TestProviders>
);
it('renders rows and spacers correctly', async () => {
await act(async () => {
const { asFragment } = render(
<TestProviders>
<Overview {...propsWithoutSeverity} />
</TestProviders>
);
expect(asFragment()).toMatchSnapshot();
expect(asFragment()).toMatchSnapshot();
});
});
});

View file

@ -6,7 +6,7 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import React, { useMemo } from 'react';
import React, { useMemo, Fragment } from 'react';
import { chunk, find } from 'lodash/fp';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
@ -213,10 +213,10 @@ export const Overview = React.memo<Props>(
// Add a spacer between rows but not after the last row
const addSpacer = index < length - 1;
return (
<>
<Fragment key={index}>
<NotGrowingFlexGroup gutterSize="s">{elements}</NotGrowingFlexGroup>
{addSpacer && <EuiSpacer size="s" />}
</>
</Fragment>
);
})}
</>

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { act, render } from '@testing-library/react';
import { OverviewCardWithActions } from './overview_card';
import {
createSecuritySolutionStorageMock,
@ -19,6 +19,7 @@ import { SeverityBadge } from '../../../../detections/components/rules/severity_
import type { State } from '../../../store';
import { createStore } from '../../../store';
import { TimelineId } from '../../../../../common/types';
import { createAction } from '@kbn/ui-actions-plugin/public';
const state: State = {
...mockGlobalState,
@ -74,23 +75,33 @@ const props = {
jest.mock('../../../lib/kibana');
const mockAction = createAction({
id: 'test_action',
execute: async () => {},
getIconType: () => 'test-icon',
getDisplayName: () => 'test-actions',
});
// jest.useFakeTimers();
describe('OverviewCardWithActions', () => {
test('it renders correctly', () => {
const { getByText } = render(
<TestProviders store={store}>
<OverviewCardWithActions {...props}>
<SeverityBadge value="medium" />
</OverviewCardWithActions>
</TestProviders>
);
test('it renders correctly', async () => {
await act(async () => {
const { getByText, findByTestId } = render(
<TestProviders store={store} cellActions={[mockAction]}>
<OverviewCardWithActions {...props}>
<SeverityBadge value="medium" />
</OverviewCardWithActions>
</TestProviders>
);
// Headline
getByText('Severity');
// Headline
getByText('Severity');
// Content
getByText('Medium');
// Content
getByText('Medium');
// Hover actions
getByText('Add To Timeline');
// Hover actions
await findByTestId('actionItem-test_action');
});
});
});

View file

@ -6,76 +6,52 @@
*/
import { EuiFlexGroup, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
import React, { useState } from 'react';
import React from 'react';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { ActionCell } from '../table/action_cell';
import { CellActions, CellActionsMode } from '@kbn/cell-actions';
import type { EnrichedFieldInfo } from '../types';
import { CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER } from '../../../../../common/constants';
const ActionWrapper = euiStyled.div`
width: 0;
transform: translate(6px);
transition: transform 50ms ease-in-out;
margin-left: ${({ theme }) => theme.eui.euiSizeS};
`;
const OverviewPanel = euiStyled(EuiPanel)<{
$isPopoverVisible: boolean;
}>`
const OverviewPanel = euiStyled(EuiPanel)`
&&& {
background-color: ${({ theme }) => theme.eui.euiColorLightestShade};
padding: ${({ theme }) => theme.eui.euiSizeS};
height: 78px;
}
& {
.hoverActions-active {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
&:hover {
.inlineActions {
opacity: 1;
width: auto;
transform: translate(0);
}
&:hover {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
${ActionWrapper} {
width: auto;
transform: translate(0);
}
}
${(props) =>
props.$isPopoverVisible &&
`
& ${ActionWrapper} {
width: auto;
transform: translate(0);
}
`}
}
.inlineActions {
opacity: 0;
width: 0;
transform: translate(6px);
transition: transform 50ms ease-in-out;
&.inlineActions-popoverOpen {
opacity: 1;
width: auto;
transform: translate(0);
}
}
`;
interface OverviewCardProps {
isPopoverVisible?: boolean; // Prevent the hover actions from collapsing on each other when not directly hovered on
title: string;
}
export const OverviewCard: React.FC<OverviewCardProps> = ({
title,
children,
isPopoverVisible = false, // default to false as this behavior is only really necessary in the situation without an overflow
}) => (
<OverviewPanel
borderRadius="none"
hasShadow={false}
hasBorder={false}
paddingSize="s"
$isPopoverVisible={isPopoverVisible}
>
export const OverviewCard: React.FC<OverviewCardProps> = ({ title, children }) => (
<OverviewPanel borderRadius="none" hasShadow={false} hasBorder={false} paddingSize="s">
<EuiText size="s">{title}</EuiText>
<EuiSpacer size="s" />
{children}
@ -106,25 +82,27 @@ export const OverviewCardWithActions: React.FC<OverviewCardWithActionsProps> = (
contextId,
dataTestSubj,
enrichedFieldInfo,
}) => {
const [isPopoverVisisble, setIsPopoverVisible] = useState(false);
}) => (
<OverviewCard title={title}>
<EuiFlexGroup alignItems="center" gutterSize="none">
<ClampedContent data-test-subj={dataTestSubj}>{children}</ClampedContent>
return (
<OverviewCard title={title} isPopoverVisible={isPopoverVisisble}>
<EuiFlexGroup alignItems="center" gutterSize="none">
<ClampedContent data-test-subj={dataTestSubj}>{children}</ClampedContent>
<ActionWrapper>
<ActionCell
{...enrichedFieldInfo}
contextId={contextId}
setIsPopoverVisible={setIsPopoverVisible}
applyWidthAndPadding={false}
/>
</ActionWrapper>
</EuiFlexGroup>
</OverviewCard>
);
};
<ActionWrapper>
<CellActions
field={{
name: enrichedFieldInfo.data.field,
value: enrichedFieldInfo?.values ? enrichedFieldInfo?.values[0] : '',
type: enrichedFieldInfo.data.type,
aggregatable: enrichedFieldInfo.fieldFromBrowserField?.aggregatable,
}}
triggerId={CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER}
mode={CellActionsMode.INLINE}
metadata={{ scopeId: contextId }}
visibleCellActions={3}
/>
</ActionWrapper>
</EuiFlexGroup>
</OverviewCard>
);
OverviewCardWithActions.displayName = 'OverviewCardWithActions';

View file

@ -1,83 +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 React, { useContext } from 'react';
import { TimelineContext } from '../../../../timelines/components/timeline';
import { HoverActions } from '../../hover_actions';
import { useActionCellDataProvider } from './use_action_cell_data_provider';
import type { EnrichedFieldInfo } from '../types';
import type { ColumnHeaderOptions } from '../../../../../common/types/timeline';
import { useTopNPopOver } from '../../hover_actions/utils';
interface Props extends EnrichedFieldInfo {
contextId: string;
applyWidthAndPadding?: boolean;
disabled?: boolean;
getLinkValue?: (field: string) => string | null;
onFilterAdded?: () => void;
setIsPopoverVisible?: (isVisible: boolean) => void;
toggleColumn?: (column: ColumnHeaderOptions) => void;
hideAddToTimeline?: boolean;
}
export const ActionCell: React.FC<Props> = React.memo(
({
applyWidthAndPadding = true,
contextId,
data,
eventId,
fieldFromBrowserField,
getLinkValue,
linkValue,
onFilterAdded,
setIsPopoverVisible,
scopeId,
toggleColumn,
values,
hideAddToTimeline,
}) => {
const actionCellConfig = useActionCellDataProvider({
contextId,
eventId,
field: data.field,
fieldFormat: data.format,
fieldFromBrowserField,
fieldType: data.type,
isObjectArray: data.isObjectArray,
linkValue: (getLinkValue && getLinkValue(data.field)) ?? linkValue,
values,
});
const { closeTopN, toggleTopN, isShowingTopN } = useTopNPopOver(setIsPopoverVisible);
const { aggregatable, type } = fieldFromBrowserField || { aggregatable: false, type: '' };
const { timelineId: timelineIdFind } = useContext(TimelineContext);
return (
<HoverActions
applyWidthAndPadding={applyWidthAndPadding}
closeTopN={closeTopN}
dataType={data.type}
dataProvider={actionCellConfig?.dataProviders}
enableOverflowButton={true}
field={data.field}
isAggregatable={aggregatable}
fieldType={type}
hideAddToTimeline={hideAddToTimeline}
isObjectArray={data.isObjectArray}
onFilterAdded={onFilterAdded}
ownFocus={false}
showTopN={isShowingTopN}
scopeId={scopeId ?? timelineIdFind}
toggleColumn={toggleColumn}
toggleTopN={toggleTopN}
values={actionCellConfig?.values}
/>
);
}
);
ActionCell.displayName = 'ActionCell';

View file

@ -12,14 +12,12 @@ import { FieldIcon } from '@kbn/react-field';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import * as i18n from '../translations';
import { getExampleText } from '../helpers';
import type { BrowserField } from '../../../containers/source';
import type { EventFieldsData } from '../types';
import { getFieldTypeName } from './get_field_type_name';
export interface FieldNameCellProps {
data: EventFieldsData;
field: string;
fieldFromBrowserField: BrowserField;
fieldMapping?: DataViewField;
scripted?: boolean;
}

View file

@ -10,21 +10,17 @@ import styled from 'styled-components';
import { EuiInMemoryTable } from '@elastic/eui';
export const SummaryTable = styled(EuiInMemoryTable as unknown as AnyStyledComponent)`
.timelines__hoverActionButton {
.inlineActions {
opacity: 0;
}
.flyoutTableHoverActions {
.hoverActions-active {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
.inlineActions-popoverOpen {
opacity: 1;
}
&:hover {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
.inlineActions {
opacity: 1;
}
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import type { BrowserField } from '../../../containers/source';
@ -76,38 +76,43 @@ const enrichedAgentStatusData: AlertSummaryRow['description'] = {
describe('SummaryValueCell', () => {
test('it should render', async () => {
render(
<TestProviders>
<SummaryValueCell {...enrichedHostIpData} />
</TestProviders>
);
await act(async () => {
render(
<TestProviders>
<SummaryValueCell {...enrichedHostIpData} />
</TestProviders>
);
});
hostIpValues.forEach((ipValue) => expect(screen.getByText(ipValue)).toBeInTheDocument());
expect(screen.getAllByTestId('test-filter-for')).toHaveLength(1);
expect(screen.getAllByTestId('test-filter-out')).toHaveLength(1);
expect(screen.getByTestId('inlineActions')).toBeInTheDocument();
});
describe('Without hover actions', () => {
test('When in the timeline flyout with timelineId active', async () => {
render(
<TestProviders>
<SummaryValueCell {...enrichedHostIpData} scopeId={TimelineId.active} />
</TestProviders>
);
await act(async () => {
render(
<TestProviders>
<SummaryValueCell {...enrichedHostIpData} scopeId={TimelineId.active} />
</TestProviders>
);
});
hostIpValues.forEach((ipValue) => expect(screen.getByText(ipValue)).toBeInTheDocument());
expect(screen.queryByTestId('test-filter-for')).toBeNull();
expect(screen.queryByTestId('test-filter-out')).toBeNull();
expect(screen.queryByTestId('inlineActions')).not.toBeInTheDocument();
});
test('When rendering the host status field', async () => {
render(
<TestProviders>
<SummaryValueCell {...enrichedAgentStatusData} />
</TestProviders>
);
await act(async () => {
render(
<TestProviders>
<SummaryValueCell {...enrichedAgentStatusData} />
</TestProviders>
);
});
expect(screen.getByTestId('event-field-agent.status')).toBeInTheDocument();
expect(screen.queryByTestId('test-filter-for')).toBeNull();
expect(screen.queryByTestId('test-filter-out')).toBeNull();
expect(screen.queryByTestId('inlineActions')).not.toBeInTheDocument();
});
});
});

View file

@ -7,11 +7,12 @@
import React from 'react';
import { ActionCell } from './action_cell';
import { CellActions, CellActionsMode } from '@kbn/cell-actions';
import { FieldValueCell } from './field_value_cell';
import type { AlertSummaryRow } from '../helpers';
import { hasHoverOrRowActions } from '../helpers';
import { TimelineId } from '../../../../../common/types';
import { CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER } from '../../../../../common/constants';
const style = { flexGrow: 0 };
@ -40,16 +41,17 @@ export const SummaryValueCell: React.FC<AlertSummaryRow['description']> = ({
values={values}
/>
{scopeId !== TimelineId.active && !isReadOnly && hoverActionsEnabled && (
<ActionCell
contextId={scopeId}
data={data}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
linkValue={linkValue}
scopeId={scopeId}
values={values}
applyWidthAndPadding={false}
hideAddToTimeline={false}
<CellActions
field={{
name: data.field,
value: values && values.length > 0 ? values[0] : '',
type: data.type,
aggregatable: fieldFromBrowserField?.aggregatable,
}}
triggerId={CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER}
mode={CellActionsMode.INLINE}
visibleCellActions={3}
metadata={{ scopeId }}
/>
)}
</>

View file

@ -4,6 +4,7 @@ exports[`entity_draggable renders correctly against snapshot 1`] = `
<CellActions
field={
Object {
"aggregatable": true,
"name": "entity-name",
"type": "keyword",
"value": "entity-value",

View file

@ -21,6 +21,7 @@ export const EntityComponent: React.FC<Props> = ({ entityName, entityValue }) =>
name: entityName,
value: entityValue,
type: 'keyword',
aggregatable: true,
}}
triggerId={CELL_ACTIONS_DEFAULT_TRIGGER}
mode={CellActionsMode.HOVER}

View file

@ -4,6 +4,7 @@ exports[`draggable_score renders correctly against snapshot 1`] = `
<CellActions
field={
Object {
"aggregatable": true,
"name": "process.name",
"type": "keyword",
"value": "du",
@ -21,6 +22,7 @@ exports[`draggable_score renders correctly against snapshot when the index is no
<CellActions
field={
Object {
"aggregatable": true,
"name": "process.name",
"type": "keyword",
"value": "du",

View file

@ -28,6 +28,7 @@ export const ScoreComponent = ({
name: score.entityName,
value: score.entityValue,
type: 'keyword',
aggregatable: true,
}}
triggerId={CELL_ACTIONS_DEFAULT_TRIGGER}
visibleCellActions={5}

View file

@ -37,6 +37,7 @@ export const getAnomaliesHostTableColumns = (
idPrefix: `anomalies-host-table-hostName-${createCompoundAnomalyKey(
anomaliesByHost.anomaly
)}-hostName`,
aggregatable: true,
fieldType: 'keyword',
render: (item) => <HostDetailsLink hostName={item} />,
}),

View file

@ -41,6 +41,7 @@ export const getAnomaliesNetworkTableColumns = (
idPrefix: `anomalies-network-table-ip-${createCompoundAnomalyKey(
anomaliesByNetwork.anomaly
)}`,
aggregatable: true,
fieldType: 'ip',
render: (item) => <NetworkDetailsLink ip={item} flowTarget={flowTarget} />,
}),

View file

@ -38,6 +38,7 @@ export const getAnomaliesUserTableColumns = (
idPrefix: `anomalies-user-table-userName-${createCompoundAnomalyKey(
anomaliesByUser.anomaly
)}-userName`,
aggregatable: true,
fieldType: 'keyword',
render: (item) => <UserDetailsLink userName={item} />,
}),

View file

@ -31,6 +31,7 @@ describe('Table Helpers', () => {
values: undefined,
fieldName: 'attrName',
idPrefix: 'idPrefix',
aggregatable: false,
});
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
@ -43,6 +44,7 @@ describe('Table Helpers', () => {
values: [''],
fieldName: 'attrName',
idPrefix: 'idPrefix',
aggregatable: false,
});
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
@ -54,6 +56,7 @@ describe('Table Helpers', () => {
values: null,
fieldName: 'attrName',
idPrefix: 'idPrefix',
aggregatable: false,
displayCount: 0,
});
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
@ -66,6 +69,7 @@ describe('Table Helpers', () => {
values: ['item1'],
fieldName: 'attrName',
idPrefix: 'idPrefix',
aggregatable: false,
render: renderer,
});
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
@ -78,6 +82,7 @@ describe('Table Helpers', () => {
values: [],
fieldName: 'attrName',
idPrefix: 'idPrefix',
aggregatable: false,
});
const { container } = render(<TestProviders>{rowItems}</TestProviders>);
expect(container.textContent).toBe(getEmptyValue());
@ -88,6 +93,7 @@ describe('Table Helpers', () => {
values: items,
fieldName: 'attrName',
idPrefix: 'idPrefix',
aggregatable: false,
displayCount: 2,
});
const { queryAllByTestId, queryByTestId } = render(<TestProviders>{rowItems}</TestProviders>);

View file

@ -27,6 +27,7 @@ interface GetRowItemsWithActionsParams {
render?: (item: string) => JSX.Element;
displayCount?: number;
maxOverflow?: number;
aggregatable: boolean;
}
export const getRowItemsWithActions = ({
@ -37,6 +38,7 @@ export const getRowItemsWithActions = ({
render,
displayCount = 5,
maxOverflow = 5,
aggregatable,
}: GetRowItemsWithActionsParams): JSX.Element => {
if (values != null && values.length > 0) {
const visibleItems = values.slice(0, displayCount).map((value, index) => {
@ -52,6 +54,7 @@ export const getRowItemsWithActions = ({
name: fieldName,
value,
type: fieldType,
aggregatable,
}}
>
<>{render ? render(value) : defaultToEmptyTag(value)}</>
@ -69,6 +72,7 @@ export const getRowItemsWithActions = ({
idPrefix={idPrefix}
maxOverflowItems={maxOverflow}
overflowIndexStart={displayCount}
isAggregatable={aggregatable}
/>
</>
) : (
@ -82,6 +86,7 @@ export const getRowItemsWithActions = ({
interface RowItemOverflowProps {
fieldName: string;
fieldType: string;
isAggregatable?: boolean;
values: string[];
idPrefix: string;
maxOverflowItems: number;
@ -92,6 +97,7 @@ export const RowItemOverflowComponent: React.FC<RowItemOverflowProps> = ({
fieldName,
values,
fieldType,
isAggregatable,
idPrefix,
maxOverflowItems = 5,
overflowIndexStart = 5,
@ -104,6 +110,7 @@ export const RowItemOverflowComponent: React.FC<RowItemOverflowProps> = ({
<MoreContainer
fieldName={fieldName}
idPrefix={idPrefix}
isAggregatable={isAggregatable}
fieldType={fieldType}
values={values}
overflowIndexStart={overflowIndexStart}

View file

@ -103,6 +103,7 @@ const LAST_SUCCESSFUL_SOURCE_COLUMN: Columns<AuthenticationsEdges, Authenticatio
values: node.lastSuccess?.source?.ip || null,
fieldName: 'source.ip',
fieldType: 'ip',
aggregatable: true,
idPrefix: `authentications-table-${node._id}-lastSuccessSource`,
render: (item) => <NetworkDetailsLink ip={item} />,
}),
@ -116,6 +117,7 @@ const LAST_SUCCESSFUL_DESTINATION_COLUMN: Columns<AuthenticationsEdges, Authenti
values: node.lastSuccess?.host?.name ?? null,
fieldName: 'host.name',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `authentications-table-${node._id}-lastSuccessfulDestination`,
render: (item) => <HostDetailsLink hostName={item} />,
}),
@ -140,6 +142,7 @@ const LAST_FAILED_SOURCE_COLUMN: Columns<AuthenticationsEdges, AuthenticationsEd
values: node.lastFailure?.source?.ip || null,
fieldName: 'source.ip',
fieldType: 'ip',
aggregatable: true,
idPrefix: `authentications-table-${node._id}-lastFailureSource`,
render: (item) => <NetworkDetailsLink ip={item} />,
}),
@ -153,6 +156,7 @@ const LAST_FAILED_DESTINATION_COLUMN: Columns<AuthenticationsEdges, Authenticati
values: node.lastFailure?.host?.name || null,
fieldName: 'host.name',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `authentications-table-${node._id}-lastFailureDestination`,
render: (item) => <HostDetailsLink hostName={item} />,
}),
@ -168,6 +172,7 @@ const USER_COLUMN: Columns<AuthenticationsEdges, AuthenticationsEdges> = {
fieldName: 'user.name',
idPrefix: `authentications-table-${node._id}-userName`,
fieldType: 'keyword',
aggregatable: true,
render: (item) => <UserDetailsLink userName={item} />,
}),
};
@ -182,6 +187,7 @@ const HOST_COLUMN: Columns<AuthenticationsEdges, AuthenticationsEdges> = {
fieldName: 'host.name',
idPrefix: `authentications-table-${node._id}-hostName`,
fieldType: 'keyword',
aggregatable: true,
render: (item) => <HostDetailsLink hostName={item} />,
}),
};

View file

@ -152,6 +152,7 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [
values: node.process.name,
fieldName: 'process.name',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `uncommon-process-table-${node._id}-processName`,
}),
},
@ -181,6 +182,7 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [
values: getHostNames(node.hosts),
fieldName: 'host.name',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `uncommon-process-table-${node._id}-processHost`,
render: (item) => <HostDetailsLink hostName={item} />,
}),
@ -195,6 +197,7 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [
values: node.process != null ? node.process.args : null,
fieldName: 'process.args',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `uncommon-process-table-${node._id}-processArgs`,
render: (item) => <HostDetailsLink hostName={item} />,
displayCount: 1,
@ -209,6 +212,7 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [
values: node.user != null ? node.user.name : null,
fieldName: 'user.name',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `uncommon-process-table-${node._id}-processUser`,
}),
},

View file

@ -39,6 +39,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
values: methods,
idPrefix: escapeDataProviderId(`${tableId}-table-methods-${path}`),
fieldType: 'keyword',
aggregatable: true,
displayCount: 3,
})
: getEmptyTagValue();
@ -53,6 +54,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
fieldName: 'url.domain',
idPrefix: escapeDataProviderId(`${tableId}-table-domains-${path}`),
fieldType: 'keyword',
aggregatable: true,
})
: getEmptyTagValue(),
},
@ -66,6 +68,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
fieldName: 'url.path',
idPrefix: escapeDataProviderId(`${tableId}-table-path-${path}`),
fieldType: 'keyword',
aggregatable: true,
})
: getEmptyTagValue(),
},
@ -78,6 +81,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
fieldName: 'http.response.status_code',
idPrefix: escapeDataProviderId(`${tableId}-table-statuses-${path}`),
fieldType: 'keyword',
aggregatable: true,
displayCount: 3,
})
: getEmptyTagValue(),
@ -91,6 +95,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
fieldName: 'host.name',
idPrefix: escapeDataProviderId(`${tableId}-table-lastHost-${path}`),
fieldType: 'keyword',
aggregatable: true,
})
: getEmptyTagValue(),
},
@ -103,6 +108,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
fieldName: 'source.ip',
idPrefix: escapeDataProviderId(`${tableId}-table-lastSourceIp-${path}`),
fieldType: 'keyword',
aggregatable: true,
render: () => <NetworkDetailsLink ip={lastSourceIp} />,
})
: getEmptyTagValue(),

View file

@ -117,6 +117,7 @@ export const getNetworkTopNFlowColumns = (
values: domains,
fieldName: domainAttr,
fieldType: 'keyword',
aggregatable: true,
idPrefix: id,
displayCount: 1,
});
@ -140,6 +141,7 @@ export const getNetworkTopNFlowColumns = (
values: [as.name],
fieldName: `${flowTarget}.as.organization.name`,
fieldType: 'keyword',
aggregatable: true,
idPrefix: `${id}-name`,
})}
@ -151,6 +153,7 @@ export const getNetworkTopNFlowColumns = (
fieldName: `${flowTarget}.as.number`,
idPrefix: `${id}-number`,
fieldType: 'keyword',
aggregatable: true,
})}
</>
)}

View file

@ -36,6 +36,7 @@ export const getTlsColumns = (tableId: string): TlsColumns => [
values: issuers,
fieldName: 'tls.server.issuer',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `${tableId}-${_id}-table-issuers`,
}),
},
@ -50,6 +51,7 @@ export const getTlsColumns = (tableId: string): TlsColumns => [
values: subjects,
fieldName: 'tls.server.subject',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `${tableId}-${_id}-table-subjects`,
}),
},
@ -64,6 +66,7 @@ export const getTlsColumns = (tableId: string): TlsColumns => [
values: sha1 ? [sha1] : undefined,
fieldName: 'tls.server.hash.sha1',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `${tableId}-${sha1}-table-sha1`,
}),
},
@ -78,6 +81,7 @@ export const getTlsColumns = (tableId: string): TlsColumns => [
values: ja3,
fieldName: 'tls.server.ja3s',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `${tableId}-${_id}-table-ja3`,
}),
},
@ -92,6 +96,7 @@ export const getTlsColumns = (tableId: string): TlsColumns => [
values: notAfter,
fieldName: 'tls.server.not_after',
fieldType: 'date',
aggregatable: false,
idPrefix: `${tableId}-${_id}-table-notAfter`,
render: (validUntil) => (
<LocalizedDateTooltip date={moment(new Date(validUntil)).toDate()}>

View file

@ -35,6 +35,7 @@ export const getUsersColumns = (
values: userName ? [userName] : undefined,
fieldName: 'user.name',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `${tableId}-table-${flowTarget}-user`,
}),
},
@ -49,6 +50,7 @@ export const getUsersColumns = (
values: userIds,
fieldName: 'user.id',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `${tableId}-table-${flowTarget}`,
}),
},
@ -63,6 +65,7 @@ export const getUsersColumns = (
values: groupNames,
fieldName: 'user.group.name',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `${tableId}-table-${flowTarget}`,
}),
},
@ -77,6 +80,7 @@ export const getUsersColumns = (
values: groupId,
fieldName: 'user.group.id',
fieldType: 'keyword',
aggregatable: true,
idPrefix: `${tableId}-table-${flowTarget}`,
}),
},

View file

@ -85,6 +85,7 @@ const getUsersColumns = (
values: [name],
idPrefix: `users-table-${name}-name`,
render: (item) => <UserDetailsLink userName={item} />,
aggregatable: true,
fieldType: 'keyword',
})
: getOrEmptyTagFromValue(name),
@ -109,6 +110,7 @@ const getUsersColumns = (
fieldName: 'user.domain',
values: [domain],
idPrefix: `users-table-${domain}-domain`,
aggregatable: true,
fieldType: 'keyword',
})
: getOrEmptyTagFromValue(domain),

View file

@ -1,38 +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 { render } from '@testing-library/react';
import React from 'react';
import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__';
import { TestProviders } from '../../../../common/mock';
import { EntityAnalyticsHoverActions } from './entity_hover_actions';
const mockedUseKibana = mockUseKibana();
jest.mock('../../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../../common/lib/kibana');
return {
...original,
useKibana: () => mockedUseKibana,
};
});
describe('EntityAnalyticsHoverActions', () => {
it('it renders "add to timeline" and "copy" hover action', () => {
const { getByTestId } = render(
<EntityAnalyticsHoverActions
idPrefix={`my-test-field`}
fieldName={'test.field'}
fieldValue={'testValue'}
/>,
{ wrapper: TestProviders }
);
expect(getByTestId('test-add-to-timeline')).toBeInTheDocument();
expect(getByTestId('test-copy-button')).toBeInTheDocument();
});
});

View file

@ -1,72 +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 React, { useMemo } from 'react';
import { noop } from 'lodash/fp';
import type { DataProvider } from '../../../../../common/types';
import { IS_OPERATOR } from '../../../../../common/types';
import { escapeDataProviderId } from '../../../../common/components/drag_and_drop/helpers';
import { SecurityPageName } from '../../../../app/types';
import { HoverActions } from '../../../../common/components/hover_actions';
interface Props {
onFilterAdded?: () => void;
fieldName: string;
fieldValue: string;
idPrefix: string;
}
export const EntityAnalyticsHoverActions: React.FC<Props> = ({
fieldName,
fieldValue,
idPrefix,
onFilterAdded,
}) => {
const id = useMemo(
() => escapeDataProviderId(`${idPrefix}-${fieldName}-${fieldValue}`),
[idPrefix, fieldName, fieldValue]
);
const dataProvider: DataProvider = useMemo(
() => ({
and: [],
enabled: true,
id,
name: fieldValue,
excluded: false,
kqlQuery: '',
queryMatch: {
field: fieldName,
value: fieldValue,
displayValue: fieldValue,
operator: IS_OPERATOR,
},
}),
[fieldName, fieldValue, id]
);
return (
<HoverActions
applyWidthAndPadding
closeTopN={noop}
dataProvider={dataProvider}
dataType={'string'}
field={fieldName}
fieldType={'keyword'}
hideTopN={true}
isAggregatable
isObjectArray={false}
onFilterAdded={onFilterAdded}
ownFocus={false}
scopeId={SecurityPageName.entityAnalytics}
showTopN={false}
toggleTopN={noop}
values={[fieldValue]}
/>
);
};
EntityAnalyticsHoverActions.displayName = 'EntityAnalyticsHoverActions';

View file

@ -15,21 +15,17 @@ export const StyledBasicTable = styled(EuiBasicTable)`
}
}
.timelines__hoverActionButton {
.inlineActions {
opacity: 0;
}
.EntityAnalyticsTableHoverActions {
.hoverActions-active {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
opacity: 1;
}
.inlineActions-popoverOpen {
opacity: 1;
}
&:hover {
.timelines__hoverActionButton,
.securitySolution__hoverActionButton {
.inlineActions {
opacity: 1;
}
}

View file

@ -9,6 +9,8 @@ import React from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { EuiLink, EuiIcon, EuiToolTip } from '@elastic/eui';
import { get } from 'lodash/fp';
import { CellActions, CellActionsMode } from '@kbn/cell-actions';
import styled from 'styled-components';
import { UsersTableType } from '../../../../explore/users/store/model';
import { getEmptyTagValue } from '../../../../common/components/empty_value';
import { HostDetailsLink, UserDetailsLink } from '../../../../common/components/links';
@ -22,10 +24,17 @@ import type {
import { RiskScoreEntity, RiskScoreFields } from '../../../../../common/search_strategy';
import * as i18n from './translations';
import { FormattedCount } from '../../../../common/components/formatted_number';
import { EntityAnalyticsHoverActions } from '../common/entity_hover_actions';
import { CELL_ACTIONS_DEFAULT_TRIGGER } from '../../../../../common/constants';
import { ACTION_ID as FILTER_IN_ACTION_ID } from '../../../../actions/filter/default/filter_in';
import { ACTION_ID as FILTER_OUT_ACTION_ID } from '../../../../actions/filter/default/filter_out';
import { ACTION_ID as SHOW_TOP_N_ACTION_ID } from '../../../../actions/show_top_n/default/show_top_n';
type HostRiskScoreColumns = Array<EuiBasicTableColumn<HostRiskScore & UserRiskScore>>;
const StyledCellActions = styled(CellActions)`
padding-left: ${({ theme }) => theme.eui.euiSizeS};
`;
export const getRiskScoreColumns = (
riskEntity: RiskScoreEntity,
openEntityInTimeline: (entityName: string, oldestAlertTimestamp?: string) => void
@ -40,19 +49,30 @@ export const getRiskScoreColumns = (
return riskEntity === RiskScoreEntity.host ? (
<>
<HostDetailsLink hostName={entityName} hostTab={HostsTableType.risk} />
<EntityAnalyticsHoverActions
idPrefix={`hosts-risk-table-${entityName}`}
fieldName={'host.name'}
fieldValue={entityName}
<StyledCellActions
field={{
name: 'host.name',
value: entityName,
type: 'keyword',
}}
triggerId={CELL_ACTIONS_DEFAULT_TRIGGER}
mode={CellActionsMode.INLINE}
visibleCellActions={2}
disabledActions={[SHOW_TOP_N_ACTION_ID, FILTER_IN_ACTION_ID, FILTER_OUT_ACTION_ID]}
/>
</>
) : (
<>
<UserDetailsLink userName={entityName} userTab={UsersTableType.risk} />
<EntityAnalyticsHoverActions
idPrefix={`users-risk-table-${entityName}`}
fieldName={'user.name'}
fieldValue={entityName}
<StyledCellActions
field={{
name: 'user.name',
value: entityName,
type: 'keyword',
}}
triggerId={CELL_ACTIONS_DEFAULT_TRIGGER}
mode={CellActionsMode.INLINE}
disabledActions={[SHOW_TOP_N_ACTION_ID, FILTER_IN_ACTION_ID, FILTER_OUT_ACTION_ID]}
/>
</>
);

View file

@ -288,13 +288,23 @@ interface MoreContainerProps {
fieldType: string;
values: string[];
idPrefix: string;
isAggregatable?: boolean;
moreMaxHeight: string;
overflowIndexStart: number;
render?: (item: string) => React.ReactNode;
}
export const MoreContainer = React.memo<MoreContainerProps>(
({ fieldName, fieldType, idPrefix, moreMaxHeight, overflowIndexStart, render, values }) => {
({
fieldName,
fieldType,
idPrefix,
isAggregatable,
moreMaxHeight,
overflowIndexStart,
render,
values,
}) => {
const { timelineId } = useContext(TimelineContext);
const moreItemsWithHoverActions = useMemo(
@ -317,6 +327,7 @@ export const MoreContainer = React.memo<MoreContainerProps>(
name: fieldName,
value,
type: fieldType,
aggregatable: isAggregatable,
}}
>
<>{render ? render(value) : defaultToEmptyTag(value)}</>
@ -327,7 +338,16 @@ export const MoreContainer = React.memo<MoreContainerProps>(
return acc;
}, []),
[fieldName, fieldType, idPrefix, overflowIndexStart, render, values, timelineId]
[
fieldName,
fieldType,
idPrefix,
overflowIndexStart,
render,
values,
timelineId,
isAggregatable,
]
);
return (
@ -349,7 +369,16 @@ export const MoreContainer = React.memo<MoreContainerProps>(
MoreContainer.displayName = 'MoreContainer';
export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverflowProps>(
({ attrName, idPrefix, moreMaxHeight, overflowIndexStart = 5, render, rowItems, fieldType }) => {
({
attrName,
idPrefix,
moreMaxHeight,
overflowIndexStart = 5,
render,
rowItems,
fieldType,
isAggregatable,
}) => {
const [isOpen, setIsOpen] = useState(false);
const togglePopover = useCallback(() => setIsOpen((currentIsOpen) => !currentIsOpen), []);
const button = useMemo(
@ -391,6 +420,7 @@ export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverf
moreMaxHeight={moreMaxHeight}
overflowIndexStart={overflowIndexStart}
fieldType={fieldType}
isAggregatable={isAggregatable}
/>
</EuiPopover>
)}