mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -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 React, { useMemo, useRef } from 'react';
|
||||||
|
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||||
import { InlineActions } from './inline_actions';
|
import { InlineActions } from './inline_actions';
|
||||||
import { HoverActionsPopover } from './hover_actions_popover';
|
import { HoverActionsPopover } from './hover_actions_popover';
|
||||||
import { CellActionsMode, type CellActionsProps, type CellActionExecutionContext } from '../types';
|
import { CellActionsMode, type CellActionsProps, type CellActionExecutionContext } from '../types';
|
||||||
|
@ -18,7 +19,9 @@ export const CellActions: React.FC<CellActionsProps> = ({
|
||||||
mode,
|
mode,
|
||||||
showActionTooltips = true,
|
showActionTooltips = true,
|
||||||
visibleCellActions = 3,
|
visibleCellActions = 3,
|
||||||
|
disabledActions = [],
|
||||||
metadata,
|
metadata,
|
||||||
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const extraContentNodeRef = useRef<HTMLDivElement | null>(null);
|
const extraContentNodeRef = useRef<HTMLDivElement | null>(null);
|
||||||
const nodeRef = 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}`;
|
const dataTestSubj = `cellActions-renderContent-${field.name}`;
|
||||||
|
|
||||||
if (mode === CellActionsMode.HOVER) {
|
if (mode === CellActionsMode.HOVER) {
|
||||||
return (
|
return (
|
||||||
<div ref={nodeRef} data-test-subj={dataTestSubj}>
|
<div className={className} ref={nodeRef} data-test-subj={dataTestSubj}>
|
||||||
<HoverActionsPopover
|
<HoverActionsPopover
|
||||||
actionContext={actionContext}
|
actionContext={actionContext}
|
||||||
showActionTooltips={showActionTooltips}
|
showActionTooltips={showActionTooltips}
|
||||||
visibleCellActions={visibleCellActions}
|
visibleCellActions={visibleCellActions}
|
||||||
|
disabledActions={disabledActions}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</HoverActionsPopover>
|
</HoverActionsPopover>
|
||||||
|
@ -53,14 +56,23 @@ export const CellActions: React.FC<CellActionsProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={nodeRef} data-test-subj={dataTestSubj}>
|
<EuiFlexGroup
|
||||||
{children}
|
responsive={false}
|
||||||
<InlineActions
|
alignItems="center"
|
||||||
actionContext={actionContext}
|
ref={nodeRef}
|
||||||
showActionTooltips={showActionTooltips}
|
gutterSize="none"
|
||||||
visibleCellActions={visibleCellActions}
|
justifyContent="flexStart"
|
||||||
/>
|
>
|
||||||
<div ref={extraContentNodeRef} />
|
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
|
||||||
</div>
|
<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}
|
key={action.id}
|
||||||
icon={action.getIconType(actionContext)}
|
icon={action.getIconType(actionContext)}
|
||||||
aria-label={action.getDisplayName(actionContext)}
|
aria-label={action.getDisplayName(actionContext)}
|
||||||
|
data-test-subj={`actionItem-${action.id}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
closePopOver();
|
closePopOver();
|
||||||
action.execute(actionContext);
|
action.execute(actionContext);
|
||||||
|
|
|
@ -8,12 +8,16 @@
|
||||||
|
|
||||||
import { act, fireEvent, render } from '@testing-library/react';
|
import { act, fireEvent, render } from '@testing-library/react';
|
||||||
import React from '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 { HoverActionsPopover } from './hover_actions_popover';
|
||||||
import { CellActionsProvider } from '../context/cell_actions_context';
|
|
||||||
|
|
||||||
describe('HoverActionsPopover', () => {
|
describe('HoverActionsPopover', () => {
|
||||||
const actionContext = makeActionContext();
|
const actionContext = {
|
||||||
|
trigger: { id: 'triggerId' },
|
||||||
|
field: { name: 'fieldName' },
|
||||||
|
} as CellActionExecutionContext;
|
||||||
const TestComponent = () => <span data-test-subj="test-component" />;
|
const TestComponent = () => <span data-test-subj="test-component" />;
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
@ -22,6 +26,7 @@ describe('HoverActionsPopover', () => {
|
||||||
const { queryByTestId } = render(
|
const { queryByTestId } = render(
|
||||||
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
||||||
<HoverActionsPopover
|
<HoverActionsPopover
|
||||||
|
disabledActions={[]}
|
||||||
children={null}
|
children={null}
|
||||||
visibleCellActions={4}
|
visibleCellActions={4}
|
||||||
actionContext={actionContext}
|
actionContext={actionContext}
|
||||||
|
@ -40,6 +45,7 @@ describe('HoverActionsPopover', () => {
|
||||||
const { queryByLabelText, getByTestId } = render(
|
const { queryByLabelText, getByTestId } = render(
|
||||||
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
||||||
<HoverActionsPopover
|
<HoverActionsPopover
|
||||||
|
disabledActions={[]}
|
||||||
visibleCellActions={4}
|
visibleCellActions={4}
|
||||||
actionContext={actionContext}
|
actionContext={actionContext}
|
||||||
showActionTooltips={false}
|
showActionTooltips={false}
|
||||||
|
@ -65,6 +71,7 @@ describe('HoverActionsPopover', () => {
|
||||||
const { queryByLabelText, getByTestId } = render(
|
const { queryByLabelText, getByTestId } = render(
|
||||||
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
||||||
<HoverActionsPopover
|
<HoverActionsPopover
|
||||||
|
disabledActions={[]}
|
||||||
visibleCellActions={4}
|
visibleCellActions={4}
|
||||||
actionContext={actionContext}
|
actionContext={actionContext}
|
||||||
showActionTooltips={false}
|
showActionTooltips={false}
|
||||||
|
@ -95,6 +102,7 @@ describe('HoverActionsPopover', () => {
|
||||||
const { getByTestId } = render(
|
const { getByTestId } = render(
|
||||||
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
||||||
<HoverActionsPopover
|
<HoverActionsPopover
|
||||||
|
disabledActions={[]}
|
||||||
visibleCellActions={1}
|
visibleCellActions={1}
|
||||||
actionContext={actionContext}
|
actionContext={actionContext}
|
||||||
showActionTooltips={false}
|
showActionTooltips={false}
|
||||||
|
@ -120,6 +128,7 @@ describe('HoverActionsPopover', () => {
|
||||||
const { getByTestId, getByLabelText } = render(
|
const { getByTestId, getByLabelText } = render(
|
||||||
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
||||||
<HoverActionsPopover
|
<HoverActionsPopover
|
||||||
|
disabledActions={[]}
|
||||||
visibleCellActions={1}
|
visibleCellActions={1}
|
||||||
actionContext={actionContext}
|
actionContext={actionContext}
|
||||||
showActionTooltips={false}
|
showActionTooltips={false}
|
||||||
|
@ -154,6 +163,7 @@ describe('HoverActionsPopover', () => {
|
||||||
const { getByTestId, queryByLabelText } = render(
|
const { getByTestId, queryByLabelText } = render(
|
||||||
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
||||||
<HoverActionsPopover
|
<HoverActionsPopover
|
||||||
|
disabledActions={[]}
|
||||||
visibleCellActions={2}
|
visibleCellActions={2}
|
||||||
actionContext={actionContext}
|
actionContext={actionContext}
|
||||||
showActionTooltips={false}
|
showActionTooltips={false}
|
||||||
|
|
|
@ -40,6 +40,7 @@ interface Props {
|
||||||
visibleCellActions: number;
|
visibleCellActions: number;
|
||||||
actionContext: CellActionExecutionContext;
|
actionContext: CellActionExecutionContext;
|
||||||
showActionTooltips: boolean;
|
showActionTooltips: boolean;
|
||||||
|
disabledActions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HoverActionsPopover: React.FC<Props> = ({
|
export const HoverActionsPopover: React.FC<Props> = ({
|
||||||
|
@ -47,12 +48,13 @@ export const HoverActionsPopover: React.FC<Props> = ({
|
||||||
visibleCellActions,
|
visibleCellActions,
|
||||||
actionContext,
|
actionContext,
|
||||||
showActionTooltips,
|
showActionTooltips,
|
||||||
|
disabledActions,
|
||||||
}) => {
|
}) => {
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [isExtraActionsPopoverOpen, setIsExtraActionsPopoverOpen] = useState(false);
|
const [isExtraActionsPopoverOpen, setIsExtraActionsPopoverOpen] = useState(false);
|
||||||
const [showHoverContent, setShowHoverContent] = useState(false);
|
const [showHoverContent, setShowHoverContent] = useState(false);
|
||||||
|
|
||||||
const [{ value: actions }, loadActions] = useLoadActionsFn();
|
const [{ value: actions }, loadActions] = useLoadActionsFn({ disabledActions });
|
||||||
|
|
||||||
const { visibleActions, extraActions } = useMemo(
|
const { visibleActions, extraActions } = useMemo(
|
||||||
() => partitionActions(actions ?? [], visibleCellActions),
|
() => partitionActions(actions ?? [], visibleCellActions),
|
||||||
|
|
|
@ -8,19 +8,20 @@
|
||||||
|
|
||||||
import { act, render } from '@testing-library/react';
|
import { act, render } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { makeAction, makeActionContext } from '../mocks/helpers';
|
import { makeAction } from '../mocks/helpers';
|
||||||
import { InlineActions } from './inline_actions';
|
import { InlineActions } from './inline_actions';
|
||||||
import { CellActionsProvider } from '../context/cell_actions_context';
|
import { CellActionExecutionContext } from '../types';
|
||||||
|
import { CellActionsProvider } from '../context';
|
||||||
|
|
||||||
describe('InlineActions', () => {
|
describe('InlineActions', () => {
|
||||||
const actionContext = makeActionContext();
|
const actionContext = { trigger: { id: 'triggerId' } } as CellActionExecutionContext;
|
||||||
|
|
||||||
it('renders', async () => {
|
it('renders', async () => {
|
||||||
const getActionsPromise = Promise.resolve([]);
|
const getActionsPromise = Promise.resolve([]);
|
||||||
const getActions = () => getActionsPromise;
|
const getActions = () => getActionsPromise;
|
||||||
const { queryByTestId } = render(
|
const { queryByTestId } = render(
|
||||||
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
||||||
<InlineActions
|
<InlineActions
|
||||||
|
disabledActions={[]}
|
||||||
visibleCellActions={5}
|
visibleCellActions={5}
|
||||||
actionContext={actionContext}
|
actionContext={actionContext}
|
||||||
showActionTooltips={false}
|
showActionTooltips={false}
|
||||||
|
@ -47,6 +48,7 @@ describe('InlineActions', () => {
|
||||||
const { queryAllByRole } = render(
|
const { queryAllByRole } = render(
|
||||||
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
<CellActionsProvider getTriggerCompatibleActions={getActions}>
|
||||||
<InlineActions
|
<InlineActions
|
||||||
|
disabledActions={[]}
|
||||||
visibleCellActions={5}
|
visibleCellActions={5}
|
||||||
actionContext={actionContext}
|
actionContext={actionContext}
|
||||||
showActionTooltips={false}
|
showActionTooltips={false}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||||
import { ActionItem } from './cell_action_item';
|
import { ActionItem } from './cell_action_item';
|
||||||
import { usePartitionActions } from '../hooks/actions';
|
import { usePartitionActions } from '../hooks/actions';
|
||||||
import { ExtraActionsPopOver } from './extra_actions_popover';
|
import { ExtraActionsPopOver } from './extra_actions_popover';
|
||||||
|
@ -18,18 +19,18 @@ interface InlineActionsProps {
|
||||||
actionContext: CellActionExecutionContext;
|
actionContext: CellActionExecutionContext;
|
||||||
showActionTooltips: boolean;
|
showActionTooltips: boolean;
|
||||||
visibleCellActions: number;
|
visibleCellActions: number;
|
||||||
|
disabledActions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InlineActions: React.FC<InlineActionsProps> = ({
|
export const InlineActions: React.FC<InlineActionsProps> = ({
|
||||||
actionContext,
|
actionContext,
|
||||||
showActionTooltips,
|
showActionTooltips,
|
||||||
visibleCellActions,
|
visibleCellActions,
|
||||||
|
disabledActions,
|
||||||
}) => {
|
}) => {
|
||||||
const { value: allActions } = useLoadActions(actionContext);
|
const { value: actions } = useLoadActions(actionContext, { disabledActions });
|
||||||
const { extraActions, visibleActions } = usePartitionActions(
|
const { extraActions, visibleActions } = usePartitionActions(actions ?? [], visibleCellActions);
|
||||||
allActions ?? [],
|
|
||||||
visibleCellActions
|
|
||||||
);
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
const togglePopOver = useCallback(() => setIsPopoverOpen((isOpen) => !isOpen), []);
|
const togglePopOver = useCallback(() => setIsPopoverOpen((isOpen) => !isOpen), []);
|
||||||
const closePopOver = useCallback(() => setIsPopoverOpen(false), []);
|
const closePopOver = useCallback(() => setIsPopoverOpen(false), []);
|
||||||
|
@ -39,24 +40,34 @@ export const InlineActions: React.FC<InlineActionsProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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) => (
|
{visibleActions.map((action, index) => (
|
||||||
<ActionItem
|
<EuiFlexItem grow={false}>
|
||||||
key={`action-item-${index}`}
|
<ActionItem
|
||||||
action={action}
|
key={`action-item-${index}`}
|
||||||
actionContext={actionContext}
|
action={action}
|
||||||
showTooltip={showActionTooltips}
|
actionContext={actionContext}
|
||||||
/>
|
showTooltip={showActionTooltips}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
))}
|
))}
|
||||||
{extraActions.length > 0 ? (
|
{extraActions.length > 0 ? (
|
||||||
<ExtraActionsPopOver
|
<EuiFlexItem grow={false}>
|
||||||
actions={extraActions}
|
<ExtraActionsPopOver
|
||||||
actionContext={actionContext}
|
actions={extraActions}
|
||||||
button={button}
|
actionContext={actionContext}
|
||||||
closePopOver={closePopOver}
|
button={button}
|
||||||
isOpen={isPopoverOpen}
|
closePopOver={closePopOver}
|
||||||
/>
|
isOpen={isPopoverOpen}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</EuiFlexGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,13 +25,14 @@ interface BulkField extends Pick<CellActionField, 'name' | 'type'> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseDataGridColumnsCellActionsProps
|
export interface UseDataGridColumnsCellActionsProps
|
||||||
extends Pick<CellActionsProps, 'triggerId' | 'metadata'> {
|
extends Pick<CellActionsProps, 'triggerId' | 'metadata' | 'disabledActions'> {
|
||||||
fields: BulkField[];
|
fields: BulkField[];
|
||||||
}
|
}
|
||||||
export const useDataGridColumnsCellActions = ({
|
export const useDataGridColumnsCellActions = ({
|
||||||
fields,
|
fields,
|
||||||
triggerId,
|
triggerId,
|
||||||
metadata,
|
metadata,
|
||||||
|
disabledActions = [],
|
||||||
}: UseDataGridColumnsCellActionsProps): EuiDataGridColumnCellAction[][] => {
|
}: UseDataGridColumnsCellActionsProps): EuiDataGridColumnCellAction[][] => {
|
||||||
const bulkContexts: CellActionCompatibilityContext[] = useMemo(
|
const bulkContexts: CellActionCompatibilityContext[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
@ -43,7 +44,7 @@ export const useDataGridColumnsCellActions = ({
|
||||||
[fields, triggerId, metadata]
|
[fields, triggerId, metadata]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { loading, value: columnsActions } = useBulkLoadActions(bulkContexts);
|
const { loading, value: columnsActions } = useBulkLoadActions(bulkContexts, { disabledActions });
|
||||||
|
|
||||||
const columnsCellActions = useMemo<EuiDataGridColumnCellAction[][]>(() => {
|
const columnsCellActions = useMemo<EuiDataGridColumnCellAction[][]>(() => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { makeAction, makeActionContext } from '../mocks/helpers';
|
||||||
import { useBulkLoadActions, useLoadActions, useLoadActionsFn } from './use_load_actions';
|
import { useBulkLoadActions, useLoadActions, useLoadActionsFn } from './use_load_actions';
|
||||||
|
|
||||||
const action = makeAction('action-1', 'icon', 1);
|
const action = makeAction('action-1', 'icon', 1);
|
||||||
const mockGetActions = jest.fn(async () => [action]);
|
const mockGetActions = jest.fn();
|
||||||
jest.mock('../context/cell_actions_context', () => ({
|
jest.mock('../context/cell_actions_context', () => ({
|
||||||
useCellActionsContext: () => ({ getActions: mockGetActions }),
|
useCellActionsContext: () => ({ getActions: mockGetActions }),
|
||||||
}));
|
}));
|
||||||
|
@ -21,6 +21,7 @@ describe('loadActions hooks', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
mockGetActions.mockResolvedValue([action]);
|
||||||
});
|
});
|
||||||
describe('useLoadActions', () => {
|
describe('useLoadActions', () => {
|
||||||
it('should load actions when called', async () => {
|
it('should load actions when called', async () => {
|
||||||
|
@ -50,6 +51,20 @@ describe('loadActions hooks', () => {
|
||||||
|
|
||||||
expect(result.error?.message).toEqual(message);
|
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', () => {
|
describe('useLoadActionsFn', () => {
|
||||||
|
@ -94,6 +109,25 @@ describe('loadActions hooks', () => {
|
||||||
|
|
||||||
expect(result.error?.message).toEqual(message);
|
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', () => {
|
describe('useBulkLoadActions', () => {
|
||||||
|
@ -128,5 +162,19 @@ describe('loadActions hooks', () => {
|
||||||
|
|
||||||
expect(result.error?.message).toEqual(message);
|
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.
|
* Side Public License, v 1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
import useAsync from 'react-use/lib/useAsync';
|
import useAsync from 'react-use/lib/useAsync';
|
||||||
import useAsyncFn, { type AsyncState } from 'react-use/lib/useAsyncFn';
|
import useAsyncFn, { type AsyncState } from 'react-use/lib/useAsyncFn';
|
||||||
import { useCellActionsContext } from '../context/cell_actions_context';
|
import { useCellActionsContext } from '../context/cell_actions_context';
|
||||||
|
@ -22,34 +23,57 @@ const useThrowError = (error?: Error) => {
|
||||||
/**
|
/**
|
||||||
* Performs the getActions async call and returns its value
|
* 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 { getActions } = useCellActionsContext();
|
||||||
const { error, ...actionsState } = useAsync(() => getActions(context), []);
|
const { error, value, loading } = useAsync(() => getActions(context), []);
|
||||||
|
const filteredActions = useFilteredActions(value, options.disabledActions);
|
||||||
useThrowError(error);
|
useThrowError(error);
|
||||||
return actionsState;
|
return { value: filteredActions, loading };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a function to perform the getActions async call
|
* 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 { getActions } = useCellActionsContext();
|
||||||
const [{ error, ...actionsState }, loadActions] = useAsyncFn(getActions, []);
|
const [{ error, value, loading }, loadActions] = useAsyncFn(getActions, []);
|
||||||
|
const filteredActions = useFilteredActions(value, options.disabledActions);
|
||||||
useThrowError(error);
|
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
|
* Groups getActions calls for an array of contexts in one async bulk operation
|
||||||
*/
|
*/
|
||||||
export const useBulkLoadActions = (
|
export const useBulkLoadActions = (
|
||||||
contexts: CellActionCompatibilityContext[]
|
contexts: CellActionCompatibilityContext[],
|
||||||
|
options: LoadActionsOptions = {}
|
||||||
): AsyncActions<CellAction[][]> => {
|
): AsyncActions<CellAction[][]> => {
|
||||||
const { getActions } = useCellActionsContext();
|
const { getActions } = useCellActionsContext();
|
||||||
const { error, ...actionsState } = useAsync(
|
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);
|
useThrowError(error);
|
||||||
return actionsState;
|
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'
|
* Example: 'My-Laptop'
|
||||||
*/
|
*/
|
||||||
value: string | string[] | null | undefined;
|
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 {
|
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.
|
* It shows 'more actions' button when the number of actions is bigger than this parameter.
|
||||||
*/
|
*/
|
||||||
visibleCellActions?: number;
|
visibleCellActions?: number;
|
||||||
|
/**
|
||||||
|
* List of Actions ids that shouldn't be displayed inside cell actions.
|
||||||
|
*/
|
||||||
|
disabledActions?: string[];
|
||||||
/**
|
/**
|
||||||
* Custom set of properties used by some actions.
|
* Custom set of properties used by some actions.
|
||||||
* An action might require a specific set of metadata properties to render.
|
* An action might require a specific set of metadata properties to render.
|
||||||
* This data is sent directly to actions.
|
* This data is sent directly to actions.
|
||||||
*/
|
*/
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Metadata = Record<string, unknown> | undefined;
|
||||||
|
|
||||||
export interface CellActionExecutionContext extends ActionExecutionContext {
|
export interface CellActionExecutionContext extends ActionExecutionContext {
|
||||||
field: CellActionField;
|
field: CellActionField;
|
||||||
/**
|
/**
|
||||||
|
@ -92,18 +108,19 @@ export interface CellActionExecutionContext extends ActionExecutionContext {
|
||||||
/**
|
/**
|
||||||
* Extra configurations for actions.
|
* 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
|
* 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.
|
* Extra configurations for actions.
|
||||||
*/
|
*/
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: M;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CellAction<C extends CellActionExecutionContext = CellActionExecutionContext>
|
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,
|
* Returns a promise that resolves to true if this action is compatible given the context,
|
||||||
* otherwise resolves to false.
|
* otherwise resolves to false.
|
||||||
*/
|
*/
|
||||||
isCompatible(context: CellActionCompatibilityContext): Promise<boolean>;
|
isCompatible(context: CellActionCompatibilityContext<C['metadata']>): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetActions = (context: CellActionCompatibilityContext) => Promise<CellAction[]>;
|
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_DEFAULT_TRIGGER = 'security-solution-default-cellActions';
|
||||||
export const CELL_ACTIONS_TIMELINE_TRIGGER = 'security-solution-timeline-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_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"]';
|
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_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"]';
|
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 =
|
export const TIMELINE_TAB_CONTENT_GRAPHS_NOTES =
|
||||||
'[data-test-subj="timeline-tab-content-graph-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"]';
|
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', {
|
export const FILTER_IN = i18n.translate('xpack.securitySolution.actions.filterIn', {
|
||||||
defaultMessage: 'Filter In',
|
defaultMessage: 'Filter In',
|
||||||
});
|
});
|
||||||
const ID = 'security_filterIn';
|
export const ACTION_ID = 'security_filterIn';
|
||||||
const ICON = 'plusInCircle';
|
const ICON = 'plusInCircle';
|
||||||
|
|
||||||
export const createFilterInAction = ({ order }: { order?: number }): CellAction => ({
|
export const createFilterInAction = ({ order }: { order?: number }): CellAction => ({
|
||||||
id: ID,
|
id: ACTION_ID,
|
||||||
type: ID,
|
type: ACTION_ID,
|
||||||
order,
|
order,
|
||||||
getIconType: (): string => ICON,
|
getIconType: (): string => ICON,
|
||||||
getDisplayName: () => FILTER_IN,
|
getDisplayName: () => FILTER_IN,
|
||||||
|
|
|
@ -14,12 +14,12 @@ import { fieldHasCellActions } from '../../utils';
|
||||||
export const FILTER_OUT = i18n.translate('xpack.securitySolution.actions.filterOut', {
|
export const FILTER_OUT = i18n.translate('xpack.securitySolution.actions.filterOut', {
|
||||||
defaultMessage: 'Filter Out',
|
defaultMessage: 'Filter Out',
|
||||||
});
|
});
|
||||||
const ID = 'security_filterOut';
|
export const ACTION_ID = 'security_filterOut';
|
||||||
const ICON = 'minusInCircle';
|
const ICON = 'minusInCircle';
|
||||||
|
|
||||||
export const createFilterOutAction = ({ order }: { order?: number }): CellAction => ({
|
export const createFilterOutAction = ({ order }: { order?: number }): CellAction => ({
|
||||||
id: ID,
|
id: ACTION_ID,
|
||||||
type: ID,
|
type: ACTION_ID,
|
||||||
order,
|
order,
|
||||||
getIconType: (): string => ICON,
|
getIconType: (): string => ICON,
|
||||||
getDisplayName: () => FILTER_OUT,
|
getDisplayName: () => FILTER_OUT,
|
||||||
|
|
|
@ -24,8 +24,10 @@ import { createLensAddToTimelineAction, createDefaultAddToTimelineAction } from
|
||||||
import { createDefaultShowTopNAction } from './show_top_n';
|
import { createDefaultShowTopNAction } from './show_top_n';
|
||||||
import {
|
import {
|
||||||
CELL_ACTIONS_DEFAULT_TRIGGER,
|
CELL_ACTIONS_DEFAULT_TRIGGER,
|
||||||
|
CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER,
|
||||||
CELL_ACTIONS_TIMELINE_TRIGGER,
|
CELL_ACTIONS_TIMELINE_TRIGGER,
|
||||||
} from '../../common/constants';
|
} from '../../common/constants';
|
||||||
|
import { createDefaultToggleColumnAction } from './toggle_column';
|
||||||
|
|
||||||
export const registerUIActions = (
|
export const registerUIActions = (
|
||||||
plugins: StartPlugins,
|
plugins: StartPlugins,
|
||||||
|
@ -36,6 +38,7 @@ export const registerUIActions = (
|
||||||
registerLensActions(plugins.uiActions, store);
|
registerLensActions(plugins.uiActions, store);
|
||||||
registerDefaultActions(plugins.uiActions, store, history, services);
|
registerDefaultActions(plugins.uiActions, store, history, services);
|
||||||
registerTimelineActions(plugins.uiActions, store, history, services);
|
registerTimelineActions(plugins.uiActions, store, history, services);
|
||||||
|
registerTableFlyoutActions(plugins.uiActions, store, history, services);
|
||||||
};
|
};
|
||||||
|
|
||||||
const registerLensActions = (uiActions: UiActionsStart, store: SecurityAppStore) => {
|
const registerLensActions = (uiActions: UiActionsStart, store: SecurityAppStore) => {
|
||||||
|
@ -53,14 +56,14 @@ const registerDefaultActions = (
|
||||||
services: StartServices
|
services: StartServices
|
||||||
) => {
|
) => {
|
||||||
const filterInAction = createDefaultFilterInAction({
|
const filterInAction = createDefaultFilterInAction({
|
||||||
order: 1,
|
order: 10,
|
||||||
});
|
});
|
||||||
const filterOutAction = createDefaultFilterOutAction({
|
const filterOutAction = createDefaultFilterOutAction({
|
||||||
order: 2,
|
order: 20,
|
||||||
});
|
});
|
||||||
const addToTimeline = createDefaultAddToTimelineAction({ store, order: 3 });
|
const addToTimeline = createDefaultAddToTimelineAction({ store, order: 30 });
|
||||||
const showTopNAction = createDefaultShowTopNAction({ store, history, services, order: 4 });
|
const showTopNAction = createDefaultShowTopNAction({ store, history, services, order: 40 });
|
||||||
const copyAction = createDefaultCopyToClipboardAction({ order: 5 });
|
const copyAction = createDefaultCopyToClipboardAction({ order: 50 });
|
||||||
|
|
||||||
uiActions.registerTrigger({
|
uiActions.registerTrigger({
|
||||||
id: CELL_ACTIONS_DEFAULT_TRIGGER,
|
id: CELL_ACTIONS_DEFAULT_TRIGGER,
|
||||||
|
@ -81,15 +84,15 @@ const registerTimelineActions = (
|
||||||
) => {
|
) => {
|
||||||
const filterInAction = createTimelineFilterInAction({
|
const filterInAction = createTimelineFilterInAction({
|
||||||
store,
|
store,
|
||||||
order: 1,
|
order: 10,
|
||||||
});
|
});
|
||||||
const filterOutAction = createTimelineFilterOutAction({
|
const filterOutAction = createTimelineFilterOutAction({
|
||||||
store,
|
store,
|
||||||
order: 2,
|
order: 20,
|
||||||
});
|
});
|
||||||
const addToTimeline = createDefaultAddToTimelineAction({ store, order: 3 });
|
const addToTimeline = createDefaultAddToTimelineAction({ store, order: 30 });
|
||||||
const showTopNAction = createDefaultShowTopNAction({ store, history, services, order: 4 });
|
const showTopNAction = createDefaultShowTopNAction({ store, history, services, order: 40 });
|
||||||
const copyAction = createDefaultCopyToClipboardAction({ order: 5 });
|
const copyAction = createDefaultCopyToClipboardAction({ order: 50 });
|
||||||
|
|
||||||
uiActions.registerTrigger({
|
uiActions.registerTrigger({
|
||||||
id: CELL_ACTIONS_TIMELINE_TRIGGER,
|
id: CELL_ACTIONS_TIMELINE_TRIGGER,
|
||||||
|
@ -101,3 +104,36 @@ const registerTimelineActions = (
|
||||||
uiActions.addTriggerAction(CELL_ACTIONS_TIMELINE_TRIGGER, showTopNAction);
|
uiActions.addTriggerAction(CELL_ACTIONS_TIMELINE_TRIGGER, showTopNAction);
|
||||||
uiActions.addTriggerAction(CELL_ACTIONS_TIMELINE_TRIGGER, addToTimeline);
|
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,
|
services: mockServices,
|
||||||
});
|
});
|
||||||
const context = {
|
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' },
|
trigger: { id: 'trigger' },
|
||||||
extraContentNodeRef: {
|
extraContentNodeRef: {
|
||||||
current: element,
|
current: element,
|
||||||
|
@ -90,6 +90,15 @@ describe('createShowTopNAction', () => {
|
||||||
await showTopNAction.isCompatible({ ...context, field: { ...context.field, type: 'text' } })
|
await showTopNAction.isCompatible({ ...context, field: { ...context.field, type: 'text' } })
|
||||||
).toEqual(false);
|
).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', () => {
|
describe('execute', () => {
|
||||||
|
|
|
@ -28,7 +28,7 @@ const SHOW_TOP = (fieldName: string) =>
|
||||||
defaultMessage: `Show top {fieldName}`,
|
defaultMessage: `Show top {fieldName}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ID = 'security_showTopN';
|
export const ACTION_ID = 'security_showTopN';
|
||||||
const ICON = 'visBarVertical';
|
const ICON = 'visBarVertical';
|
||||||
const UNSUPPORTED_FIELD_TYPES = ['date', 'text'];
|
const UNSUPPORTED_FIELD_TYPES = ['date', 'text'];
|
||||||
|
|
||||||
|
@ -56,8 +56,8 @@ export const createShowTopNAction = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: ID,
|
id: ACTION_ID,
|
||||||
type: ID,
|
type: ACTION_ID,
|
||||||
order,
|
order,
|
||||||
getIconType: (): string => ICON,
|
getIconType: (): string => ICON,
|
||||||
getDisplayName: ({ field }) => SHOW_TOP(field.name),
|
getDisplayName: ({ field }) => SHOW_TOP(field.name),
|
||||||
|
@ -65,7 +65,8 @@ export const createShowTopNAction = ({
|
||||||
isCompatible: async ({ field }) =>
|
isCompatible: async ({ field }) =>
|
||||||
isInSecurityApp(currentAppId) &&
|
isInSecurityApp(currentAppId) &&
|
||||||
fieldHasCellActions(field.name) &&
|
fieldHasCellActions(field.name) &&
|
||||||
!UNSUPPORTED_FIELD_TYPES.includes(field.type),
|
!UNSUPPORTED_FIELD_TYPES.includes(field.type) &&
|
||||||
|
!!field.aggregatable,
|
||||||
execute: async (context) => {
|
execute: async (context) => {
|
||||||
const node = context.extraContentNodeRef?.current;
|
const node = context.extraContentNodeRef?.current;
|
||||||
if (!node) return;
|
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 = {
|
const props = {
|
||||||
data: mockAlertDetailsData as TimelineEventsDetailsItem[],
|
data: mockAlertDetailsData as TimelineEventsDetailsItem[],
|
||||||
browserFields: mockBrowserFields,
|
browserFields: mockBrowserFields,
|
||||||
|
@ -62,7 +80,8 @@ describe('AlertSummaryView', () => {
|
||||||
<AlertSummaryView {...props} />
|
<AlertSummaryView {...props} />
|
||||||
</TestProviders>
|
</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} />
|
<AlertSummaryView {...props} scopeId={TimelineId.active} />
|
||||||
</TestProviders>
|
</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 }} />
|
<AlertSummaryView {...{ ...props, isReadOnly: true }} />
|
||||||
</TestProviders>
|
</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 { useMountAppended } from '../../utils/use_mount_appended';
|
||||||
import { mockBrowserFields } from '../../containers/source/mock';
|
import { mockBrowserFields } from '../../containers/source/mock';
|
||||||
import type { EventFieldsData } from './types';
|
import type { EventFieldsData } from './types';
|
||||||
import { get } from 'lodash/fp';
|
|
||||||
|
|
||||||
jest.mock('../../lib/kibana');
|
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 {
|
interface Column {
|
||||||
field: string;
|
field: string;
|
||||||
name: string | JSX.Element;
|
name: string | JSX.Element;
|
||||||
|
@ -59,95 +71,21 @@ describe('getColumns', () => {
|
||||||
actionsColumn = getColumns(defaultProps)[0] as Column;
|
actionsColumn = getColumns(defaultProps)[0] as Column;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filter in', () => {
|
test('it renders inline actions', () => {
|
||||||
test('it renders a filter for (+) button', () => {
|
const wrapper = mount(
|
||||||
const wrapper = mount(
|
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
|
||||||
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
|
) as ReactWrapper;
|
||||||
) 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 does not render inline actions when readOnly prop is passed', () => {
|
||||||
test('it renders a filter out (-) button', () => {
|
actionsColumn = getColumns({ ...defaultProps, isReadOnly: true })[0] as Column;
|
||||||
const wrapper = mount(
|
const wrapper = mount(
|
||||||
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
|
<TestProviders>{actionsColumn.render(testValue, testData)}</TestProviders>
|
||||||
) as ReactWrapper;
|
) as ReactWrapper;
|
||||||
|
|
||||||
expect(wrapper.find('[data-test-subj="hover-actions-filter-out"]').exists()).toBeTruthy();
|
expect(wrapper.find('[data-test-subj="inlineActions"]').exists()).toBeFalsy();
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,15 +10,14 @@ import { get } from 'lodash';
|
||||||
import memoizeOne from 'memoize-one';
|
import memoizeOne from 'memoize-one';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { CellActions, CellActionsMode } from '@kbn/cell-actions';
|
||||||
import type { BrowserFields } from '../../containers/source';
|
import type { BrowserFields } from '../../containers/source';
|
||||||
import type { OnUpdateColumns } from '../../../timelines/components/timeline/events';
|
|
||||||
import * as i18n from './translations';
|
import * as i18n from './translations';
|
||||||
import type { EventFieldsData } from './types';
|
import type { EventFieldsData } from './types';
|
||||||
import type { ColumnHeaderOptions } from '../../../../common/types';
|
|
||||||
import type { BrowserField } from '../../../../common/search_strategy';
|
import type { BrowserField } from '../../../../common/search_strategy';
|
||||||
import { FieldValueCell } from './table/field_value_cell';
|
import { FieldValueCell } from './table/field_value_cell';
|
||||||
import { FieldNameCell } from './table/field_name_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)`
|
const HoverActionsContainer = styled(EuiPanel)`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -35,28 +34,23 @@ const HoverActionsContainer = styled(EuiPanel)`
|
||||||
HoverActionsContainer.displayName = 'HoverActionsContainer';
|
HoverActionsContainer.displayName = 'HoverActionsContainer';
|
||||||
|
|
||||||
export const getFieldFromBrowserField = memoizeOne(
|
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()
|
(newArgs, lastArgs) => newArgs[0].join() === lastArgs[0].join()
|
||||||
);
|
);
|
||||||
export const getColumns = ({
|
export const getColumns = ({
|
||||||
browserFields,
|
browserFields,
|
||||||
columnHeaders,
|
|
||||||
eventId,
|
eventId,
|
||||||
onUpdateColumns,
|
|
||||||
contextId,
|
contextId,
|
||||||
scopeId,
|
scopeId,
|
||||||
toggleColumn,
|
|
||||||
getLinkValue,
|
getLinkValue,
|
||||||
isDraggable,
|
isDraggable,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
}: {
|
}: {
|
||||||
browserFields: BrowserFields;
|
browserFields: BrowserFields;
|
||||||
columnHeaders: ColumnHeaderOptions[];
|
|
||||||
eventId: string;
|
eventId: string;
|
||||||
onUpdateColumns: OnUpdateColumns;
|
|
||||||
contextId: string;
|
contextId: string;
|
||||||
scopeId: string;
|
scopeId: string;
|
||||||
toggleColumn: (column: ColumnHeaderOptions) => void;
|
|
||||||
getLinkValue: (field: string) => string | null;
|
getLinkValue: (field: string) => string | null;
|
||||||
isDraggable?: boolean;
|
isDraggable?: boolean;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
|
@ -74,24 +68,23 @@ export const getColumns = ({
|
||||||
truncateText: false,
|
truncateText: false,
|
||||||
width: '132px',
|
width: '132px',
|
||||||
render: (values: string[] | null | undefined, data: EventFieldsData) => {
|
render: (values: string[] | null | undefined, data: EventFieldsData) => {
|
||||||
const label = data.isObjectArray
|
|
||||||
? i18n.NESTED_COLUMN(data.field)
|
|
||||||
: i18n.VIEW_COLUMN(data.field);
|
|
||||||
const fieldFromBrowserField = getFieldFromBrowserField(
|
const fieldFromBrowserField = getFieldFromBrowserField(
|
||||||
[data.category, 'fields', data.field],
|
[data.category, 'fields', data.field],
|
||||||
browserFields
|
browserFields
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionCell
|
<CellActions
|
||||||
aria-label={label}
|
field={{
|
||||||
contextId={contextId}
|
name: data.field,
|
||||||
data={data}
|
value: values,
|
||||||
eventId={eventId}
|
type: data.type,
|
||||||
fieldFromBrowserField={fieldFromBrowserField}
|
aggregatable: fieldFromBrowserField?.aggregatable,
|
||||||
getLinkValue={getLinkValue}
|
}}
|
||||||
toggleColumn={toggleColumn}
|
triggerId={CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER}
|
||||||
scopeId={scopeId}
|
mode={CellActionsMode.INLINE}
|
||||||
values={values}
|
visibleCellActions={3}
|
||||||
|
metadata={{ scopeId, isObjectArray: data.isObjectArray }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -109,18 +102,7 @@ export const getColumns = ({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
truncateText: false,
|
truncateText: false,
|
||||||
render: (field: string, data: EventFieldsData) => {
|
render: (field: string, data: EventFieldsData) => {
|
||||||
const fieldFromBrowserField = getFieldFromBrowserField(
|
return <FieldNameCell data={data} field={field} fieldMapping={undefined} />;
|
||||||
[data.category, 'fields', field],
|
|
||||||
browserFields
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<FieldNameCell
|
|
||||||
data={data}
|
|
||||||
field={field}
|
|
||||||
fieldMapping={undefined}
|
|
||||||
fieldFromBrowserField={fieldFromBrowserField}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -7,15 +7,15 @@
|
||||||
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { get } from 'lodash/fp';
|
import { get } from 'lodash/fp';
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
import { EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||||
import { partition } from 'lodash';
|
import { partition } from 'lodash';
|
||||||
|
import { CellActions, CellActionsMode } from '@kbn/cell-actions';
|
||||||
import * as i18n from './translations';
|
import * as i18n from './translations';
|
||||||
import type { CtiEnrichment } from '../../../../../common/search_strategy/security_solution/cti';
|
import type { CtiEnrichment } from '../../../../../common/search_strategy/security_solution/cti';
|
||||||
import { getEnrichmentIdentifiers, isInvestigationTimeEnrichment } from './helpers';
|
import { getEnrichmentIdentifiers, isInvestigationTimeEnrichment } from './helpers';
|
||||||
|
|
||||||
import type { FieldsData } from '../types';
|
import type { FieldsData } from '../types';
|
||||||
import { ActionCell } from '../table/action_cell';
|
|
||||||
import type {
|
import type {
|
||||||
BrowserField,
|
BrowserField,
|
||||||
BrowserFields,
|
BrowserFields,
|
||||||
|
@ -23,6 +23,7 @@ import type {
|
||||||
} from '../../../../../common/search_strategy';
|
} from '../../../../../common/search_strategy';
|
||||||
import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field';
|
import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field';
|
||||||
import { EnrichedDataRow, ThreatSummaryPanelHeader } from './threat_summary_view';
|
import { EnrichedDataRow, ThreatSummaryPanelHeader } from './threat_summary_view';
|
||||||
|
import { CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER } from '../../../../../common/constants';
|
||||||
|
|
||||||
export interface ThreatSummaryDescription {
|
export interface ThreatSummaryDescription {
|
||||||
browserField: BrowserField;
|
browserField: BrowserField;
|
||||||
|
@ -42,16 +43,16 @@ const EnrichmentFieldFeedName = styled.span`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const StyledEuiFlexGroup = styled(EuiFlexGroup)`
|
export const StyledEuiFlexGroup = styled(EuiFlexGroup)`
|
||||||
.hoverActions-active {
|
.inlineActions {
|
||||||
.timelines__hoverActionButton,
|
opacity: 0;
|
||||||
.securitySolution__hoverActionButton {
|
}
|
||||||
opacity: 1;
|
|
||||||
}
|
.inlineActions-popoverOpen {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.timelines__hoverActionButton,
|
.inlineActions {
|
||||||
.securitySolution__hoverActionButton {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -68,8 +69,23 @@ const EnrichmentDescription: React.FC<ThreatSummaryDescription> = ({
|
||||||
isDraggable,
|
isDraggable,
|
||||||
isReadOnly,
|
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}`;
|
const key = `alert-details-value-formatted-field-value-${scopeId}-${eventId}-${data.field}-${value}-${index}-${feedName}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledEuiFlexGroup key={key} direction="row" gutterSize="xs" alignItems="center">
|
<StyledEuiFlexGroup key={key} direction="row" gutterSize="xs" alignItems="center">
|
||||||
<EuiFlexItem grow={false}>
|
<EuiFlexItem grow={false}>
|
||||||
|
@ -95,14 +111,12 @@ const EnrichmentDescription: React.FC<ThreatSummaryDescription> = ({
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
<EuiFlexItem>
|
<EuiFlexItem>
|
||||||
{value && !isReadOnly && (
|
{value && !isReadOnly && (
|
||||||
<ActionCell
|
<CellActions
|
||||||
data={data}
|
field={field}
|
||||||
contextId={scopeId}
|
triggerId={CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER}
|
||||||
eventId={key}
|
mode={CellActionsMode.INLINE}
|
||||||
fieldFromBrowserField={browserField}
|
metadata={metadata}
|
||||||
scopeId={scopeId}
|
visibleCellActions={3}
|
||||||
values={[value]}
|
|
||||||
applyWidthAndPadding={false}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
|
|
|
@ -24,7 +24,6 @@ jest.mock('../../../lib/kibana', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../table/action_cell', () => ({ ActionCell: () => <></> }));
|
|
||||||
jest.mock('../table/field_name_cell');
|
jest.mock('../table/field_name_cell');
|
||||||
|
|
||||||
const RISK_SCORE_DATA_ROWS = 2;
|
const RISK_SCORE_DATA_ROWS = 2;
|
||||||
|
|
|
@ -14,7 +14,6 @@ import { EventFieldsBrowser } from './event_fields_browser';
|
||||||
import { mockBrowserFields } from '../../containers/source/mock';
|
import { mockBrowserFields } from '../../containers/source/mock';
|
||||||
import { useMountAppended } from '../../utils/use_mount_appended';
|
import { useMountAppended } from '../../utils/use_mount_appended';
|
||||||
import { TimelineTabs } from '../../../../common/types/timeline';
|
import { TimelineTabs } from '../../../../common/types/timeline';
|
||||||
import { get } from 'lodash/fp';
|
|
||||||
|
|
||||||
jest.mock('../../lib/kibana');
|
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');
|
jest.mock('../link_to');
|
||||||
|
|
||||||
const mockDispatch = jest.fn();
|
const mockDispatch = jest.fn();
|
||||||
|
@ -84,7 +95,7 @@ describe('EventFieldsBrowser', () => {
|
||||||
describe('Hover Actions', () => {
|
describe('Hover Actions', () => {
|
||||||
const eventId = 'pEMaMmkBUV60JmNWmWVi';
|
const eventId = 'pEMaMmkBUV60JmNWmWVi';
|
||||||
|
|
||||||
test('it renders a filter for (+) button', () => {
|
test('it renders inline actions', () => {
|
||||||
const wrapper = mount(
|
const wrapper = mount(
|
||||||
<TestProviders>
|
<TestProviders>
|
||||||
<EventFieldsBrowser
|
<EventFieldsBrowser
|
||||||
|
@ -96,113 +107,7 @@ describe('EventFieldsBrowser', () => {
|
||||||
/>
|
/>
|
||||||
</TestProviders>
|
</TestProviders>
|
||||||
);
|
);
|
||||||
|
expect(wrapper.find('[data-test-subj="inlineActions"]').exists()).toBeTruthy();
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
import { getOr, noop, sortBy } from 'lodash/fp';
|
import { getOr, noop, sortBy } from 'lodash/fp';
|
||||||
import { EuiInMemoryTable } from '@elastic/eui';
|
import { EuiInMemoryTable } from '@elastic/eui';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import { rgba } from 'polished';
|
import { rgba } from 'polished';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import {
|
import {
|
||||||
|
@ -19,7 +18,7 @@ import {
|
||||||
onKeyDownFocusHandler,
|
onKeyDownFocusHandler,
|
||||||
} from '@kbn/timelines-plugin/public';
|
} from '@kbn/timelines-plugin/public';
|
||||||
|
|
||||||
import { getScopedActions, isInTableScope, isTimelineScope } from '../../../helpers';
|
import { isInTableScope, isTimelineScope } from '../../../helpers';
|
||||||
import { tableDefaults } from '../../store/data_table/defaults';
|
import { tableDefaults } from '../../store/data_table/defaults';
|
||||||
import { dataTableSelectors } from '../../store/data_table';
|
import { dataTableSelectors } from '../../store/data_table';
|
||||||
import { ADD_TIMELINE_BUTTON_CLASS_NAME } from '../../../timelines/components/flyout/add_timeline_button';
|
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 { getColumns } from './columns';
|
||||||
import { EVENT_FIELDS_TABLE_CLASS_NAME, onEventDetailsTabKeyPressed, search } from './helpers';
|
import { EVENT_FIELDS_TABLE_CLASS_NAME, onEventDetailsTabKeyPressed, search } from './helpers';
|
||||||
import { useDeepEqualSelector } from '../../hooks/use_selector';
|
import { useDeepEqualSelector } from '../../hooks/use_selector';
|
||||||
import type { ColumnHeaderOptions, TimelineTabs } from '../../../../common/types/timeline';
|
import type { TimelineTabs } from '../../../../common/types/timeline';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
browserFields: BrowserFields;
|
browserFields: BrowserFields;
|
||||||
|
@ -92,31 +91,23 @@ const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)`
|
||||||
font: ${({ theme }) => theme.eui.euiFont};
|
font: ${({ theme }) => theme.eui.euiFont};
|
||||||
} */
|
} */
|
||||||
|
|
||||||
|
.inlineActions {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.eventFieldsTable__tableRow {
|
.eventFieldsTable__tableRow {
|
||||||
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
|
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
|
||||||
font-family: ${({ theme }) => theme.eui.euiCodeFontFamily};
|
font-family: ${({ theme }) => theme.eui.euiCodeFontFamily};
|
||||||
|
|
||||||
.hoverActions-active {
|
.inlineActions-popoverOpen {
|
||||||
.timelines__hoverActionButton,
|
opacity: 1;
|
||||||
.securitySolution__hoverActionButton {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.timelines__hoverActionButton,
|
.inlineActions {
|
||||||
.securitySolution__hoverActionButton {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.timelines__hoverActionButton,
|
|
||||||
.securitySolution__hoverActionButton {
|
|
||||||
// TODO: Using this logic from discover
|
|
||||||
/* @include euiBreakpoint('m', 'l', 'xl') {
|
|
||||||
opacity: 0;
|
|
||||||
} */
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventFieldsTable__actionCell,
|
.eventFieldsTable__actionCell,
|
||||||
|
@ -171,7 +162,6 @@ const useFieldBrowserPagination = () => {
|
||||||
export const EventFieldsBrowser = React.memo<Props>(
|
export const EventFieldsBrowser = React.memo<Props>(
|
||||||
({ browserFields, data, eventId, isDraggable, timelineTabType, scopeId, isReadOnly }) => {
|
({ browserFields, data, eventId, isDraggable, timelineTabType, scopeId, isReadOnly }) => {
|
||||||
const containerElement = useRef<HTMLDivElement | null>(null);
|
const containerElement = useRef<HTMLDivElement | null>(null);
|
||||||
const dispatch = useDispatch();
|
|
||||||
const getScope = useMemo(() => {
|
const getScope = useMemo(() => {
|
||||||
if (isTimelineScope(scopeId)) {
|
if (isTimelineScope(scopeId)) {
|
||||||
return timelineSelectors.getTimelineByIdSelector();
|
return timelineSelectors.getTimelineByIdSelector();
|
||||||
|
@ -209,31 +199,6 @@ export const EventFieldsBrowser = React.memo<Props>(
|
||||||
},
|
},
|
||||||
[data, columnHeaders]
|
[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 onSetRowProps = useCallback(({ ariaRowindex, field }: TimelineEventsDetailsItem) => {
|
||||||
const rowIndex = ariaRowindex != null ? { 'data-rowindex': ariaRowindex } : {};
|
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(
|
const columns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getColumns({
|
getColumns({
|
||||||
browserFields,
|
browserFields,
|
||||||
columnHeaders,
|
|
||||||
eventId,
|
eventId,
|
||||||
onUpdateColumns,
|
|
||||||
contextId: `event-fields-browser-for-${scopeId}-${timelineTabType}`,
|
contextId: `event-fields-browser-for-${scopeId}-${timelineTabType}`,
|
||||||
scopeId,
|
scopeId,
|
||||||
toggleColumn,
|
|
||||||
getLinkValue,
|
getLinkValue,
|
||||||
isDraggable,
|
isDraggable,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
}),
|
}),
|
||||||
[
|
[browserFields, eventId, scopeId, timelineTabType, getLinkValue, isDraggable, isReadOnly]
|
||||||
browserFields,
|
|
||||||
columnHeaders,
|
|
||||||
eventId,
|
|
||||||
onUpdateColumns,
|
|
||||||
scopeId,
|
|
||||||
timelineTabType,
|
|
||||||
toggleColumn,
|
|
||||||
getLinkValue,
|
|
||||||
isDraggable,
|
|
||||||
isReadOnly,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const focusSearchInput = useCallback(() => {
|
const focusSearchInput = useCallback(() => {
|
||||||
|
|
|
@ -2,45 +2,11 @@
|
||||||
|
|
||||||
exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
|
exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
|
||||||
<DocumentFragment>
|
<DocumentFragment>
|
||||||
.c6 {
|
.c3 {
|
||||||
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 {
|
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c5 {
|
.c4 {
|
||||||
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;
|
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,17 +16,27 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
|
||||||
height: 78px;
|
height: 78px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c1 .hoverActions-active .timelines__hoverActionButton,
|
.c1:hover .inlineActions {
|
||||||
.c1 .hoverActions-active .securitySolution__hoverActionButton {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
width: auto;
|
||||||
|
-webkit-transform: translate(0);
|
||||||
|
-ms-transform: translate(0);
|
||||||
|
transform: translate(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.c1:hover .timelines__hoverActionButton,
|
.c1 .inlineActions {
|
||||||
.c1:hover .securitySolution__hoverActionButton {
|
opacity: 0;
|
||||||
opacity: 1;
|
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;
|
width: auto;
|
||||||
-webkit-transform: translate(0);
|
-webkit-transform: translate(0);
|
||||||
-ms-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"
|
aria-label="Click to change alert status"
|
||||||
class="euiBadge c3 emotion-euiBadge-clickable"
|
class="euiBadge c3 emotion-euiBadge-clickable"
|
||||||
style="background-color: rgb(121, 170, 217); color: rgb(0, 0, 0);"
|
style="background-color: rgb(121, 170, 217); color: rgb(0, 0, 0);"
|
||||||
title="open"
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="euiBadge__content emotion-euiBadge__content"
|
class="euiBadge__content emotion-euiBadge__content"
|
||||||
|
@ -137,45 +112,23 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="c4 c5"
|
class="c4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-eui="EuiFocusTrap"
|
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-center-row"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<div
|
||||||
data-test-subj="hover-actions-filter-for"
|
class="euiFlexGroup inlineActions emotion-euiFlexGroup-none-flexStart-flexStart-row"
|
||||||
>
|
data-test-subj="inlineActions"
|
||||||
<span
|
/>
|
||||||
data-test-subj="test-filter-for"
|
<div />
|
||||||
>
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -206,45 +159,23 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
|
||||||
47
|
47
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="c4 c5"
|
class="c4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-eui="EuiFocusTrap"
|
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-center-row"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<div
|
||||||
data-test-subj="hover-actions-filter-for"
|
class="euiFlexGroup inlineActions emotion-euiFlexGroup-none-flexStart-flexStart-row"
|
||||||
>
|
data-test-subj="inlineActions"
|
||||||
<span
|
/>
|
||||||
data-test-subj="test-filter-for"
|
<div />
|
||||||
>
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -287,45 +218,23 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="c4 c5"
|
class="c4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-eui="EuiFocusTrap"
|
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-center-row"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<div
|
||||||
data-test-subj="hover-actions-filter-for"
|
class="euiFlexGroup inlineActions emotion-euiFlexGroup-none-flexStart-flexStart-row"
|
||||||
>
|
data-test-subj="inlineActions"
|
||||||
<span
|
/>
|
||||||
data-test-subj="test-filter-for"
|
<div />
|
||||||
>
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render } from '@testing-library/react';
|
import { act, render } from '@testing-library/react';
|
||||||
import { Overview } from '.';
|
import { Overview } from '.';
|
||||||
import { TestProviders } from '../../../mock';
|
import { TestProviders } from '../../../mock';
|
||||||
|
|
||||||
|
@ -23,55 +23,63 @@ jest.mock(
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('Event Details Overview Cards', () => {
|
describe('Event Details Overview Cards', () => {
|
||||||
it('renders all cards', () => {
|
it('renders all cards', async () => {
|
||||||
const { getByText } = render(
|
await act(async () => {
|
||||||
<TestProviders>
|
const { getByText } = render(
|
||||||
<Overview {...props} />
|
<TestProviders>
|
||||||
</TestProviders>
|
<Overview {...props} />
|
||||||
);
|
</TestProviders>
|
||||||
|
);
|
||||||
|
|
||||||
getByText('Status');
|
getByText('Status');
|
||||||
getByText('Severity');
|
getByText('Severity');
|
||||||
getByText('Risk Score');
|
getByText('Risk Score');
|
||||||
getByText('Rule');
|
getByText('Rule');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders only readOnly cards', () => {
|
it('renders only readOnly cards', async () => {
|
||||||
const { getByText, queryByText } = render(
|
await act(async () => {
|
||||||
<TestProviders>
|
const { getByText, queryByText } = render(
|
||||||
<Overview {...propsWithReadOnly} />
|
<TestProviders>
|
||||||
</TestProviders>
|
<Overview {...propsWithReadOnly} />
|
||||||
);
|
</TestProviders>
|
||||||
|
);
|
||||||
|
|
||||||
getByText('Severity');
|
getByText('Severity');
|
||||||
getByText('Risk Score');
|
getByText('Risk Score');
|
||||||
|
|
||||||
expect(queryByText('Status')).not.toBeInTheDocument();
|
expect(queryByText('Status')).not.toBeInTheDocument();
|
||||||
expect(queryByText('Rule')).not.toBeInTheDocument();
|
expect(queryByText('Rule')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders all cards it has data for', () => {
|
it('renders all cards it has data for', async () => {
|
||||||
const { getByText, queryByText } = render(
|
await act(async () => {
|
||||||
<TestProviders>
|
const { getByText, queryByText } = render(
|
||||||
<Overview {...propsWithoutSeverity} />
|
<TestProviders>
|
||||||
</TestProviders>
|
<Overview {...propsWithoutSeverity} />
|
||||||
);
|
</TestProviders>
|
||||||
|
);
|
||||||
|
|
||||||
getByText('Status');
|
getByText('Status');
|
||||||
getByText('Risk Score');
|
getByText('Risk Score');
|
||||||
getByText('Rule');
|
getByText('Rule');
|
||||||
|
|
||||||
expect(queryByText('Severity')).not.toBeInTheDocument();
|
expect(queryByText('Severity')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders rows and spacers correctly', () => {
|
it('renders rows and spacers correctly', async () => {
|
||||||
const { asFragment } = render(
|
await act(async () => {
|
||||||
<TestProviders>
|
const { asFragment } = render(
|
||||||
<Overview {...propsWithoutSeverity} />
|
<TestProviders>
|
||||||
</TestProviders>
|
<Overview {...propsWithoutSeverity} />
|
||||||
);
|
</TestProviders>
|
||||||
|
);
|
||||||
|
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
|
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 { chunk, find } from 'lodash/fp';
|
||||||
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
|
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
|
// Add a spacer between rows but not after the last row
|
||||||
const addSpacer = index < length - 1;
|
const addSpacer = index < length - 1;
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={index}>
|
||||||
<NotGrowingFlexGroup gutterSize="s">{elements}</NotGrowingFlexGroup>
|
<NotGrowingFlexGroup gutterSize="s">{elements}</NotGrowingFlexGroup>
|
||||||
{addSpacer && <EuiSpacer size="s" />}
|
{addSpacer && <EuiSpacer size="s" />}
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render } from '@testing-library/react';
|
import { act, render } from '@testing-library/react';
|
||||||
import { OverviewCardWithActions } from './overview_card';
|
import { OverviewCardWithActions } from './overview_card';
|
||||||
import {
|
import {
|
||||||
createSecuritySolutionStorageMock,
|
createSecuritySolutionStorageMock,
|
||||||
|
@ -19,6 +19,7 @@ import { SeverityBadge } from '../../../../detections/components/rules/severity_
|
||||||
import type { State } from '../../../store';
|
import type { State } from '../../../store';
|
||||||
import { createStore } from '../../../store';
|
import { createStore } from '../../../store';
|
||||||
import { TimelineId } from '../../../../../common/types';
|
import { TimelineId } from '../../../../../common/types';
|
||||||
|
import { createAction } from '@kbn/ui-actions-plugin/public';
|
||||||
|
|
||||||
const state: State = {
|
const state: State = {
|
||||||
...mockGlobalState,
|
...mockGlobalState,
|
||||||
|
@ -74,23 +75,33 @@ const props = {
|
||||||
|
|
||||||
jest.mock('../../../lib/kibana');
|
jest.mock('../../../lib/kibana');
|
||||||
|
|
||||||
|
const mockAction = createAction({
|
||||||
|
id: 'test_action',
|
||||||
|
execute: async () => {},
|
||||||
|
getIconType: () => 'test-icon',
|
||||||
|
getDisplayName: () => 'test-actions',
|
||||||
|
});
|
||||||
|
|
||||||
|
// jest.useFakeTimers();
|
||||||
|
|
||||||
describe('OverviewCardWithActions', () => {
|
describe('OverviewCardWithActions', () => {
|
||||||
test('it renders correctly', () => {
|
test('it renders correctly', async () => {
|
||||||
const { getByText } = render(
|
await act(async () => {
|
||||||
<TestProviders store={store}>
|
const { getByText, findByTestId } = render(
|
||||||
<OverviewCardWithActions {...props}>
|
<TestProviders store={store} cellActions={[mockAction]}>
|
||||||
<SeverityBadge value="medium" />
|
<OverviewCardWithActions {...props}>
|
||||||
</OverviewCardWithActions>
|
<SeverityBadge value="medium" />
|
||||||
</TestProviders>
|
</OverviewCardWithActions>
|
||||||
);
|
</TestProviders>
|
||||||
|
);
|
||||||
|
// Headline
|
||||||
|
getByText('Severity');
|
||||||
|
|
||||||
// Headline
|
// Content
|
||||||
getByText('Severity');
|
getByText('Medium');
|
||||||
|
|
||||||
// Content
|
// Hover actions
|
||||||
getByText('Medium');
|
await findByTestId('actionItem-test_action');
|
||||||
|
});
|
||||||
// Hover actions
|
|
||||||
getByText('Add To Timeline');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,76 +6,52 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EuiFlexGroup, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
|
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 { 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 type { EnrichedFieldInfo } from '../types';
|
||||||
|
import { CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER } from '../../../../../common/constants';
|
||||||
|
|
||||||
const ActionWrapper = euiStyled.div`
|
const ActionWrapper = euiStyled.div`
|
||||||
width: 0;
|
|
||||||
transform: translate(6px);
|
|
||||||
transition: transform 50ms ease-in-out;
|
|
||||||
margin-left: ${({ theme }) => theme.eui.euiSizeS};
|
margin-left: ${({ theme }) => theme.eui.euiSizeS};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const OverviewPanel = euiStyled(EuiPanel)<{
|
const OverviewPanel = euiStyled(EuiPanel)`
|
||||||
$isPopoverVisible: boolean;
|
|
||||||
}>`
|
|
||||||
&&& {
|
&&& {
|
||||||
background-color: ${({ theme }) => theme.eui.euiColorLightestShade};
|
background-color: ${({ theme }) => theme.eui.euiColorLightestShade};
|
||||||
padding: ${({ theme }) => theme.eui.euiSizeS};
|
padding: ${({ theme }) => theme.eui.euiSizeS};
|
||||||
height: 78px;
|
height: 78px;
|
||||||
}
|
}
|
||||||
|
|
||||||
& {
|
&:hover {
|
||||||
.hoverActions-active {
|
.inlineActions {
|
||||||
.timelines__hoverActionButton,
|
opacity: 1;
|
||||||
.securitySolution__hoverActionButton {
|
width: auto;
|
||||||
opacity: 1;
|
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 {
|
interface OverviewCardProps {
|
||||||
isPopoverVisible?: boolean; // Prevent the hover actions from collapsing on each other when not directly hovered on
|
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OverviewCard: React.FC<OverviewCardProps> = ({
|
export const OverviewCard: React.FC<OverviewCardProps> = ({ title, children }) => (
|
||||||
title,
|
<OverviewPanel borderRadius="none" hasShadow={false} hasBorder={false} paddingSize="s">
|
||||||
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}
|
|
||||||
>
|
|
||||||
<EuiText size="s">{title}</EuiText>
|
<EuiText size="s">{title}</EuiText>
|
||||||
<EuiSpacer size="s" />
|
<EuiSpacer size="s" />
|
||||||
{children}
|
{children}
|
||||||
|
@ -106,25 +82,27 @@ export const OverviewCardWithActions: React.FC<OverviewCardWithActionsProps> = (
|
||||||
contextId,
|
contextId,
|
||||||
dataTestSubj,
|
dataTestSubj,
|
||||||
enrichedFieldInfo,
|
enrichedFieldInfo,
|
||||||
}) => {
|
}) => (
|
||||||
const [isPopoverVisisble, setIsPopoverVisible] = useState(false);
|
<OverviewCard title={title}>
|
||||||
|
<EuiFlexGroup alignItems="center" gutterSize="none">
|
||||||
|
<ClampedContent data-test-subj={dataTestSubj}>{children}</ClampedContent>
|
||||||
|
|
||||||
return (
|
<ActionWrapper>
|
||||||
<OverviewCard title={title} isPopoverVisible={isPopoverVisisble}>
|
<CellActions
|
||||||
<EuiFlexGroup alignItems="center" gutterSize="none">
|
field={{
|
||||||
<ClampedContent data-test-subj={dataTestSubj}>{children}</ClampedContent>
|
name: enrichedFieldInfo.data.field,
|
||||||
|
value: enrichedFieldInfo?.values ? enrichedFieldInfo?.values[0] : '',
|
||||||
<ActionWrapper>
|
type: enrichedFieldInfo.data.type,
|
||||||
<ActionCell
|
aggregatable: enrichedFieldInfo.fieldFromBrowserField?.aggregatable,
|
||||||
{...enrichedFieldInfo}
|
}}
|
||||||
contextId={contextId}
|
triggerId={CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER}
|
||||||
setIsPopoverVisible={setIsPopoverVisible}
|
mode={CellActionsMode.INLINE}
|
||||||
applyWidthAndPadding={false}
|
metadata={{ scopeId: contextId }}
|
||||||
/>
|
visibleCellActions={3}
|
||||||
</ActionWrapper>
|
/>
|
||||||
</EuiFlexGroup>
|
</ActionWrapper>
|
||||||
</OverviewCard>
|
</EuiFlexGroup>
|
||||||
);
|
</OverviewCard>
|
||||||
};
|
);
|
||||||
|
|
||||||
OverviewCardWithActions.displayName = 'OverviewCardWithActions';
|
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 type { DataViewField } from '@kbn/data-views-plugin/common';
|
||||||
import * as i18n from '../translations';
|
import * as i18n from '../translations';
|
||||||
import { getExampleText } from '../helpers';
|
import { getExampleText } from '../helpers';
|
||||||
import type { BrowserField } from '../../../containers/source';
|
|
||||||
import type { EventFieldsData } from '../types';
|
import type { EventFieldsData } from '../types';
|
||||||
import { getFieldTypeName } from './get_field_type_name';
|
import { getFieldTypeName } from './get_field_type_name';
|
||||||
|
|
||||||
export interface FieldNameCellProps {
|
export interface FieldNameCellProps {
|
||||||
data: EventFieldsData;
|
data: EventFieldsData;
|
||||||
field: string;
|
field: string;
|
||||||
fieldFromBrowserField: BrowserField;
|
|
||||||
fieldMapping?: DataViewField;
|
fieldMapping?: DataViewField;
|
||||||
scripted?: boolean;
|
scripted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,21 +10,17 @@ import styled from 'styled-components';
|
||||||
import { EuiInMemoryTable } from '@elastic/eui';
|
import { EuiInMemoryTable } from '@elastic/eui';
|
||||||
|
|
||||||
export const SummaryTable = styled(EuiInMemoryTable as unknown as AnyStyledComponent)`
|
export const SummaryTable = styled(EuiInMemoryTable as unknown as AnyStyledComponent)`
|
||||||
.timelines__hoverActionButton {
|
.inlineActions {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flyoutTableHoverActions {
|
.flyoutTableHoverActions {
|
||||||
.hoverActions-active {
|
.inlineActions-popoverOpen {
|
||||||
.timelines__hoverActionButton,
|
opacity: 1;
|
||||||
.securitySolution__hoverActionButton {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.timelines__hoverActionButton,
|
.inlineActions {
|
||||||
.securitySolution__hoverActionButton {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { act, render, screen } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { BrowserField } from '../../../containers/source';
|
import type { BrowserField } from '../../../containers/source';
|
||||||
|
@ -76,38 +76,43 @@ const enrichedAgentStatusData: AlertSummaryRow['description'] = {
|
||||||
|
|
||||||
describe('SummaryValueCell', () => {
|
describe('SummaryValueCell', () => {
|
||||||
test('it should render', async () => {
|
test('it should render', async () => {
|
||||||
render(
|
await act(async () => {
|
||||||
<TestProviders>
|
render(
|
||||||
<SummaryValueCell {...enrichedHostIpData} />
|
<TestProviders>
|
||||||
</TestProviders>
|
<SummaryValueCell {...enrichedHostIpData} />
|
||||||
);
|
</TestProviders>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
hostIpValues.forEach((ipValue) => expect(screen.getByText(ipValue)).toBeInTheDocument());
|
hostIpValues.forEach((ipValue) => expect(screen.getByText(ipValue)).toBeInTheDocument());
|
||||||
expect(screen.getAllByTestId('test-filter-for')).toHaveLength(1);
|
expect(screen.getByTestId('inlineActions')).toBeInTheDocument();
|
||||||
expect(screen.getAllByTestId('test-filter-out')).toHaveLength(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Without hover actions', () => {
|
describe('Without hover actions', () => {
|
||||||
test('When in the timeline flyout with timelineId active', async () => {
|
test('When in the timeline flyout with timelineId active', async () => {
|
||||||
render(
|
await act(async () => {
|
||||||
<TestProviders>
|
render(
|
||||||
<SummaryValueCell {...enrichedHostIpData} scopeId={TimelineId.active} />
|
<TestProviders>
|
||||||
</TestProviders>
|
<SummaryValueCell {...enrichedHostIpData} scopeId={TimelineId.active} />
|
||||||
);
|
</TestProviders>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
hostIpValues.forEach((ipValue) => expect(screen.getByText(ipValue)).toBeInTheDocument());
|
hostIpValues.forEach((ipValue) => expect(screen.getByText(ipValue)).toBeInTheDocument());
|
||||||
expect(screen.queryByTestId('test-filter-for')).toBeNull();
|
expect(screen.queryByTestId('inlineActions')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('test-filter-out')).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('When rendering the host status field', async () => {
|
test('When rendering the host status field', async () => {
|
||||||
render(
|
await act(async () => {
|
||||||
<TestProviders>
|
render(
|
||||||
<SummaryValueCell {...enrichedAgentStatusData} />
|
<TestProviders>
|
||||||
</TestProviders>
|
<SummaryValueCell {...enrichedAgentStatusData} />
|
||||||
);
|
</TestProviders>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId('event-field-agent.status')).toBeInTheDocument();
|
expect(screen.getByTestId('event-field-agent.status')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('test-filter-for')).toBeNull();
|
expect(screen.queryByTestId('inlineActions')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('test-filter-out')).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,11 +7,12 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { ActionCell } from './action_cell';
|
import { CellActions, CellActionsMode } from '@kbn/cell-actions';
|
||||||
import { FieldValueCell } from './field_value_cell';
|
import { FieldValueCell } from './field_value_cell';
|
||||||
import type { AlertSummaryRow } from '../helpers';
|
import type { AlertSummaryRow } from '../helpers';
|
||||||
import { hasHoverOrRowActions } from '../helpers';
|
import { hasHoverOrRowActions } from '../helpers';
|
||||||
import { TimelineId } from '../../../../../common/types';
|
import { TimelineId } from '../../../../../common/types';
|
||||||
|
import { CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER } from '../../../../../common/constants';
|
||||||
|
|
||||||
const style = { flexGrow: 0 };
|
const style = { flexGrow: 0 };
|
||||||
|
|
||||||
|
@ -40,16 +41,17 @@ export const SummaryValueCell: React.FC<AlertSummaryRow['description']> = ({
|
||||||
values={values}
|
values={values}
|
||||||
/>
|
/>
|
||||||
{scopeId !== TimelineId.active && !isReadOnly && hoverActionsEnabled && (
|
{scopeId !== TimelineId.active && !isReadOnly && hoverActionsEnabled && (
|
||||||
<ActionCell
|
<CellActions
|
||||||
contextId={scopeId}
|
field={{
|
||||||
data={data}
|
name: data.field,
|
||||||
eventId={eventId}
|
value: values && values.length > 0 ? values[0] : '',
|
||||||
fieldFromBrowserField={fieldFromBrowserField}
|
type: data.type,
|
||||||
linkValue={linkValue}
|
aggregatable: fieldFromBrowserField?.aggregatable,
|
||||||
scopeId={scopeId}
|
}}
|
||||||
values={values}
|
triggerId={CELL_ACTIONS_DETAILS_FLYOUT_TRIGGER}
|
||||||
applyWidthAndPadding={false}
|
mode={CellActionsMode.INLINE}
|
||||||
hideAddToTimeline={false}
|
visibleCellActions={3}
|
||||||
|
metadata={{ scopeId }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -4,6 +4,7 @@ exports[`entity_draggable renders correctly against snapshot 1`] = `
|
||||||
<CellActions
|
<CellActions
|
||||||
field={
|
field={
|
||||||
Object {
|
Object {
|
||||||
|
"aggregatable": true,
|
||||||
"name": "entity-name",
|
"name": "entity-name",
|
||||||
"type": "keyword",
|
"type": "keyword",
|
||||||
"value": "entity-value",
|
"value": "entity-value",
|
||||||
|
|
|
@ -21,6 +21,7 @@ export const EntityComponent: React.FC<Props> = ({ entityName, entityValue }) =>
|
||||||
name: entityName,
|
name: entityName,
|
||||||
value: entityValue,
|
value: entityValue,
|
||||||
type: 'keyword',
|
type: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
}}
|
}}
|
||||||
triggerId={CELL_ACTIONS_DEFAULT_TRIGGER}
|
triggerId={CELL_ACTIONS_DEFAULT_TRIGGER}
|
||||||
mode={CellActionsMode.HOVER}
|
mode={CellActionsMode.HOVER}
|
||||||
|
|
|
@ -4,6 +4,7 @@ exports[`draggable_score renders correctly against snapshot 1`] = `
|
||||||
<CellActions
|
<CellActions
|
||||||
field={
|
field={
|
||||||
Object {
|
Object {
|
||||||
|
"aggregatable": true,
|
||||||
"name": "process.name",
|
"name": "process.name",
|
||||||
"type": "keyword",
|
"type": "keyword",
|
||||||
"value": "du",
|
"value": "du",
|
||||||
|
@ -21,6 +22,7 @@ exports[`draggable_score renders correctly against snapshot when the index is no
|
||||||
<CellActions
|
<CellActions
|
||||||
field={
|
field={
|
||||||
Object {
|
Object {
|
||||||
|
"aggregatable": true,
|
||||||
"name": "process.name",
|
"name": "process.name",
|
||||||
"type": "keyword",
|
"type": "keyword",
|
||||||
"value": "du",
|
"value": "du",
|
||||||
|
|
|
@ -28,6 +28,7 @@ export const ScoreComponent = ({
|
||||||
name: score.entityName,
|
name: score.entityName,
|
||||||
value: score.entityValue,
|
value: score.entityValue,
|
||||||
type: 'keyword',
|
type: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
}}
|
}}
|
||||||
triggerId={CELL_ACTIONS_DEFAULT_TRIGGER}
|
triggerId={CELL_ACTIONS_DEFAULT_TRIGGER}
|
||||||
visibleCellActions={5}
|
visibleCellActions={5}
|
||||||
|
|
|
@ -37,6 +37,7 @@ export const getAnomaliesHostTableColumns = (
|
||||||
idPrefix: `anomalies-host-table-hostName-${createCompoundAnomalyKey(
|
idPrefix: `anomalies-host-table-hostName-${createCompoundAnomalyKey(
|
||||||
anomaliesByHost.anomaly
|
anomaliesByHost.anomaly
|
||||||
)}-hostName`,
|
)}-hostName`,
|
||||||
|
aggregatable: true,
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
render: (item) => <HostDetailsLink hostName={item} />,
|
render: (item) => <HostDetailsLink hostName={item} />,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -41,6 +41,7 @@ export const getAnomaliesNetworkTableColumns = (
|
||||||
idPrefix: `anomalies-network-table-ip-${createCompoundAnomalyKey(
|
idPrefix: `anomalies-network-table-ip-${createCompoundAnomalyKey(
|
||||||
anomaliesByNetwork.anomaly
|
anomaliesByNetwork.anomaly
|
||||||
)}`,
|
)}`,
|
||||||
|
aggregatable: true,
|
||||||
fieldType: 'ip',
|
fieldType: 'ip',
|
||||||
render: (item) => <NetworkDetailsLink ip={item} flowTarget={flowTarget} />,
|
render: (item) => <NetworkDetailsLink ip={item} flowTarget={flowTarget} />,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -38,6 +38,7 @@ export const getAnomaliesUserTableColumns = (
|
||||||
idPrefix: `anomalies-user-table-userName-${createCompoundAnomalyKey(
|
idPrefix: `anomalies-user-table-userName-${createCompoundAnomalyKey(
|
||||||
anomaliesByUser.anomaly
|
anomaliesByUser.anomaly
|
||||||
)}-userName`,
|
)}-userName`,
|
||||||
|
aggregatable: true,
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
render: (item) => <UserDetailsLink userName={item} />,
|
render: (item) => <UserDetailsLink userName={item} />,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -31,6 +31,7 @@ describe('Table Helpers', () => {
|
||||||
values: undefined,
|
values: undefined,
|
||||||
fieldName: 'attrName',
|
fieldName: 'attrName',
|
||||||
idPrefix: 'idPrefix',
|
idPrefix: 'idPrefix',
|
||||||
|
aggregatable: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
|
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
|
||||||
|
@ -43,6 +44,7 @@ describe('Table Helpers', () => {
|
||||||
values: [''],
|
values: [''],
|
||||||
fieldName: 'attrName',
|
fieldName: 'attrName',
|
||||||
idPrefix: 'idPrefix',
|
idPrefix: 'idPrefix',
|
||||||
|
aggregatable: false,
|
||||||
});
|
});
|
||||||
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
|
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
|
||||||
|
|
||||||
|
@ -54,6 +56,7 @@ describe('Table Helpers', () => {
|
||||||
values: null,
|
values: null,
|
||||||
fieldName: 'attrName',
|
fieldName: 'attrName',
|
||||||
idPrefix: 'idPrefix',
|
idPrefix: 'idPrefix',
|
||||||
|
aggregatable: false,
|
||||||
displayCount: 0,
|
displayCount: 0,
|
||||||
});
|
});
|
||||||
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
|
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
|
||||||
|
@ -66,6 +69,7 @@ describe('Table Helpers', () => {
|
||||||
values: ['item1'],
|
values: ['item1'],
|
||||||
fieldName: 'attrName',
|
fieldName: 'attrName',
|
||||||
idPrefix: 'idPrefix',
|
idPrefix: 'idPrefix',
|
||||||
|
aggregatable: false,
|
||||||
render: renderer,
|
render: renderer,
|
||||||
});
|
});
|
||||||
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
|
const { container } = render(<TestProviders>{rowItem}</TestProviders>);
|
||||||
|
@ -78,6 +82,7 @@ describe('Table Helpers', () => {
|
||||||
values: [],
|
values: [],
|
||||||
fieldName: 'attrName',
|
fieldName: 'attrName',
|
||||||
idPrefix: 'idPrefix',
|
idPrefix: 'idPrefix',
|
||||||
|
aggregatable: false,
|
||||||
});
|
});
|
||||||
const { container } = render(<TestProviders>{rowItems}</TestProviders>);
|
const { container } = render(<TestProviders>{rowItems}</TestProviders>);
|
||||||
expect(container.textContent).toBe(getEmptyValue());
|
expect(container.textContent).toBe(getEmptyValue());
|
||||||
|
@ -88,6 +93,7 @@ describe('Table Helpers', () => {
|
||||||
values: items,
|
values: items,
|
||||||
fieldName: 'attrName',
|
fieldName: 'attrName',
|
||||||
idPrefix: 'idPrefix',
|
idPrefix: 'idPrefix',
|
||||||
|
aggregatable: false,
|
||||||
displayCount: 2,
|
displayCount: 2,
|
||||||
});
|
});
|
||||||
const { queryAllByTestId, queryByTestId } = render(<TestProviders>{rowItems}</TestProviders>);
|
const { queryAllByTestId, queryByTestId } = render(<TestProviders>{rowItems}</TestProviders>);
|
||||||
|
|
|
@ -27,6 +27,7 @@ interface GetRowItemsWithActionsParams {
|
||||||
render?: (item: string) => JSX.Element;
|
render?: (item: string) => JSX.Element;
|
||||||
displayCount?: number;
|
displayCount?: number;
|
||||||
maxOverflow?: number;
|
maxOverflow?: number;
|
||||||
|
aggregatable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getRowItemsWithActions = ({
|
export const getRowItemsWithActions = ({
|
||||||
|
@ -37,6 +38,7 @@ export const getRowItemsWithActions = ({
|
||||||
render,
|
render,
|
||||||
displayCount = 5,
|
displayCount = 5,
|
||||||
maxOverflow = 5,
|
maxOverflow = 5,
|
||||||
|
aggregatable,
|
||||||
}: GetRowItemsWithActionsParams): JSX.Element => {
|
}: GetRowItemsWithActionsParams): JSX.Element => {
|
||||||
if (values != null && values.length > 0) {
|
if (values != null && values.length > 0) {
|
||||||
const visibleItems = values.slice(0, displayCount).map((value, index) => {
|
const visibleItems = values.slice(0, displayCount).map((value, index) => {
|
||||||
|
@ -52,6 +54,7 @@ export const getRowItemsWithActions = ({
|
||||||
name: fieldName,
|
name: fieldName,
|
||||||
value,
|
value,
|
||||||
type: fieldType,
|
type: fieldType,
|
||||||
|
aggregatable,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<>{render ? render(value) : defaultToEmptyTag(value)}</>
|
<>{render ? render(value) : defaultToEmptyTag(value)}</>
|
||||||
|
@ -69,6 +72,7 @@ export const getRowItemsWithActions = ({
|
||||||
idPrefix={idPrefix}
|
idPrefix={idPrefix}
|
||||||
maxOverflowItems={maxOverflow}
|
maxOverflowItems={maxOverflow}
|
||||||
overflowIndexStart={displayCount}
|
overflowIndexStart={displayCount}
|
||||||
|
isAggregatable={aggregatable}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
@ -82,6 +86,7 @@ export const getRowItemsWithActions = ({
|
||||||
interface RowItemOverflowProps {
|
interface RowItemOverflowProps {
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
fieldType: string;
|
fieldType: string;
|
||||||
|
isAggregatable?: boolean;
|
||||||
values: string[];
|
values: string[];
|
||||||
idPrefix: string;
|
idPrefix: string;
|
||||||
maxOverflowItems: number;
|
maxOverflowItems: number;
|
||||||
|
@ -92,6 +97,7 @@ export const RowItemOverflowComponent: React.FC<RowItemOverflowProps> = ({
|
||||||
fieldName,
|
fieldName,
|
||||||
values,
|
values,
|
||||||
fieldType,
|
fieldType,
|
||||||
|
isAggregatable,
|
||||||
idPrefix,
|
idPrefix,
|
||||||
maxOverflowItems = 5,
|
maxOverflowItems = 5,
|
||||||
overflowIndexStart = 5,
|
overflowIndexStart = 5,
|
||||||
|
@ -104,6 +110,7 @@ export const RowItemOverflowComponent: React.FC<RowItemOverflowProps> = ({
|
||||||
<MoreContainer
|
<MoreContainer
|
||||||
fieldName={fieldName}
|
fieldName={fieldName}
|
||||||
idPrefix={idPrefix}
|
idPrefix={idPrefix}
|
||||||
|
isAggregatable={isAggregatable}
|
||||||
fieldType={fieldType}
|
fieldType={fieldType}
|
||||||
values={values}
|
values={values}
|
||||||
overflowIndexStart={overflowIndexStart}
|
overflowIndexStart={overflowIndexStart}
|
||||||
|
|
|
@ -103,6 +103,7 @@ const LAST_SUCCESSFUL_SOURCE_COLUMN: Columns<AuthenticationsEdges, Authenticatio
|
||||||
values: node.lastSuccess?.source?.ip || null,
|
values: node.lastSuccess?.source?.ip || null,
|
||||||
fieldName: 'source.ip',
|
fieldName: 'source.ip',
|
||||||
fieldType: 'ip',
|
fieldType: 'ip',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `authentications-table-${node._id}-lastSuccessSource`,
|
idPrefix: `authentications-table-${node._id}-lastSuccessSource`,
|
||||||
render: (item) => <NetworkDetailsLink ip={item} />,
|
render: (item) => <NetworkDetailsLink ip={item} />,
|
||||||
}),
|
}),
|
||||||
|
@ -116,6 +117,7 @@ const LAST_SUCCESSFUL_DESTINATION_COLUMN: Columns<AuthenticationsEdges, Authenti
|
||||||
values: node.lastSuccess?.host?.name ?? null,
|
values: node.lastSuccess?.host?.name ?? null,
|
||||||
fieldName: 'host.name',
|
fieldName: 'host.name',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `authentications-table-${node._id}-lastSuccessfulDestination`,
|
idPrefix: `authentications-table-${node._id}-lastSuccessfulDestination`,
|
||||||
render: (item) => <HostDetailsLink hostName={item} />,
|
render: (item) => <HostDetailsLink hostName={item} />,
|
||||||
}),
|
}),
|
||||||
|
@ -140,6 +142,7 @@ const LAST_FAILED_SOURCE_COLUMN: Columns<AuthenticationsEdges, AuthenticationsEd
|
||||||
values: node.lastFailure?.source?.ip || null,
|
values: node.lastFailure?.source?.ip || null,
|
||||||
fieldName: 'source.ip',
|
fieldName: 'source.ip',
|
||||||
fieldType: 'ip',
|
fieldType: 'ip',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `authentications-table-${node._id}-lastFailureSource`,
|
idPrefix: `authentications-table-${node._id}-lastFailureSource`,
|
||||||
render: (item) => <NetworkDetailsLink ip={item} />,
|
render: (item) => <NetworkDetailsLink ip={item} />,
|
||||||
}),
|
}),
|
||||||
|
@ -153,6 +156,7 @@ const LAST_FAILED_DESTINATION_COLUMN: Columns<AuthenticationsEdges, Authenticati
|
||||||
values: node.lastFailure?.host?.name || null,
|
values: node.lastFailure?.host?.name || null,
|
||||||
fieldName: 'host.name',
|
fieldName: 'host.name',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `authentications-table-${node._id}-lastFailureDestination`,
|
idPrefix: `authentications-table-${node._id}-lastFailureDestination`,
|
||||||
render: (item) => <HostDetailsLink hostName={item} />,
|
render: (item) => <HostDetailsLink hostName={item} />,
|
||||||
}),
|
}),
|
||||||
|
@ -168,6 +172,7 @@ const USER_COLUMN: Columns<AuthenticationsEdges, AuthenticationsEdges> = {
|
||||||
fieldName: 'user.name',
|
fieldName: 'user.name',
|
||||||
idPrefix: `authentications-table-${node._id}-userName`,
|
idPrefix: `authentications-table-${node._id}-userName`,
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
render: (item) => <UserDetailsLink userName={item} />,
|
render: (item) => <UserDetailsLink userName={item} />,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
@ -182,6 +187,7 @@ const HOST_COLUMN: Columns<AuthenticationsEdges, AuthenticationsEdges> = {
|
||||||
fieldName: 'host.name',
|
fieldName: 'host.name',
|
||||||
idPrefix: `authentications-table-${node._id}-hostName`,
|
idPrefix: `authentications-table-${node._id}-hostName`,
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
render: (item) => <HostDetailsLink hostName={item} />,
|
render: (item) => <HostDetailsLink hostName={item} />,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -152,6 +152,7 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [
|
||||||
values: node.process.name,
|
values: node.process.name,
|
||||||
fieldName: 'process.name',
|
fieldName: 'process.name',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `uncommon-process-table-${node._id}-processName`,
|
idPrefix: `uncommon-process-table-${node._id}-processName`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
@ -181,6 +182,7 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [
|
||||||
values: getHostNames(node.hosts),
|
values: getHostNames(node.hosts),
|
||||||
fieldName: 'host.name',
|
fieldName: 'host.name',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `uncommon-process-table-${node._id}-processHost`,
|
idPrefix: `uncommon-process-table-${node._id}-processHost`,
|
||||||
render: (item) => <HostDetailsLink hostName={item} />,
|
render: (item) => <HostDetailsLink hostName={item} />,
|
||||||
}),
|
}),
|
||||||
|
@ -195,6 +197,7 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [
|
||||||
values: node.process != null ? node.process.args : null,
|
values: node.process != null ? node.process.args : null,
|
||||||
fieldName: 'process.args',
|
fieldName: 'process.args',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `uncommon-process-table-${node._id}-processArgs`,
|
idPrefix: `uncommon-process-table-${node._id}-processArgs`,
|
||||||
render: (item) => <HostDetailsLink hostName={item} />,
|
render: (item) => <HostDetailsLink hostName={item} />,
|
||||||
displayCount: 1,
|
displayCount: 1,
|
||||||
|
@ -209,6 +212,7 @@ const getUncommonColumns = (): UncommonProcessTableColumns => [
|
||||||
values: node.user != null ? node.user.name : null,
|
values: node.user != null ? node.user.name : null,
|
||||||
fieldName: 'user.name',
|
fieldName: 'user.name',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `uncommon-process-table-${node._id}-processUser`,
|
idPrefix: `uncommon-process-table-${node._id}-processUser`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
|
@ -39,6 +39,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
|
||||||
values: methods,
|
values: methods,
|
||||||
idPrefix: escapeDataProviderId(`${tableId}-table-methods-${path}`),
|
idPrefix: escapeDataProviderId(`${tableId}-table-methods-${path}`),
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
displayCount: 3,
|
displayCount: 3,
|
||||||
})
|
})
|
||||||
: getEmptyTagValue();
|
: getEmptyTagValue();
|
||||||
|
@ -53,6 +54,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
|
||||||
fieldName: 'url.domain',
|
fieldName: 'url.domain',
|
||||||
idPrefix: escapeDataProviderId(`${tableId}-table-domains-${path}`),
|
idPrefix: escapeDataProviderId(`${tableId}-table-domains-${path}`),
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
})
|
})
|
||||||
: getEmptyTagValue(),
|
: getEmptyTagValue(),
|
||||||
},
|
},
|
||||||
|
@ -66,6 +68,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
|
||||||
fieldName: 'url.path',
|
fieldName: 'url.path',
|
||||||
idPrefix: escapeDataProviderId(`${tableId}-table-path-${path}`),
|
idPrefix: escapeDataProviderId(`${tableId}-table-path-${path}`),
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
})
|
})
|
||||||
: getEmptyTagValue(),
|
: getEmptyTagValue(),
|
||||||
},
|
},
|
||||||
|
@ -78,6 +81,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
|
||||||
fieldName: 'http.response.status_code',
|
fieldName: 'http.response.status_code',
|
||||||
idPrefix: escapeDataProviderId(`${tableId}-table-statuses-${path}`),
|
idPrefix: escapeDataProviderId(`${tableId}-table-statuses-${path}`),
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
displayCount: 3,
|
displayCount: 3,
|
||||||
})
|
})
|
||||||
: getEmptyTagValue(),
|
: getEmptyTagValue(),
|
||||||
|
@ -91,6 +95,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
|
||||||
fieldName: 'host.name',
|
fieldName: 'host.name',
|
||||||
idPrefix: escapeDataProviderId(`${tableId}-table-lastHost-${path}`),
|
idPrefix: escapeDataProviderId(`${tableId}-table-lastHost-${path}`),
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
})
|
})
|
||||||
: getEmptyTagValue(),
|
: getEmptyTagValue(),
|
||||||
},
|
},
|
||||||
|
@ -103,6 +108,7 @@ export const getNetworkHttpColumns = (tableId: string): NetworkHttpColumns => [
|
||||||
fieldName: 'source.ip',
|
fieldName: 'source.ip',
|
||||||
idPrefix: escapeDataProviderId(`${tableId}-table-lastSourceIp-${path}`),
|
idPrefix: escapeDataProviderId(`${tableId}-table-lastSourceIp-${path}`),
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
render: () => <NetworkDetailsLink ip={lastSourceIp} />,
|
render: () => <NetworkDetailsLink ip={lastSourceIp} />,
|
||||||
})
|
})
|
||||||
: getEmptyTagValue(),
|
: getEmptyTagValue(),
|
||||||
|
|
|
@ -117,6 +117,7 @@ export const getNetworkTopNFlowColumns = (
|
||||||
values: domains,
|
values: domains,
|
||||||
fieldName: domainAttr,
|
fieldName: domainAttr,
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: id,
|
idPrefix: id,
|
||||||
displayCount: 1,
|
displayCount: 1,
|
||||||
});
|
});
|
||||||
|
@ -140,6 +141,7 @@ export const getNetworkTopNFlowColumns = (
|
||||||
values: [as.name],
|
values: [as.name],
|
||||||
fieldName: `${flowTarget}.as.organization.name`,
|
fieldName: `${flowTarget}.as.organization.name`,
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `${id}-name`,
|
idPrefix: `${id}-name`,
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
@ -151,6 +153,7 @@ export const getNetworkTopNFlowColumns = (
|
||||||
fieldName: `${flowTarget}.as.number`,
|
fieldName: `${flowTarget}.as.number`,
|
||||||
idPrefix: `${id}-number`,
|
idPrefix: `${id}-number`,
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -36,6 +36,7 @@ export const getTlsColumns = (tableId: string): TlsColumns => [
|
||||||
values: issuers,
|
values: issuers,
|
||||||
fieldName: 'tls.server.issuer',
|
fieldName: 'tls.server.issuer',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `${tableId}-${_id}-table-issuers`,
|
idPrefix: `${tableId}-${_id}-table-issuers`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
@ -50,6 +51,7 @@ export const getTlsColumns = (tableId: string): TlsColumns => [
|
||||||
values: subjects,
|
values: subjects,
|
||||||
fieldName: 'tls.server.subject',
|
fieldName: 'tls.server.subject',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `${tableId}-${_id}-table-subjects`,
|
idPrefix: `${tableId}-${_id}-table-subjects`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
@ -64,6 +66,7 @@ export const getTlsColumns = (tableId: string): TlsColumns => [
|
||||||
values: sha1 ? [sha1] : undefined,
|
values: sha1 ? [sha1] : undefined,
|
||||||
fieldName: 'tls.server.hash.sha1',
|
fieldName: 'tls.server.hash.sha1',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `${tableId}-${sha1}-table-sha1`,
|
idPrefix: `${tableId}-${sha1}-table-sha1`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
@ -78,6 +81,7 @@ export const getTlsColumns = (tableId: string): TlsColumns => [
|
||||||
values: ja3,
|
values: ja3,
|
||||||
fieldName: 'tls.server.ja3s',
|
fieldName: 'tls.server.ja3s',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `${tableId}-${_id}-table-ja3`,
|
idPrefix: `${tableId}-${_id}-table-ja3`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
@ -92,6 +96,7 @@ export const getTlsColumns = (tableId: string): TlsColumns => [
|
||||||
values: notAfter,
|
values: notAfter,
|
||||||
fieldName: 'tls.server.not_after',
|
fieldName: 'tls.server.not_after',
|
||||||
fieldType: 'date',
|
fieldType: 'date',
|
||||||
|
aggregatable: false,
|
||||||
idPrefix: `${tableId}-${_id}-table-notAfter`,
|
idPrefix: `${tableId}-${_id}-table-notAfter`,
|
||||||
render: (validUntil) => (
|
render: (validUntil) => (
|
||||||
<LocalizedDateTooltip date={moment(new Date(validUntil)).toDate()}>
|
<LocalizedDateTooltip date={moment(new Date(validUntil)).toDate()}>
|
||||||
|
|
|
@ -35,6 +35,7 @@ export const getUsersColumns = (
|
||||||
values: userName ? [userName] : undefined,
|
values: userName ? [userName] : undefined,
|
||||||
fieldName: 'user.name',
|
fieldName: 'user.name',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `${tableId}-table-${flowTarget}-user`,
|
idPrefix: `${tableId}-table-${flowTarget}-user`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
@ -49,6 +50,7 @@ export const getUsersColumns = (
|
||||||
values: userIds,
|
values: userIds,
|
||||||
fieldName: 'user.id',
|
fieldName: 'user.id',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `${tableId}-table-${flowTarget}`,
|
idPrefix: `${tableId}-table-${flowTarget}`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
@ -63,6 +65,7 @@ export const getUsersColumns = (
|
||||||
values: groupNames,
|
values: groupNames,
|
||||||
fieldName: 'user.group.name',
|
fieldName: 'user.group.name',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `${tableId}-table-${flowTarget}`,
|
idPrefix: `${tableId}-table-${flowTarget}`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
@ -77,6 +80,7 @@ export const getUsersColumns = (
|
||||||
values: groupId,
|
values: groupId,
|
||||||
fieldName: 'user.group.id',
|
fieldName: 'user.group.id',
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
|
aggregatable: true,
|
||||||
idPrefix: `${tableId}-table-${flowTarget}`,
|
idPrefix: `${tableId}-table-${flowTarget}`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
|
@ -85,6 +85,7 @@ const getUsersColumns = (
|
||||||
values: [name],
|
values: [name],
|
||||||
idPrefix: `users-table-${name}-name`,
|
idPrefix: `users-table-${name}-name`,
|
||||||
render: (item) => <UserDetailsLink userName={item} />,
|
render: (item) => <UserDetailsLink userName={item} />,
|
||||||
|
aggregatable: true,
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
})
|
})
|
||||||
: getOrEmptyTagFromValue(name),
|
: getOrEmptyTagFromValue(name),
|
||||||
|
@ -109,6 +110,7 @@ const getUsersColumns = (
|
||||||
fieldName: 'user.domain',
|
fieldName: 'user.domain',
|
||||||
values: [domain],
|
values: [domain],
|
||||||
idPrefix: `users-table-${domain}-domain`,
|
idPrefix: `users-table-${domain}-domain`,
|
||||||
|
aggregatable: true,
|
||||||
fieldType: 'keyword',
|
fieldType: 'keyword',
|
||||||
})
|
})
|
||||||
: getOrEmptyTagFromValue(domain),
|
: 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;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.EntityAnalyticsTableHoverActions {
|
.EntityAnalyticsTableHoverActions {
|
||||||
.hoverActions-active {
|
.inlineActions-popoverOpen {
|
||||||
.timelines__hoverActionButton,
|
opacity: 1;
|
||||||
.securitySolution__hoverActionButton {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.timelines__hoverActionButton,
|
.inlineActions {
|
||||||
.securitySolution__hoverActionButton {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,8 @@ import React from 'react';
|
||||||
import type { EuiBasicTableColumn } from '@elastic/eui';
|
import type { EuiBasicTableColumn } from '@elastic/eui';
|
||||||
import { EuiLink, EuiIcon, EuiToolTip } from '@elastic/eui';
|
import { EuiLink, EuiIcon, EuiToolTip } from '@elastic/eui';
|
||||||
import { get } from 'lodash/fp';
|
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 { UsersTableType } from '../../../../explore/users/store/model';
|
||||||
import { getEmptyTagValue } from '../../../../common/components/empty_value';
|
import { getEmptyTagValue } from '../../../../common/components/empty_value';
|
||||||
import { HostDetailsLink, UserDetailsLink } from '../../../../common/components/links';
|
import { HostDetailsLink, UserDetailsLink } from '../../../../common/components/links';
|
||||||
|
@ -22,10 +24,17 @@ import type {
|
||||||
import { RiskScoreEntity, RiskScoreFields } from '../../../../../common/search_strategy';
|
import { RiskScoreEntity, RiskScoreFields } from '../../../../../common/search_strategy';
|
||||||
import * as i18n from './translations';
|
import * as i18n from './translations';
|
||||||
import { FormattedCount } from '../../../../common/components/formatted_number';
|
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>>;
|
type HostRiskScoreColumns = Array<EuiBasicTableColumn<HostRiskScore & UserRiskScore>>;
|
||||||
|
|
||||||
|
const StyledCellActions = styled(CellActions)`
|
||||||
|
padding-left: ${({ theme }) => theme.eui.euiSizeS};
|
||||||
|
`;
|
||||||
|
|
||||||
export const getRiskScoreColumns = (
|
export const getRiskScoreColumns = (
|
||||||
riskEntity: RiskScoreEntity,
|
riskEntity: RiskScoreEntity,
|
||||||
openEntityInTimeline: (entityName: string, oldestAlertTimestamp?: string) => void
|
openEntityInTimeline: (entityName: string, oldestAlertTimestamp?: string) => void
|
||||||
|
@ -40,19 +49,30 @@ export const getRiskScoreColumns = (
|
||||||
return riskEntity === RiskScoreEntity.host ? (
|
return riskEntity === RiskScoreEntity.host ? (
|
||||||
<>
|
<>
|
||||||
<HostDetailsLink hostName={entityName} hostTab={HostsTableType.risk} />
|
<HostDetailsLink hostName={entityName} hostTab={HostsTableType.risk} />
|
||||||
<EntityAnalyticsHoverActions
|
<StyledCellActions
|
||||||
idPrefix={`hosts-risk-table-${entityName}`}
|
field={{
|
||||||
fieldName={'host.name'}
|
name: 'host.name',
|
||||||
fieldValue={entityName}
|
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} />
|
<UserDetailsLink userName={entityName} userTab={UsersTableType.risk} />
|
||||||
<EntityAnalyticsHoverActions
|
<StyledCellActions
|
||||||
idPrefix={`users-risk-table-${entityName}`}
|
field={{
|
||||||
fieldName={'user.name'}
|
name: 'user.name',
|
||||||
fieldValue={entityName}
|
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;
|
fieldType: string;
|
||||||
values: string[];
|
values: string[];
|
||||||
idPrefix: string;
|
idPrefix: string;
|
||||||
|
isAggregatable?: boolean;
|
||||||
moreMaxHeight: string;
|
moreMaxHeight: string;
|
||||||
overflowIndexStart: number;
|
overflowIndexStart: number;
|
||||||
render?: (item: string) => React.ReactNode;
|
render?: (item: string) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MoreContainer = React.memo<MoreContainerProps>(
|
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 { timelineId } = useContext(TimelineContext);
|
||||||
|
|
||||||
const moreItemsWithHoverActions = useMemo(
|
const moreItemsWithHoverActions = useMemo(
|
||||||
|
@ -317,6 +327,7 @@ export const MoreContainer = React.memo<MoreContainerProps>(
|
||||||
name: fieldName,
|
name: fieldName,
|
||||||
value,
|
value,
|
||||||
type: fieldType,
|
type: fieldType,
|
||||||
|
aggregatable: isAggregatable,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<>{render ? render(value) : defaultToEmptyTag(value)}</>
|
<>{render ? render(value) : defaultToEmptyTag(value)}</>
|
||||||
|
@ -327,7 +338,16 @@ export const MoreContainer = React.memo<MoreContainerProps>(
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, []),
|
}, []),
|
||||||
[fieldName, fieldType, idPrefix, overflowIndexStart, render, values, timelineId]
|
[
|
||||||
|
fieldName,
|
||||||
|
fieldType,
|
||||||
|
idPrefix,
|
||||||
|
overflowIndexStart,
|
||||||
|
render,
|
||||||
|
values,
|
||||||
|
timelineId,
|
||||||
|
isAggregatable,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -349,7 +369,16 @@ export const MoreContainer = React.memo<MoreContainerProps>(
|
||||||
MoreContainer.displayName = 'MoreContainer';
|
MoreContainer.displayName = 'MoreContainer';
|
||||||
|
|
||||||
export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverflowProps>(
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const togglePopover = useCallback(() => setIsOpen((currentIsOpen) => !currentIsOpen), []);
|
const togglePopover = useCallback(() => setIsOpen((currentIsOpen) => !currentIsOpen), []);
|
||||||
const button = useMemo(
|
const button = useMemo(
|
||||||
|
@ -391,6 +420,7 @@ export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverf
|
||||||
moreMaxHeight={moreMaxHeight}
|
moreMaxHeight={moreMaxHeight}
|
||||||
overflowIndexStart={overflowIndexStart}
|
overflowIndexStart={overflowIndexStart}
|
||||||
fieldType={fieldType}
|
fieldType={fieldType}
|
||||||
|
isAggregatable={isAggregatable}
|
||||||
/>
|
/>
|
||||||
</EuiPopover>
|
</EuiPopover>
|
||||||
)}
|
)}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue