mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
2816d2bf8c
commit
c3adc5b29c
58 changed files with 925 additions and 922 deletions
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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[]>;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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"]';
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -28,6 +28,7 @@ export const ScoreComponent = ({
|
|||
name: score.entityName,
|
||||
value: score.entityValue,
|
||||
type: 'keyword',
|
||||
aggregatable: true,
|
||||
}}
|
||||
triggerId={CELL_ACTIONS_DEFAULT_TRIGGER}
|
||||
visibleCellActions={5}
|
||||
|
|
|
@ -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} />,
|
||||
}),
|
||||
|
|
|
@ -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} />,
|
||||
}),
|
||||
|
|
|
@ -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} />,
|
||||
}),
|
||||
|
|
|
@ -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>);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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} />,
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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`,
|
||||
}),
|
||||
},
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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()}>
|
||||
|
|
|
@ -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}`,
|
||||
}),
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue