[Security Solution] [Investigations] [Tech Debt] removes redundant code from timelines plugin (#130928)

## [Security Solution] [Investigations] [Tech Debt] removes redundant code from the timelines plugin

This follow-up PR removes redundant code from the `timelines` plugin, identified while implementing https://github.com/elastic/kibana/pull/130740
This commit is contained in:
Andrew Goldstein 2022-04-25 16:14:59 -06:00 committed by GitHub
parent 4e39685c01
commit 3ad6452166
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 2 additions and 4036 deletions

View file

@ -1,26 +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 { Ecs } from '../../../../ecs';
import { CursorType, Maybe } from '../../../common';
export interface TimelineEdges {
node: TimelineItem;
cursor: CursorType;
}
export interface TimelineItem {
_id: string;
_index?: Maybe<string>;
data: TimelineNonEcsData[];
ecs: Ecs;
}
export interface TimelineNonEcsData {
field: string;
value?: Maybe<string[] | string>;
}

View file

@ -6,13 +6,6 @@
*/
import * as runtimeTypes from 'io-ts';
import { ReactNode } from 'react';
// This type is for typing EuiDescriptionList
export interface DescriptionList {
title: NonNullable<ReactNode>;
description: NonNullable<ReactNode>;
}
export const unionWithNullType = <T extends runtimeTypes.Mixed>(type: T) =>
runtimeTypes.union([type, runtimeTypes.null]);

View file

@ -1,54 +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, { MouseEvent } from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { EventsTdContent } from '../t_grid/styles';
import { DEFAULT_ACTION_BUTTON_WIDTH } from '../t_grid/body/constants';
interface ActionIconItemProps {
ariaLabel?: string;
width?: number;
dataTestSubj?: string;
content?: string;
iconType?: string;
isDisabled?: boolean;
onClick?: (event: MouseEvent) => void;
children?: React.ReactNode;
}
const ActionIconItemComponent: React.FC<ActionIconItemProps> = ({
width = DEFAULT_ACTION_BUTTON_WIDTH,
dataTestSubj,
content,
ariaLabel,
iconType = '',
isDisabled = false,
onClick,
children,
}) => (
<div>
<EventsTdContent textAlign="center" width={width}>
{children ?? (
<EuiToolTip data-test-subj={`${dataTestSubj}-tool-tip`} content={content}>
<EuiButtonIcon
aria-label={ariaLabel}
data-test-subj={`${dataTestSubj}-button`}
iconType={iconType}
isDisabled={isDisabled}
onClick={onClick}
/>
</EuiToolTip>
)}
</EventsTdContent>
</div>
);
ActionIconItemComponent.displayName = 'ActionIconItemComponent';
export const ActionIconItem = React.memo(ActionIconItemComponent);

View file

@ -1,48 +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 { rgba } from 'polished';
import React from 'react';
import styled from 'styled-components';
interface WidthProp {
width?: number;
}
const Field = styled.div.attrs<WidthProp>(({ width }) => {
if (width) {
return {
style: {
width: `${width}px`,
},
};
}
})<WidthProp>`
background-color: ${({ theme }) => theme.eui.euiColorEmptyShade};
border: ${({ theme }) => theme.eui.euiBorderThin};
box-shadow: 0 2px 2px -1px ${({ theme }) => rgba(theme.eui.euiColorMediumShade, 0.3)},
0 1px 5px -2px ${({ theme }) => rgba(theme.eui.euiColorMediumShade, 0.3)};
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
line-height: ${({ theme }) => theme.eui.euiLineHeight};
padding: ${({ theme }) => theme.eui.paddingSizes.xs};
`;
Field.displayName = 'Field';
/**
* Renders a field (e.g. `event.action`) as a draggable badge
*/
export const DraggableFieldBadge = React.memo<{ fieldId: string; fieldWidth?: number }>(
({ fieldId, fieldWidth }) => (
<Field data-test-subj="field" width={fieldWidth}>
{fieldId}
</Field>
)
);
DraggableFieldBadge.displayName = 'DraggableFieldBadge';

View file

@ -1,34 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const CATEGORY = i18n.translate('xpack.timelines.draggables.field.categoryLabel', {
defaultMessage: 'Category',
});
export const COPY_TO_CLIPBOARD = i18n.translate(
'xpack.timelines.eventDetails.copyToClipboardTooltip',
{
defaultMessage: 'Copy to Clipboard',
}
);
export const FIELD = i18n.translate('xpack.timelines.draggables.field.fieldLabel', {
defaultMessage: 'Field',
});
export const TYPE = i18n.translate('xpack.timelines.draggables.field.typeLabel', {
defaultMessage: 'Type',
});
export const VIEW_CATEGORY = i18n.translate(
'xpack.timelines.draggables.field.viewCategoryTooltip',
{
defaultMessage: 'View Category',
}
);

View file

@ -1,8 +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.
*/
export * from './field_badge';

View file

@ -1,7 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EmptyValue it renders against snapshot 1`] = `
<p>
(Empty String)
</p>
`;

View file

@ -5,162 +5,10 @@
* 2.0.
*/
import { mount, shallow } from 'enzyme';
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import {
defaultToEmptyTag,
getEmptyString,
getEmptyStringTag,
getEmptyTagValue,
getEmptyValue,
getOrEmptyTag,
} from '.';
import { getMockTheme } from '../../mock/kibana_react.mock';
import { getEmptyValue } from '.';
describe('EmptyValue', () => {
const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } });
test('it renders against snapshot', () => {
const wrapper = shallow(<p>{getEmptyString()}</p>);
expect(wrapper).toMatchSnapshot();
});
describe('#getEmptyValue', () => {
test('should return an empty value', () => expect(getEmptyValue()).toBe('—'));
});
describe('#getEmptyString', () => {
test('should turn into an empty string place holder', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={mockTheme}>
<p>{getEmptyString()}</p>
</ThemeProvider>
);
expect(wrapper.text()).toBe('(Empty String)');
});
});
describe('#getEmptyTagValue', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<p>{getEmptyTagValue()}</p>
</ThemeProvider>
);
test('should return an empty tag value', () => expect(wrapper.text()).toBe('—'));
});
describe('#getEmptyStringTag', () => {
test('should turn into an span that has length of 1', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={mockTheme}>
<p>{getEmptyStringTag()}</p>
</ThemeProvider>
);
expect(wrapper.find('span')).toHaveLength(1);
});
test('should turn into an empty string tag place holder', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={mockTheme}>
<p>{getEmptyStringTag()}</p>
</ThemeProvider>
);
expect(wrapper.text()).toBe(getEmptyString());
});
});
describe('#defaultToEmptyTag', () => {
test('should default to an empty value when a value is null', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<p>{defaultToEmptyTag(null)}</p>
</ThemeProvider>
);
expect(wrapper.text()).toBe(getEmptyValue());
});
test('should default to an empty value when a value is undefined', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<p>{defaultToEmptyTag(undefined)}</p>
</ThemeProvider>
);
expect(wrapper.text()).toBe(getEmptyValue());
});
test('should return a deep path value', () => {
const test = {
a: {
b: {
c: 1,
},
},
};
const wrapper = mount(<p>{defaultToEmptyTag(test.a.b.c)}</p>);
expect(wrapper.text()).toBe('1');
});
});
describe('#getOrEmptyTag', () => {
test('should default empty value when a deep rooted value is null', () => {
const test = {
a: {
b: {
c: null,
},
},
};
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<p>{getOrEmptyTag('a.b.c', test)}</p>
</ThemeProvider>
);
expect(wrapper.text()).toBe(getEmptyValue());
});
test('should default empty value when a deep rooted value is undefined', () => {
const test = {
a: {
b: {
c: undefined,
},
},
};
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<p>{getOrEmptyTag('a.b.c', test)}</p>
</ThemeProvider>
);
expect(wrapper.text()).toBe(getEmptyValue());
});
test('should default empty value when a deep rooted value is missing', () => {
const test = {
a: {
b: {},
},
};
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<p>{getOrEmptyTag('a.b.c', test)}</p>
</ThemeProvider>
);
expect(wrapper.text()).toBe(getEmptyValue());
});
test('should return a deep path value', () => {
const test = {
a: {
b: {
c: 1,
},
},
};
const wrapper = mount(<p>{getOrEmptyTag('a.b.c', test)}</p>);
expect(wrapper.text()).toBe('1');
});
});
});

View file

@ -5,45 +5,4 @@
* 2.0.
*/
import { get, isString } from 'lodash/fp';
import React from 'react';
import styled from 'styled-components';
import * as i18n from './translations';
const EmptyWrapper = styled.span`
color: ${(props) => props.theme.eui.euiColorMediumShade};
`;
EmptyWrapper.displayName = 'EmptyWrapper';
export const getEmptyValue = () => '—';
export const getEmptyString = () => `(${i18n.EMPTY_STRING})`;
export const getEmptyTagValue = () => <EmptyWrapper>{getEmptyValue()}</EmptyWrapper>;
export const getEmptyStringTag = () => <EmptyWrapper>{getEmptyString()}</EmptyWrapper>;
export const defaultToEmptyTag = <T extends unknown>(item: T): JSX.Element => {
if (item == null) {
return getEmptyTagValue();
} else if (isString(item) && item === '') {
return getEmptyStringTag();
} else {
return <>{item}</>;
}
};
export const getOrEmptyTag = (path: string, item: unknown): JSX.Element => {
const text = get(path, item);
return getOrEmptyTagFromValue(text);
};
export const getOrEmptyTagFromValue = (value: string | number | null | undefined): JSX.Element => {
if (value == null) {
return getEmptyTagValue();
} else if (value === '') {
return getEmptyStringTag();
} else {
return <>{value}</>;
}
};

View file

@ -1,12 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const EMPTY_STRING = i18n.translate('xpack.timelines.emptyString.emptyStringDescription', {
defaultMessage: 'Empty String',
});

View file

@ -1,60 +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 { mount } from 'enzyme';
import React from 'react';
import { TestProviders } from '../../mock/test_providers';
import * as i18n from './translations';
import { ExitFullScreen, EXIT_FULL_SCREEN_CLASS_NAME } from '.';
describe('ExitFullScreen', () => {
test('it returns null when fullScreen is false', () => {
const exitFullScreen = mount(
<TestProviders>
<ExitFullScreen fullScreen={false} setFullScreen={jest.fn()} />
</TestProviders>
);
expect(exitFullScreen.find('[data-test-subj="exit-full-screen"]').exists()).toBe(false);
});
test('it renders a button with the exported EXIT_FULL_SCREEN_CLASS_NAME class when fullScreen is true', () => {
const exitFullScreen = mount(
<TestProviders>
<ExitFullScreen fullScreen={true} setFullScreen={jest.fn()} />
</TestProviders>
);
expect(exitFullScreen.find(`button.${EXIT_FULL_SCREEN_CLASS_NAME}`).exists()).toBe(true);
});
test('it renders the expected button text when fullScreen is true', () => {
const exitFullScreen = mount(
<TestProviders>
<ExitFullScreen fullScreen={true} setFullScreen={jest.fn()} />
</TestProviders>
);
expect(exitFullScreen.find('[data-test-subj="exit-full-screen"]').first().text()).toBe(
i18n.EXIT_FULL_SCREEN
);
});
test('it invokes setFullScreen with a value of false when the button is clicked', () => {
const setFullScreen = jest.fn();
const exitFullScreen = mount(
<TestProviders>
<ExitFullScreen fullScreen={true} setFullScreen={setFullScreen} />
</TestProviders>
);
exitFullScreen.find('[data-test-subj="exit-full-screen"]').first().simulate('click');
expect(setFullScreen).toBeCalledWith(false);
});
});

View file

@ -1,64 +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 { EuiButton, EuiWindowEvent } from '@elastic/eui';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import * as i18n from './translations';
export const EXIT_FULL_SCREEN_CLASS_NAME = 'exit-full-screen';
const StyledEuiButton = styled(EuiButton)`
margin: ${({ theme }) => theme.eui.paddingSizes.s};
`;
interface Props {
fullScreen: boolean;
setFullScreen: (fullScreen: boolean) => void;
}
const ExitFullScreenComponent: React.FC<Props> = ({ fullScreen, setFullScreen }) => {
const exitFullScreen = useCallback(() => {
setFullScreen(false);
}, [setFullScreen]);
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
exitFullScreen();
}
},
[exitFullScreen]
);
if (!fullScreen) {
return null;
}
return (
<>
<EuiWindowEvent event="keydown" handler={onKeyDown} />
<StyledEuiButton
className={EXIT_FULL_SCREEN_CLASS_NAME}
data-test-subj="exit-full-screen"
fullWidth={false}
iconType="fullScreen"
isDisabled={!fullScreen}
onClick={exitFullScreen}
>
{i18n.EXIT_FULL_SCREEN}
</StyledEuiButton>
</>
);
};
ExitFullScreenComponent.displayName = 'ExitFullScreenComponent';
export const ExitFullScreen = React.memo(ExitFullScreenComponent);

View file

@ -1,12 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const EXIT_FULL_SCREEN = i18n.translate('xpack.timelines.exitFullScreenButton', {
defaultMessage: 'Exit full screen',
});

View file

@ -56,7 +56,6 @@ export const TGrid = (props: TGridComponent) => {
export { TGrid as default };
export * from './drag_and_drop';
export * from './draggables';
export * from './last_updated';
export * from './loading';
export * from './fields_browser';

View file

@ -1,69 +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 { EuiButtonIcon } from '@elastic/eui';
import React, { useCallback } from 'react';
import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline';
import { EventsHeadingExtra, EventsLoading } from '../../../styles';
import type { OnColumnRemoved } from '../../../types';
import type { Sort } from '../../sort';
import * as i18n from '../translations';
interface Props {
header: ColumnHeaderOptions;
isLoading: boolean;
onColumnRemoved: OnColumnRemoved;
sort: Sort[];
}
/** Given a `header`, returns the `SortDirection` applicable to it */
export const CloseButton = React.memo<{
columnId: string;
onColumnRemoved: OnColumnRemoved;
}>(({ columnId, onColumnRemoved }) => {
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
// To avoid a re-sorting when you delete a column
event.preventDefault();
event.stopPropagation();
onColumnRemoved(columnId);
},
[columnId, onColumnRemoved]
);
return (
<EuiButtonIcon
aria-label={i18n.REMOVE_COLUMN}
color="text"
data-test-subj="remove-column"
iconType="cross"
onClick={handleClick}
/>
);
});
CloseButton.displayName = 'CloseButton';
export const Actions = React.memo<Props>(({ header, onColumnRemoved, sort, isLoading }) => {
return (
<>
{sort.some((i) => i.columnId === header.id) && isLoading ? (
<EventsHeadingExtra className="siemEventsHeading__extra--loading">
<EventsLoading data-test-subj="timeline-loading-spinner" />
</EventsHeadingExtra>
) : (
<EventsHeadingExtra className="siemEventsHeading__extra--close">
<CloseButton columnId={header.id} onColumnRemoved={onColumnRemoved} />
</EventsHeadingExtra>
)}
</>
);
});
Actions.displayName = 'Actions';

View file

@ -1,25 +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 { FC, memo, useEffect } from 'react';
interface DraggingContainerProps {
children: JSX.Element;
onDragging: Function;
}
const DraggingContainerComponent: FC<DraggingContainerProps> = ({ children, onDragging }) => {
useEffect(() => {
onDragging(true);
return () => onDragging(false);
});
return children;
};
export const DraggingContainer = memo(DraggingContainerComponent);

View file

@ -1,19 +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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
export const FullHeightFlexGroup = styled(EuiFlexGroup)`
height: 100%;
`;
FullHeightFlexGroup.displayName = 'FullHeightFlexGroup';
export const FullHeightFlexItem = styled(EuiFlexItem)`
height: 100%;
`;
FullHeightFlexItem.displayName = 'FullHeightFlexItem';

View file

@ -1,51 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header renders correctly against snapshot 1`] = `
<Fragment>
<Memo(HeaderContentComponent)
header={
Object {
"columnHeaderType": "not-filtered",
"id": "@timestamp",
"initialWidth": 190,
"type": "number",
}
}
isLoading={false}
isResizing={false}
onClick={[Function]}
showSortingCapability={true}
sort={
Array [
Object {
"columnId": "@timestamp",
"columnType": "number",
"sortDirection": "desc",
},
]
}
>
<Actions
header={
Object {
"columnHeaderType": "not-filtered",
"id": "@timestamp",
"initialWidth": 190,
"type": "number",
}
}
isLoading={false}
onColumnRemoved={[Function]}
sort={
Array [
Object {
"columnId": "@timestamp",
"columnType": "number",
"sortDirection": "desc",
},
]
}
/>
</Memo(HeaderContentComponent)>
</Fragment>
`;

View file

@ -1,85 +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 { EuiToolTip } from '@elastic/eui';
import { noop } from 'lodash/fp';
import React from 'react';
import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline';
import { TruncatableText } from '../../../../truncatable_text';
import { EventsHeading, EventsHeadingTitleButton, EventsHeadingTitleSpan } from '../../../styles';
import { Sort } from '../../sort';
import { SortIndicator } from '../../sort/sort_indicator';
import { HeaderToolTipContent } from '../header_tooltip_content';
import { getSortDirection, getSortIndex } from './helpers';
interface HeaderContentProps {
children: React.ReactNode;
header: ColumnHeaderOptions;
isLoading: boolean;
isResizing: boolean;
onClick: () => void;
showSortingCapability: boolean;
sort: Sort[];
}
const HeaderContentComponent: React.FC<HeaderContentProps> = ({
children,
header,
isLoading,
isResizing,
onClick,
showSortingCapability,
sort,
}) => (
<EventsHeading data-test-subj={`header-${header.id}`} isLoading={isLoading}>
{header.aggregatable && showSortingCapability ? (
<EventsHeadingTitleButton
data-test-subj="header-sort-button"
onClick={!isResizing && !isLoading ? onClick : noop}
>
<TruncatableText data-test-subj={`header-text-${header.id}`}>
<EuiToolTip
data-test-subj="header-tooltip"
content={<HeaderToolTipContent header={header} />}
>
<>
{React.isValidElement(header.display)
? header.display
: header.displayAsText ?? header.id}
</>
</EuiToolTip>
</TruncatableText>
<SortIndicator
data-test-subj="header-sort-indicator"
sortDirection={getSortDirection({ header, sort })}
sortNumber={getSortIndex({ header, sort })}
/>
</EventsHeadingTitleButton>
) : (
<EventsHeadingTitleSpan>
<TruncatableText data-test-subj={`header-text-${header.id}`}>
<EuiToolTip
data-test-subj="header-tooltip"
content={<HeaderToolTipContent header={header} />}
>
<>
{React.isValidElement(header.display)
? header.display
: header.displayAsText ?? header.id}
</>
</EuiToolTip>
</TruncatableText>
</EventsHeadingTitleSpan>
)}
{children}
</EventsHeading>
);
export const HeaderContent = React.memo(HeaderContentComponent);

View file

@ -1,54 +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 { Direction } from '../../../../../../common/search_strategy';
import type { ColumnHeaderOptions } from '../../../../../../common/types';
import { assertUnreachable } from '../../../../../../common/utility_types';
import { Sort, SortDirection } from '../../sort';
interface GetNewSortDirectionOnClickParams {
clickedHeader: ColumnHeaderOptions;
currentSort: Sort[];
}
/** Given a `header`, returns the `SortDirection` applicable to it */
export const getNewSortDirectionOnClick = ({
clickedHeader,
currentSort,
}: GetNewSortDirectionOnClickParams): Direction =>
currentSort.reduce<Direction>(
(acc, item) => (clickedHeader.id === item.columnId ? getNextSortDirection(item) : acc),
Direction.desc
);
/** Given a current sort direction, it returns the next sort direction */
export const getNextSortDirection = (currentSort: Sort): Direction => {
switch (currentSort.sortDirection) {
case Direction.desc:
return Direction.asc;
case Direction.asc:
return Direction.desc;
case 'none':
return Direction.desc;
default:
return assertUnreachable(currentSort.sortDirection as never, 'Unhandled sort direction');
}
};
interface GetSortDirectionParams {
header: ColumnHeaderOptions;
sort: Sort[];
}
export const getSortDirection = ({ header, sort }: GetSortDirectionParams): SortDirection =>
sort.reduce<SortDirection>(
(acc, item) => (header.id === item.columnId ? item.sortDirection : acc),
'none'
);
export const getSortIndex = ({ header, sort }: GetSortDirectionParams): number =>
sort.findIndex((s) => s.columnId === header.id);

View file

@ -1,331 +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 { mount, shallow } from 'enzyme';
import React from 'react';
import { Sort } from '../../sort';
import { CloseButton } from '../actions';
import { defaultHeaders } from '../default_headers';
import { HeaderComponent } from '.';
import { getNewSortDirectionOnClick, getNextSortDirection, getSortDirection } from './helpers';
import { Direction } from '../../../../../../common/search_strategy';
import { TestProviders } from '../../../../../mock';
import { tGridActions } from '../../../../../store/t_grid';
import { mockGlobalState } from '../../../../../mock/global_state';
const mockDispatch = jest.fn();
jest.mock('../../../../../hooks/use_selector', () => ({
useShallowEqualSelector: () => mockGlobalState.timelineById.test,
useDeepEqualSelector: () => mockGlobalState.timelineById.test,
}));
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useSelector: jest.fn(),
useDispatch: () => mockDispatch,
};
});
describe('Header', () => {
const columnHeader = defaultHeaders[0];
const sort: Sort[] = [
{
columnId: columnHeader.id,
columnType: columnHeader.type ?? 'number',
sortDirection: Direction.desc,
},
];
const timelineId = 'test';
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<TestProviders>
<HeaderComponent header={columnHeader} sort={sort} timelineId={timelineId} />
</TestProviders>
);
expect(wrapper.find('HeaderComponent').dive()).toMatchSnapshot();
});
describe('rendering', () => {
test('it renders the header text', () => {
const wrapper = mount(
<TestProviders>
<HeaderComponent header={columnHeader} sort={sort} timelineId={timelineId} />
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text()
).toEqual(columnHeader.id);
});
test('it renders the header text alias when displayAsText is provided', () => {
const displayAsText = 'Timestamp';
const headerWithLabel = { ...columnHeader, displayAsText };
const wrapper = mount(
<TestProviders>
<HeaderComponent header={headerWithLabel} sort={sort} timelineId={timelineId} />
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text()
).toEqual(displayAsText);
});
test('it renders the header as a `ReactNode` when `display` is provided', () => {
const display: React.ReactNode = (
<div data-test-subj="rendered-via-display">
{'The display property renders the column heading as a ReactNode'}
</div>
);
const headerWithLabel = { ...columnHeader, display };
const wrapper = mount(
<TestProviders>
<HeaderComponent header={headerWithLabel} sort={sort} timelineId={timelineId} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="rendered-via-display"]`).exists()).toBe(true);
});
test('it prefers to render `display` instead of `displayAsText` when both are provided', () => {
const displayAsText = 'this text should NOT be rendered';
const display: React.ReactNode = (
<div data-test-subj="rendered-via-display">{'this text is rendered via display'}</div>
);
const headerWithLabel = { ...columnHeader, display, displayAsText };
const wrapper = mount(
<TestProviders>
<HeaderComponent header={headerWithLabel} sort={sort} timelineId={timelineId} />
</TestProviders>
);
expect(wrapper.text()).toBe('this text is rendered via display');
});
test('it falls back to rendering header.id when `display` is not a valid React node', () => {
const display = {}; // a plain object is NOT a `ReactNode`
const headerWithLabel = { ...columnHeader, display };
const wrapper = mount(
<TestProviders>
<HeaderComponent header={headerWithLabel} sort={sort} timelineId={timelineId} />
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text()
).toEqual(columnHeader.id);
});
test('it renders a sort indicator', () => {
const headerSortable = { ...columnHeader, aggregatable: true };
const wrapper = mount(
<TestProviders>
<HeaderComponent header={headerSortable} sort={sort} timelineId={timelineId} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="header-sort-indicator"]').first().exists()).toEqual(
true
);
});
});
describe('onColumnSorted', () => {
test('it invokes the onColumnSorted callback when the header sort button is clicked', () => {
const headerSortable = { ...columnHeader, aggregatable: true };
const wrapper = mount(
<TestProviders>
<HeaderComponent header={headerSortable} sort={sort} timelineId={timelineId} />
</TestProviders>
);
wrapper.find('[data-test-subj="header-sort-button"]').first().simulate('click');
expect(mockDispatch).toBeCalledWith(
tGridActions.updateSort({
id: timelineId,
sort: [
{
columnId: columnHeader.id,
columnType: columnHeader.type ?? 'number',
sortDirection: Direction.asc, // (because the previous state was Direction.desc)
},
],
})
);
});
test('it does NOT render the header sort button when aggregatable is false', () => {
const headerSortable = { ...columnHeader, aggregatable: false };
const wrapper = mount(
<TestProviders>
<HeaderComponent header={headerSortable} sort={sort} timelineId={timelineId} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="header-sort-button"]').length).toEqual(0);
});
test('it does NOT render the header sort button when aggregatable is missing', () => {
const headerSortable = { ...columnHeader };
const wrapper = mount(
<TestProviders>
<HeaderComponent header={headerSortable} sort={sort} timelineId={timelineId} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="header-sort-button"]').length).toEqual(0);
});
test('it does NOT invoke the onColumnSorted callback when the header is clicked and aggregatable is undefined', () => {
const mockOnColumnSorted = jest.fn();
const headerSortable = { ...columnHeader, aggregatable: undefined };
const wrapper = mount(
<TestProviders>
<HeaderComponent header={headerSortable} sort={sort} timelineId={timelineId} />
</TestProviders>
);
wrapper.find(`[data-test-subj="header-${columnHeader.id}"]`).first().simulate('click');
expect(mockOnColumnSorted).not.toHaveBeenCalled();
});
});
describe('CloseButton', () => {
test('it invokes the onColumnRemoved callback with the column ID when the close button is clicked', () => {
const mockOnColumnRemoved = jest.fn();
const wrapper = mount(
<CloseButton columnId={columnHeader.id} onColumnRemoved={mockOnColumnRemoved} />
);
wrapper.find('[data-test-subj="remove-column"]').first().simulate('click');
expect(mockOnColumnRemoved).toBeCalledWith(columnHeader.id);
});
});
describe('getSortDirection', () => {
test('it returns the sort direction when the header id matches the sort column id', () => {
expect(getSortDirection({ header: columnHeader, sort })).toEqual(sort[0].sortDirection);
});
test('it returns "none" when sort direction when the header id does NOT match the sort column id', () => {
const nonMatching: Sort[] = [
{
columnId: 'differentSocks',
columnType: columnHeader.type ?? 'number',
sortDirection: Direction.desc,
},
];
expect(getSortDirection({ header: columnHeader, sort: nonMatching })).toEqual('none');
});
});
describe('getNextSortDirection', () => {
test('it returns "asc" when the current direction is "desc"', () => {
const sortDescending: Sort = {
columnId: columnHeader.id,
columnType: columnHeader.type ?? 'number',
sortDirection: Direction.desc,
};
expect(getNextSortDirection(sortDescending)).toEqual('asc');
});
test('it returns "desc" when the current direction is "asc"', () => {
const sortAscending: Sort = {
columnId: columnHeader.id,
columnType: columnHeader.type ?? 'number',
sortDirection: Direction.asc,
};
expect(getNextSortDirection(sortAscending)).toEqual(Direction.desc);
});
test('it returns "desc" by default', () => {
const sortNone: Sort = {
columnId: columnHeader.id,
columnType: columnHeader.type ?? 'number',
sortDirection: 'none',
};
expect(getNextSortDirection(sortNone)).toEqual(Direction.desc);
});
});
describe('getNewSortDirectionOnClick', () => {
test('it returns the expected new sort direction when the header id matches the sort column id', () => {
const sortMatches: Sort[] = [
{
columnId: columnHeader.id,
columnType: columnHeader.type ?? 'number',
sortDirection: Direction.desc,
},
];
expect(
getNewSortDirectionOnClick({
clickedHeader: columnHeader,
currentSort: sortMatches,
})
).toEqual(Direction.asc);
});
test('it returns the expected new sort direction when the header id does NOT match the sort column id', () => {
const sortDoesNotMatch: Sort[] = [
{
columnId: 'someOtherColumn',
columnType: columnHeader.type ?? 'number',
sortDirection: 'none',
},
];
expect(
getNewSortDirectionOnClick({
clickedHeader: columnHeader,
currentSort: sortDoesNotMatch,
})
).toEqual(Direction.desc);
});
});
describe('text truncation styling', () => {
test('truncates the header text with an ellipsis', () => {
const wrapper = mount(
<TestProviders>
<HeaderComponent header={columnHeader} sort={sort} timelineId={timelineId} />
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).at(1)
).toHaveStyleRule('text-overflow', 'ellipsis');
});
});
describe('header tooltip', () => {
test('it has a tooltip to display the properties of the field', () => {
const wrapper = mount(
<TestProviders>
<HeaderComponent header={columnHeader} sort={sort} timelineId={timelineId} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="header-tooltip"]').exists()).toEqual(true);
});
});
});

View file

@ -1,95 +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, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { isDataViewFieldSubtypeNested } from '@kbn/es-query';
import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline';
import type { Sort } from '../../sort';
import { Actions } from '../actions';
import { getNewSortDirectionOnClick } from './helpers';
import { HeaderContent } from './header_content';
import { tGridActions, tGridSelectors } from '../../../../../store/t_grid';
import { useDeepEqualSelector } from '../../../../../hooks/use_selector';
interface Props {
header: ColumnHeaderOptions;
sort: Sort[];
timelineId: string;
}
export const HeaderComponent: React.FC<Props> = ({ header, sort, timelineId }) => {
const dispatch = useDispatch();
const onColumnSort = useCallback(() => {
const columnId = header.id;
const columnType = header.type ?? 'text';
const sortDirection = getNewSortDirectionOnClick({
clickedHeader: header,
currentSort: sort,
});
const headerIndex = sort.findIndex((col) => col.columnId === columnId);
let newSort = [];
if (headerIndex === -1) {
newSort = [
...sort,
{
columnId,
columnType,
sortDirection,
},
];
} else {
newSort = [
...sort.slice(0, headerIndex),
{
columnId,
columnType,
sortDirection,
},
...sort.slice(headerIndex + 1),
];
}
dispatch(
tGridActions.updateSort({
id: timelineId,
sort: newSort,
})
);
}, [dispatch, header, sort, timelineId]);
const onColumnRemoved = useCallback(
(columnId) => dispatch(tGridActions.removeColumn({ id: timelineId, columnId })),
[dispatch, timelineId]
);
const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []);
const { isLoading } = useDeepEqualSelector((state) => getManageTimeline(state, timelineId ?? ''));
const showSortingCapability = !isDataViewFieldSubtypeNested(header);
return (
<>
<HeaderContent
header={header}
isLoading={isLoading}
isResizing={false}
onClick={onColumnSort}
showSortingCapability={showSortingCapability}
sort={sort}
>
<Actions
header={header}
isLoading={isLoading}
onColumnRemoved={onColumnRemoved}
sort={sort}
/>
</HeaderContent>
</>
);
};
export const Header = React.memo(HeaderComponent);

View file

@ -1,66 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HeaderToolTipContent it renders the expected table content 1`] = `
<Fragment>
<P>
<ToolTipTableMetadata
data-test-subj="category"
>
Category
:
</ToolTipTableMetadata>
<ToolTipTableValue
data-test-subj="category-value"
>
base
</ToolTipTableValue>
</P>
<P>
<ToolTipTableMetadata
data-test-subj="field"
>
Field
:
</ToolTipTableMetadata>
<ToolTipTableValue
data-test-subj="field-value"
>
@timestamp
</ToolTipTableValue>
</P>
<P>
<ToolTipTableMetadata
data-test-subj="type"
>
Type
:
</ToolTipTableMetadata>
<ToolTipTableValue>
<IconType
data-test-subj="type-icon"
type="clock"
/>
<span
data-test-subj="type-value"
>
date
</span>
</ToolTipTableValue>
</P>
<P>
<ToolTipTableMetadata
data-test-subj="description"
>
Description
:
</ToolTipTableMetadata>
<ToolTipTableValue
data-test-subj="description-value"
>
Date/time when the event originated.
For log events this is the date/time when the event was generated, and not when it was read.
Required field for all events.
</ToolTipTableValue>
</P>
</Fragment>
`;

View file

@ -1,72 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { mount, shallow } from 'enzyme';
import { cloneDeep } from 'lodash/fp';
import React from 'react';
import { ColumnHeaderOptions } from '../../../../../../common/types/timeline';
import { HeaderToolTipContent } from '.';
import { defaultHeaders } from '../../../../../mock/header';
describe('HeaderToolTipContent', () => {
let header: ColumnHeaderOptions;
beforeEach(() => {
header = cloneDeep(defaultHeaders[0]);
});
test('it renders the category', () => {
const wrapper = mount(<HeaderToolTipContent header={header} />);
expect(wrapper.find('[data-test-subj="category-value"]').first().text()).toEqual(
header.category
);
});
test('it renders the name of the field', () => {
const wrapper = mount(<HeaderToolTipContent header={header} />);
expect(wrapper.find('[data-test-subj="field-value"]').first().text()).toEqual(header.id);
});
test('it renders the expected icon for the header type', () => {
const wrapper = mount(<HeaderToolTipContent header={header} />);
expect(wrapper.find('[data-test-subj="type-icon"]').first().props().type).toEqual('clock');
});
test('it renders the type of the field', () => {
const wrapper = mount(<HeaderToolTipContent header={header} />);
expect(wrapper.find('[data-test-subj="type-value"]').first().text()).toEqual(header.type);
});
test('it renders the description of the field', () => {
const wrapper = mount(<HeaderToolTipContent header={header} />);
expect(wrapper.find('[data-test-subj="description-value"]').first().text()).toEqual(
header.description
);
});
test('it does NOT render the description column when the field does NOT contain a description', () => {
const noDescription = {
...header,
description: '',
};
const wrapper = mount(<HeaderToolTipContent header={noDescription} />);
expect(wrapper.find('[data-test-subj="description"]').exists()).toEqual(false);
});
test('it renders the expected table content', () => {
const wrapper = shallow(<HeaderToolTipContent header={header} />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -1,81 +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 { EuiIcon } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React from 'react';
import styled from 'styled-components';
import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline';
import { getIconFromType } from '../../../../utils/helpers';
import * as i18n from '../translations';
const IconType = styled(EuiIcon)`
margin-right: 3px;
position: relative;
top: -2px;
`;
IconType.displayName = 'IconType';
const P = styled.span`
margin-bottom: 5px;
`;
P.displayName = 'P';
const ToolTipTableMetadata = styled.span`
margin-right: 5px;
display: block;
`;
ToolTipTableMetadata.displayName = 'ToolTipTableMetadata';
const ToolTipTableValue = styled.span`
word-wrap: break-word;
`;
ToolTipTableValue.displayName = 'ToolTipTableValue';
export const HeaderToolTipContent = React.memo<{ header: ColumnHeaderOptions }>(({ header }) => (
<>
{!isEmpty(header.category) && (
<P>
<ToolTipTableMetadata data-test-subj="category">
{i18n.CATEGORY}
{':'}
</ToolTipTableMetadata>
<ToolTipTableValue data-test-subj="category-value">{header.category}</ToolTipTableValue>
</P>
)}
<P>
<ToolTipTableMetadata data-test-subj="field">
{i18n.FIELD}
{':'}
</ToolTipTableMetadata>
<ToolTipTableValue data-test-subj="field-value">{header.id}</ToolTipTableValue>
</P>
<P>
<ToolTipTableMetadata data-test-subj="type">
{i18n.TYPE}
{':'}
</ToolTipTableMetadata>
<ToolTipTableValue>
<IconType data-test-subj="type-icon" type={getIconFromType(header.type)} />
<span data-test-subj="type-value">{header.type}</span>
</ToolTipTableValue>
</P>
{!isEmpty(header.description) && (
<P>
<ToolTipTableMetadata data-test-subj="description">
{i18n.DESCRIPTION}
{':'}
</ToolTipTableMetadata>
<ToolTipTableValue data-test-subj="description-value">
{header.description}
</ToolTipTableValue>
</P>
)}
</>
));
HeaderToolTipContent.displayName = 'HeaderToolTipContent';

View file

@ -24,14 +24,8 @@ import { euiThemeVars } from '@kbn/ui-theme';
export const DEFAULT_ACTION_BUTTON_WIDTH =
parseInt(euiThemeVars.euiSizeXL, 10) - parseInt(euiThemeVars.euiSizeXS, 10); // px
/** Additional column width to include when checkboxes are shown **/
export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 24; // px;
/** The default minimum width of a column (when a width for the column type is not specified) */
export const DEFAULT_COLUMN_MIN_WIDTH = 180; // px
/** The minimum width of a resized column */
export const RESIZED_COLUMN_MIN_WITH = 70; // px
/** The default minimum width of a column of type `date` */
export const DEFAULT_DATE_COLUMN_MIN_WIDTH = 190; // px

View file

@ -1,967 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Columns it renders the expected columns 1`] = `
<styled.div
data-test-subj="data-driven-columns"
>
<TgridTdCell
_id="1"
ariaRowindex={2}
data={
Array [
Object {
"field": "@timestamp",
"value": Array [
"2018-11-05T19:03:25.937Z",
],
},
Object {
"field": "event.severity",
"value": Array [
"3",
],
},
Object {
"field": "event.category",
"value": Array [
"Access",
],
},
Object {
"field": "event.action",
"value": Array [
"Action",
],
},
Object {
"field": "host.name",
"value": Array [
"apache",
],
},
Object {
"field": "source.ip",
"value": Array [
"192.168.0.1",
],
},
Object {
"field": "destination.ip",
"value": Array [
"192.168.0.3",
],
},
Object {
"field": "destination.bytes",
"value": Array [
"123456",
],
},
Object {
"field": "user.name",
"value": Array [
"john.dee",
],
},
]
}
ecsData={
Object {
"_id": "1",
"destination": Object {
"ip": Array [
"192.168.0.3",
],
"port": Array [
6343,
],
},
"event": Object {
"action": Array [
"Action",
],
"category": Array [
"Access",
],
"id": Array [
"1",
],
"module": Array [
"nginx",
],
"severity": Array [
3,
],
},
"geo": Object {
"country_iso_code": Array [
"xx",
],
"region_name": Array [
"xx",
],
},
"host": Object {
"ip": Array [
"192.168.0.1",
],
"name": Array [
"apache",
],
},
"source": Object {
"ip": Array [
"192.168.0.1",
],
"port": Array [
80,
],
},
"timestamp": "2018-11-05T19:03:25.937Z",
"user": Object {
"id": Array [
"1",
],
"name": Array [
"john.dee",
],
},
}
}
hasRowRenderers={false}
header={
Object {
"columnHeaderType": "not-filtered",
"id": "message",
"initialWidth": 180,
}
}
index={0}
key="message"
renderCellValue={[Function]}
timelineId="test"
/>
<TgridTdCell
_id="1"
ariaRowindex={2}
data={
Array [
Object {
"field": "@timestamp",
"value": Array [
"2018-11-05T19:03:25.937Z",
],
},
Object {
"field": "event.severity",
"value": Array [
"3",
],
},
Object {
"field": "event.category",
"value": Array [
"Access",
],
},
Object {
"field": "event.action",
"value": Array [
"Action",
],
},
Object {
"field": "host.name",
"value": Array [
"apache",
],
},
Object {
"field": "source.ip",
"value": Array [
"192.168.0.1",
],
},
Object {
"field": "destination.ip",
"value": Array [
"192.168.0.3",
],
},
Object {
"field": "destination.bytes",
"value": Array [
"123456",
],
},
Object {
"field": "user.name",
"value": Array [
"john.dee",
],
},
]
}
ecsData={
Object {
"_id": "1",
"destination": Object {
"ip": Array [
"192.168.0.3",
],
"port": Array [
6343,
],
},
"event": Object {
"action": Array [
"Action",
],
"category": Array [
"Access",
],
"id": Array [
"1",
],
"module": Array [
"nginx",
],
"severity": Array [
3,
],
},
"geo": Object {
"country_iso_code": Array [
"xx",
],
"region_name": Array [
"xx",
],
},
"host": Object {
"ip": Array [
"192.168.0.1",
],
"name": Array [
"apache",
],
},
"source": Object {
"ip": Array [
"192.168.0.1",
],
"port": Array [
80,
],
},
"timestamp": "2018-11-05T19:03:25.937Z",
"user": Object {
"id": Array [
"1",
],
"name": Array [
"john.dee",
],
},
}
}
hasRowRenderers={false}
header={
Object {
"columnHeaderType": "not-filtered",
"id": "event.category",
"initialWidth": 180,
}
}
index={1}
key="event.category"
renderCellValue={[Function]}
timelineId="test"
/>
<TgridTdCell
_id="1"
ariaRowindex={2}
data={
Array [
Object {
"field": "@timestamp",
"value": Array [
"2018-11-05T19:03:25.937Z",
],
},
Object {
"field": "event.severity",
"value": Array [
"3",
],
},
Object {
"field": "event.category",
"value": Array [
"Access",
],
},
Object {
"field": "event.action",
"value": Array [
"Action",
],
},
Object {
"field": "host.name",
"value": Array [
"apache",
],
},
Object {
"field": "source.ip",
"value": Array [
"192.168.0.1",
],
},
Object {
"field": "destination.ip",
"value": Array [
"192.168.0.3",
],
},
Object {
"field": "destination.bytes",
"value": Array [
"123456",
],
},
Object {
"field": "user.name",
"value": Array [
"john.dee",
],
},
]
}
ecsData={
Object {
"_id": "1",
"destination": Object {
"ip": Array [
"192.168.0.3",
],
"port": Array [
6343,
],
},
"event": Object {
"action": Array [
"Action",
],
"category": Array [
"Access",
],
"id": Array [
"1",
],
"module": Array [
"nginx",
],
"severity": Array [
3,
],
},
"geo": Object {
"country_iso_code": Array [
"xx",
],
"region_name": Array [
"xx",
],
},
"host": Object {
"ip": Array [
"192.168.0.1",
],
"name": Array [
"apache",
],
},
"source": Object {
"ip": Array [
"192.168.0.1",
],
"port": Array [
80,
],
},
"timestamp": "2018-11-05T19:03:25.937Z",
"user": Object {
"id": Array [
"1",
],
"name": Array [
"john.dee",
],
},
}
}
hasRowRenderers={false}
header={
Object {
"columnHeaderType": "not-filtered",
"id": "event.action",
"initialWidth": 180,
}
}
index={2}
key="event.action"
renderCellValue={[Function]}
timelineId="test"
/>
<TgridTdCell
_id="1"
ariaRowindex={2}
data={
Array [
Object {
"field": "@timestamp",
"value": Array [
"2018-11-05T19:03:25.937Z",
],
},
Object {
"field": "event.severity",
"value": Array [
"3",
],
},
Object {
"field": "event.category",
"value": Array [
"Access",
],
},
Object {
"field": "event.action",
"value": Array [
"Action",
],
},
Object {
"field": "host.name",
"value": Array [
"apache",
],
},
Object {
"field": "source.ip",
"value": Array [
"192.168.0.1",
],
},
Object {
"field": "destination.ip",
"value": Array [
"192.168.0.3",
],
},
Object {
"field": "destination.bytes",
"value": Array [
"123456",
],
},
Object {
"field": "user.name",
"value": Array [
"john.dee",
],
},
]
}
ecsData={
Object {
"_id": "1",
"destination": Object {
"ip": Array [
"192.168.0.3",
],
"port": Array [
6343,
],
},
"event": Object {
"action": Array [
"Action",
],
"category": Array [
"Access",
],
"id": Array [
"1",
],
"module": Array [
"nginx",
],
"severity": Array [
3,
],
},
"geo": Object {
"country_iso_code": Array [
"xx",
],
"region_name": Array [
"xx",
],
},
"host": Object {
"ip": Array [
"192.168.0.1",
],
"name": Array [
"apache",
],
},
"source": Object {
"ip": Array [
"192.168.0.1",
],
"port": Array [
80,
],
},
"timestamp": "2018-11-05T19:03:25.937Z",
"user": Object {
"id": Array [
"1",
],
"name": Array [
"john.dee",
],
},
}
}
hasRowRenderers={false}
header={
Object {
"columnHeaderType": "not-filtered",
"id": "host.name",
"initialWidth": 180,
}
}
index={3}
key="host.name"
renderCellValue={[Function]}
timelineId="test"
/>
<TgridTdCell
_id="1"
ariaRowindex={2}
data={
Array [
Object {
"field": "@timestamp",
"value": Array [
"2018-11-05T19:03:25.937Z",
],
},
Object {
"field": "event.severity",
"value": Array [
"3",
],
},
Object {
"field": "event.category",
"value": Array [
"Access",
],
},
Object {
"field": "event.action",
"value": Array [
"Action",
],
},
Object {
"field": "host.name",
"value": Array [
"apache",
],
},
Object {
"field": "source.ip",
"value": Array [
"192.168.0.1",
],
},
Object {
"field": "destination.ip",
"value": Array [
"192.168.0.3",
],
},
Object {
"field": "destination.bytes",
"value": Array [
"123456",
],
},
Object {
"field": "user.name",
"value": Array [
"john.dee",
],
},
]
}
ecsData={
Object {
"_id": "1",
"destination": Object {
"ip": Array [
"192.168.0.3",
],
"port": Array [
6343,
],
},
"event": Object {
"action": Array [
"Action",
],
"category": Array [
"Access",
],
"id": Array [
"1",
],
"module": Array [
"nginx",
],
"severity": Array [
3,
],
},
"geo": Object {
"country_iso_code": Array [
"xx",
],
"region_name": Array [
"xx",
],
},
"host": Object {
"ip": Array [
"192.168.0.1",
],
"name": Array [
"apache",
],
},
"source": Object {
"ip": Array [
"192.168.0.1",
],
"port": Array [
80,
],
},
"timestamp": "2018-11-05T19:03:25.937Z",
"user": Object {
"id": Array [
"1",
],
"name": Array [
"john.dee",
],
},
}
}
hasRowRenderers={false}
header={
Object {
"columnHeaderType": "not-filtered",
"id": "source.ip",
"initialWidth": 180,
}
}
index={4}
key="source.ip"
renderCellValue={[Function]}
timelineId="test"
/>
<TgridTdCell
_id="1"
ariaRowindex={2}
data={
Array [
Object {
"field": "@timestamp",
"value": Array [
"2018-11-05T19:03:25.937Z",
],
},
Object {
"field": "event.severity",
"value": Array [
"3",
],
},
Object {
"field": "event.category",
"value": Array [
"Access",
],
},
Object {
"field": "event.action",
"value": Array [
"Action",
],
},
Object {
"field": "host.name",
"value": Array [
"apache",
],
},
Object {
"field": "source.ip",
"value": Array [
"192.168.0.1",
],
},
Object {
"field": "destination.ip",
"value": Array [
"192.168.0.3",
],
},
Object {
"field": "destination.bytes",
"value": Array [
"123456",
],
},
Object {
"field": "user.name",
"value": Array [
"john.dee",
],
},
]
}
ecsData={
Object {
"_id": "1",
"destination": Object {
"ip": Array [
"192.168.0.3",
],
"port": Array [
6343,
],
},
"event": Object {
"action": Array [
"Action",
],
"category": Array [
"Access",
],
"id": Array [
"1",
],
"module": Array [
"nginx",
],
"severity": Array [
3,
],
},
"geo": Object {
"country_iso_code": Array [
"xx",
],
"region_name": Array [
"xx",
],
},
"host": Object {
"ip": Array [
"192.168.0.1",
],
"name": Array [
"apache",
],
},
"source": Object {
"ip": Array [
"192.168.0.1",
],
"port": Array [
80,
],
},
"timestamp": "2018-11-05T19:03:25.937Z",
"user": Object {
"id": Array [
"1",
],
"name": Array [
"john.dee",
],
},
}
}
hasRowRenderers={false}
header={
Object {
"columnHeaderType": "not-filtered",
"id": "destination.ip",
"initialWidth": 180,
}
}
index={5}
key="destination.ip"
renderCellValue={[Function]}
timelineId="test"
/>
<TgridTdCell
_id="1"
ariaRowindex={2}
data={
Array [
Object {
"field": "@timestamp",
"value": Array [
"2018-11-05T19:03:25.937Z",
],
},
Object {
"field": "event.severity",
"value": Array [
"3",
],
},
Object {
"field": "event.category",
"value": Array [
"Access",
],
},
Object {
"field": "event.action",
"value": Array [
"Action",
],
},
Object {
"field": "host.name",
"value": Array [
"apache",
],
},
Object {
"field": "source.ip",
"value": Array [
"192.168.0.1",
],
},
Object {
"field": "destination.ip",
"value": Array [
"192.168.0.3",
],
},
Object {
"field": "destination.bytes",
"value": Array [
"123456",
],
},
Object {
"field": "user.name",
"value": Array [
"john.dee",
],
},
]
}
ecsData={
Object {
"_id": "1",
"destination": Object {
"ip": Array [
"192.168.0.3",
],
"port": Array [
6343,
],
},
"event": Object {
"action": Array [
"Action",
],
"category": Array [
"Access",
],
"id": Array [
"1",
],
"module": Array [
"nginx",
],
"severity": Array [
3,
],
},
"geo": Object {
"country_iso_code": Array [
"xx",
],
"region_name": Array [
"xx",
],
},
"host": Object {
"ip": Array [
"192.168.0.1",
],
"name": Array [
"apache",
],
},
"source": Object {
"ip": Array [
"192.168.0.1",
],
"port": Array [
80,
],
},
"timestamp": "2018-11-05T19:03:25.937Z",
"user": Object {
"id": Array [
"1",
],
"name": Array [
"john.dee",
],
},
}
}
hasRowRenderers={false}
header={
Object {
"columnHeaderType": "not-filtered",
"id": "user.name",
"initialWidth": 180,
}
}
index={6}
key="user.name"
renderCellValue={[Function]}
timelineId="test"
/>
</styled.div>
`;

View file

@ -1,59 +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 { shallow } from 'enzyme';
import React from 'react';
import { defaultHeaders } from '../column_headers/default_headers';
import { DataDrivenColumns } from '.';
import { mockTimelineData } from '../../../../mock/mock_timeline_data';
import { TestCellRenderer } from '../../../../mock/cell_renderer';
window.matchMedia = jest.fn().mockImplementation((query) => {
return {
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
};
});
describe('Columns', () => {
const headersSansTimestamp = defaultHeaders.filter((h) => h.id !== '@timestamp');
test('it renders the expected columns', () => {
const wrapper = shallow(
<DataDrivenColumns
ariaRowindex={2}
id={mockTimelineData[0]._id}
actionsColumnWidth={50}
checked={false}
columnHeaders={headersSansTimestamp}
data={mockTimelineData[0].data}
ecsData={mockTimelineData[0].ecs}
hasRowRenderers={false}
renderCellValue={TestCellRenderer}
timelineId="test"
columnValues={'abc def'}
showCheckboxes={false}
selectedEventIds={{}}
loadingEventIds={[]}
onEventDetailsPanelOpened={jest.fn()}
onRowSelected={jest.fn()}
leadingControlColumns={[]}
trailingControlColumns={[]}
setEventsLoading={jest.fn()}
setEventsDeleted={jest.fn()}
/>
);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -5,396 +5,7 @@
* 2.0.
*/
import { EuiScreenReaderOnly } from '@elastic/eui';
import React, { useMemo } from 'react';
import { getOr } from 'lodash/fp';
import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '@kbn/securitysolution-t-grid';
import { OnRowSelected } from '../../types';
import {
EventsTd,
EVENTS_TD_CLASS_NAME,
EventsTdContent,
EventsTdGroupData,
EventsTdGroupActions,
} from '../../styles';
import { StatefulCell } from './stateful_cell';
import * as i18n from './translations';
import {
SetEventsDeleted,
SetEventsLoading,
TimelineTabs,
} from '../../../../../common/types/timeline';
import type {
ActionProps,
CellValueElementProps,
ColumnHeaderOptions,
ControlColumnProps,
RowCellRender,
} from '../../../../../common/types/timeline';
import type { TimelineNonEcsData } from '../../../../../common/search_strategy';
import { ARIA_COLUMN_INDEX_OFFSET } from '../../helpers';
import type { Ecs } from '../../../../../common/ecs';
interface CellProps {
_id: string;
ariaRowindex: number;
index: number;
header: ColumnHeaderOptions;
data: TimelineNonEcsData[];
ecsData: Ecs;
hasRowRenderers: boolean;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
tabType?: TimelineTabs;
timelineId: string;
}
interface DataDrivenColumnProps {
id: string;
actionsColumnWidth: number;
ariaRowindex: number;
checked: boolean;
columnHeaders: ColumnHeaderOptions[];
columnValues: string;
data: TimelineNonEcsData[];
ecsData: Ecs;
isEventViewer?: boolean;
loadingEventIds: Readonly<string[]>;
onEventDetailsPanelOpened: () => void;
onRowSelected: OnRowSelected;
onRuleChange?: () => void;
hasRowRenderers: boolean;
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
tabType?: TimelineTabs;
timelineId: string;
trailingControlColumns: ControlColumnProps[];
leadingControlColumns: ControlColumnProps[];
setEventsLoading: SetEventsLoading;
setEventsDeleted: SetEventsDeleted;
}
const SPACE = ' ';
export const shouldForwardKeyDownEvent = (key: string): boolean => {
switch (key) {
case SPACE: // fall through
case 'Enter':
return true;
default:
return false;
}
};
export const onKeyDown = (keyboardEvent: React.KeyboardEvent) => {
const { altKey, ctrlKey, key, metaKey, shiftKey, target, type } = keyboardEvent;
const targetElement = target as Element;
// we *only* forward the event to the (child) draggable keyboard wrapper
// if the keyboard event originated from the container (TD) element
if (shouldForwardKeyDownEvent(key) && targetElement.className?.includes(EVENTS_TD_CLASS_NAME)) {
const draggableKeyboardWrapper = targetElement.querySelector<HTMLDivElement>(
`.${DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME}`
);
const newEvent = new KeyboardEvent(type, {
altKey,
bubbles: true,
cancelable: true,
ctrlKey,
key,
metaKey,
shiftKey,
});
if (key === ' ') {
// prevent the default behavior of scrolling the table when space is pressed
keyboardEvent.preventDefault();
}
draggableKeyboardWrapper?.dispatchEvent(newEvent);
}
};
const TgridActionTdCell = ({
action: Action,
width,
actionsColumnWidth,
ariaRowindex,
columnId,
columnValues,
data,
ecsData,
eventIdToNoteIds,
index,
isEventPinned,
isEventViewer,
eventId,
loadingEventIds,
onEventDetailsPanelOpened,
onRowSelected,
rowIndex,
hasRowRenderers,
onRuleChange,
selectedEventIds = {},
showCheckboxes,
showNotes = false,
tabType,
timelineId,
toggleShowNotes,
setEventsLoading,
setEventsDeleted,
}: ActionProps & {
columnId: string;
hasRowRenderers: boolean;
actionsColumnWidth: number;
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
}) => {
const displayWidth = width ? width : actionsColumnWidth;
return (
<EventsTdGroupActions
width={displayWidth}
data-test-subj="event-actions-container"
tabIndex={0}
>
<EventsTd
$ariaColumnIndex={index + ARIA_COLUMN_INDEX_OFFSET}
key={tabType != null ? `${eventId}_${tabType}` : `${eventId}`}
onKeyDown={onKeyDown}
role="button"
tabIndex={0}
width={width}
>
<EventsTdContent data-test-subj="cell-container">
<>
<EuiScreenReaderOnly data-test-subj="screenReaderOnly">
<p>{i18n.YOU_ARE_IN_A_TABLE_CELL({ row: ariaRowindex, column: index + 2 })}</p>
</EuiScreenReaderOnly>
{Action && (
<Action
ariaRowindex={ariaRowindex}
width={width}
checked={Object.keys(selectedEventIds).includes(eventId)}
columnId={columnId}
columnValues={columnValues}
eventId={eventId}
data={data}
ecsData={ecsData}
eventIdToNoteIds={eventIdToNoteIds}
index={index}
isEventPinned={isEventPinned}
isEventViewer={isEventViewer}
loadingEventIds={loadingEventIds}
onEventDetailsPanelOpened={onEventDetailsPanelOpened}
onRowSelected={onRowSelected}
rowIndex={rowIndex}
onRuleChange={onRuleChange}
showCheckboxes={showCheckboxes}
showNotes={showNotes}
timelineId={timelineId}
toggleShowNotes={toggleShowNotes}
setEventsLoading={setEventsLoading}
setEventsDeleted={setEventsDeleted}
/>
)}
</>
</EventsTdContent>
{hasRowRenderers ? (
<EuiScreenReaderOnly data-test-subj="hasRowRendererScreenReaderOnly">
<p>{i18n.EVENT_HAS_AN_EVENT_RENDERER(ariaRowindex)}</p>
</EuiScreenReaderOnly>
) : null}
</EventsTd>
</EventsTdGroupActions>
);
};
const TgridTdCell = ({
_id,
ariaRowindex,
index,
header,
data,
ecsData,
hasRowRenderers,
renderCellValue,
tabType,
timelineId,
}: CellProps) => {
const ariaColIndex = index + ARIA_COLUMN_INDEX_OFFSET;
return (
<EventsTd
$ariaColumnIndex={ariaColIndex}
key={tabType != null ? `${header.id}_${tabType}` : `${header.id}`}
onKeyDown={onKeyDown}
role="button"
tabIndex={0}
width={header.initialWidth}
>
<EventsTdContent data-test-subj="cell-container">
<>
<EuiScreenReaderOnly data-test-subj="screenReaderOnly">
<p>{i18n.YOU_ARE_IN_A_TABLE_CELL({ row: ariaRowindex, column: ariaColIndex })}</p>
</EuiScreenReaderOnly>
<StatefulCell
rowIndex={ariaRowindex - 1}
colIndex={ariaColIndex - 1}
data={data}
header={header}
eventId={_id}
linkValues={getOr([], header.linkField ?? '', ecsData)}
renderCellValue={renderCellValue}
tabType={tabType}
timelineId={timelineId}
/>
</>
</EventsTdContent>
{hasRowRenderers ? (
<EuiScreenReaderOnly data-test-subj="hasRowRendererScreenReaderOnly">
<p>{i18n.EVENT_HAS_AN_EVENT_RENDERER(ariaRowindex)}</p>
</EuiScreenReaderOnly>
) : null}
</EventsTd>
);
};
export const DataDrivenColumns = React.memo<DataDrivenColumnProps>(
({
ariaRowindex,
actionsColumnWidth,
columnHeaders,
columnValues,
data,
ecsData,
isEventViewer,
id: _id,
loadingEventIds,
onEventDetailsPanelOpened,
onRowSelected,
hasRowRenderers,
onRuleChange,
renderCellValue,
selectedEventIds = {},
showCheckboxes,
tabType,
timelineId,
trailingControlColumns,
leadingControlColumns,
setEventsLoading,
setEventsDeleted,
}) => {
const trailingActionCells = useMemo(
() =>
trailingControlColumns ? trailingControlColumns.map((column) => column.rowCellRender) : [],
[trailingControlColumns]
);
const leadingAndDataColumnCount = useMemo(
() => leadingControlColumns.length + columnHeaders.length,
[leadingControlColumns, columnHeaders]
);
const TrailingActions = useMemo(
() =>
trailingActionCells.map((Action: RowCellRender | undefined, index) => {
return (
Action && (
<TgridActionTdCell
action={Action}
width={trailingControlColumns[index].width}
actionsColumnWidth={actionsColumnWidth}
ariaRowindex={ariaRowindex}
checked={Object.keys(selectedEventIds).includes(_id)}
columnId={trailingControlColumns[index].id || ''}
columnValues={columnValues}
onRowSelected={onRowSelected}
data-test-subj="actions"
eventId={_id}
data={data}
key={index}
index={leadingAndDataColumnCount + index}
rowIndex={ariaRowindex}
ecsData={ecsData}
loadingEventIds={loadingEventIds}
onEventDetailsPanelOpened={onEventDetailsPanelOpened}
showCheckboxes={showCheckboxes}
isEventViewer={isEventViewer}
hasRowRenderers={hasRowRenderers}
onRuleChange={onRuleChange}
selectedEventIds={selectedEventIds}
tabType={tabType}
timelineId={timelineId}
setEventsLoading={setEventsLoading}
setEventsDeleted={setEventsDeleted}
/>
)
);
}),
[
trailingControlColumns,
_id,
data,
ecsData,
onRowSelected,
isEventViewer,
actionsColumnWidth,
ariaRowindex,
columnValues,
hasRowRenderers,
leadingAndDataColumnCount,
loadingEventIds,
onEventDetailsPanelOpened,
onRuleChange,
selectedEventIds,
showCheckboxes,
tabType,
timelineId,
trailingActionCells,
setEventsLoading,
setEventsDeleted,
]
);
const ColumnHeaders = useMemo(
() =>
columnHeaders.map((header, index) => (
<TgridTdCell
_id={_id}
index={index}
header={header}
key={tabType != null ? `${header.id}_${tabType}` : `${header.id}`}
ariaRowindex={ariaRowindex}
data={data}
ecsData={ecsData}
hasRowRenderers={hasRowRenderers}
renderCellValue={renderCellValue}
tabType={tabType}
timelineId={timelineId}
/>
)),
[
_id,
ariaRowindex,
columnHeaders,
data,
ecsData,
hasRowRenderers,
renderCellValue,
tabType,
timelineId,
]
);
return (
<EventsTdGroupData data-test-subj="data-driven-columns">
{ColumnHeaders}
{TrailingActions}
</EventsTdGroupData>
);
}
);
DataDrivenColumns.displayName = 'DataDrivenColumns';
export const getMappedNonEcsValue = ({
data,

View file

@ -1,180 +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 { mount } from 'enzyme';
import { cloneDeep } from 'lodash/fp';
import React, { useEffect } from 'react';
import { StatefulCell } from './stateful_cell';
import { getMappedNonEcsValue } from '.';
import { defaultHeaders } from '../../../../mock/header';
import {
CellValueElementProps,
ColumnHeaderOptions,
TimelineTabs,
} from '../../../../../common/types/timeline';
import { TimelineNonEcsData } from '../../../../../common/search_strategy';
import { mockTimelineData } from '../../../../mock/mock_timeline_data';
/**
* This (test) component implement's `EuiDataGrid`'s `renderCellValue` interface,
* as documented here: https://elastic.github.io/eui/#/tabular-content/data-grid
*
* Its `CellValueElementProps` props are a superset of `EuiDataGridCellValueElementProps`.
* The `setCellProps` function, defined by the `EuiDataGridCellValueElementProps` interface,
* is typically called in a `useEffect`, as illustrated by `EuiDataGrid`'s code sandbox example:
* https://codesandbox.io/s/zhxmo
*/
const RenderCellValue: React.FC<CellValueElementProps> = ({ columnId, data, setCellProps }) => {
useEffect(() => {
// branching logic that conditionally renders a specific cell green:
if (columnId === defaultHeaders[0].id) {
const value = getMappedNonEcsValue({
data,
fieldName: columnId,
});
if (value?.length) {
setCellProps({
style: {
backgroundColor: 'green',
},
});
}
}
}, [columnId, data, setCellProps]);
return (
<div data-test-subj="renderCellValue">
{getMappedNonEcsValue({
data,
fieldName: columnId,
})}
</div>
);
};
describe('StatefulCell', () => {
const rowIndex = 123;
const colIndex = 0;
const eventId = '_id-123';
const linkValues = ['foo', 'bar', '@baz'];
const tabType = TimelineTabs.query;
const timelineId = 'test';
let header: ColumnHeaderOptions;
let data: TimelineNonEcsData[];
beforeEach(() => {
data = cloneDeep(mockTimelineData[0].data);
header = cloneDeep(defaultHeaders[0]);
});
test('it invokes renderCellValue with the expected arguments when tabType is specified', () => {
const renderCellValue = jest.fn();
mount(
<StatefulCell
rowIndex={rowIndex}
colIndex={colIndex}
data={data}
header={header}
eventId={eventId}
linkValues={linkValues}
renderCellValue={renderCellValue}
tabType={TimelineTabs.query}
timelineId={timelineId}
/>
);
expect(renderCellValue).toBeCalledWith(
expect.objectContaining({
columnId: header.id,
eventId,
data,
header,
isExpandable: true,
isExpanded: false,
isDetails: false,
linkValues,
rowIndex,
colIndex,
timelineId: `${timelineId}-${tabType}`,
})
);
});
test('it invokes renderCellValue with the expected arguments when tabType is NOT specified', () => {
const renderCellValue = jest.fn();
mount(
<StatefulCell
rowIndex={rowIndex}
colIndex={colIndex}
data={data}
header={header}
eventId={eventId}
linkValues={linkValues}
renderCellValue={renderCellValue}
timelineId={timelineId}
/>
);
expect(renderCellValue).toBeCalledWith(
expect.objectContaining({
columnId: header.id,
eventId,
data,
header,
isExpandable: true,
isExpanded: false,
isDetails: false,
linkValues,
rowIndex,
colIndex,
timelineId,
})
);
});
test('it renders the React.Node returned by renderCellValue', () => {
const renderCellValue = () => <div data-test-subj="renderCellValue" />;
const wrapper = mount(
<StatefulCell
rowIndex={rowIndex}
colIndex={colIndex}
data={data}
header={header}
eventId={eventId}
linkValues={linkValues}
renderCellValue={renderCellValue}
timelineId={timelineId}
/>
);
expect(wrapper.find('[data-test-subj="renderCellValue"]').exists()).toBe(true);
});
test("it renders a div with the styles set by `renderCellValue`'s `setCellProps` argument", () => {
const wrapper = mount(
<StatefulCell
rowIndex={rowIndex}
colIndex={colIndex}
data={data}
header={header}
eventId={eventId}
linkValues={linkValues}
renderCellValue={RenderCellValue}
timelineId={timelineId}
/>
);
expect(
wrapper.find('[data-test-subj="statefulCell"]').getDOMNode().getAttribute('style')
).toEqual('background-color: green;');
});
});

View file

@ -1,68 +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, { HTMLAttributes, useState } from 'react';
import type { TimelineNonEcsData } from '../../../../../common/search_strategy';
import { TimelineTabs } from '../../../../../common/types/timeline';
import type {
CellValueElementProps,
ColumnHeaderOptions,
} from '../../../../../common/types/timeline';
export interface CommonProps {
className?: string;
'aria-label'?: string;
'data-test-subj'?: string;
}
const StatefulCellComponent = ({
rowIndex,
colIndex,
data,
header,
eventId,
linkValues,
renderCellValue,
tabType,
timelineId,
}: {
rowIndex: number;
colIndex: number;
data: TimelineNonEcsData[];
header: ColumnHeaderOptions;
eventId: string;
linkValues: string[] | undefined;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
tabType?: TimelineTabs;
timelineId: string;
}) => {
const [cellProps, setCellProps] = useState<CommonProps & HTMLAttributes<HTMLDivElement>>({});
return (
<div data-test-subj="statefulCell" {...cellProps}>
{renderCellValue({
columnId: header.id,
eventId,
data,
header,
isDraggable: true,
isExpandable: true,
isExpanded: false,
isDetails: false,
linkValues,
rowIndex,
colIndex,
setCellProps,
timelineId: tabType != null ? `${timelineId}-${tabType}` : timelineId,
})}
</div>
);
};
StatefulCellComponent.displayName = 'StatefulCellComponent';
export const StatefulCell = React.memo(StatefulCellComponent);

View file

@ -1,28 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const YOU_ARE_IN_A_TABLE_CELL = ({ column, row }: { column: number; row: number }) =>
i18n.translate('xpack.timelines.timeline.youAreInATableCellScreenReaderOnly', {
values: { column, row },
defaultMessage: 'You are in a table cell. row: {row}, column: {column}',
});
export const EVENT_HAS_AN_EVENT_RENDERER = (row: number) =>
i18n.translate('xpack.timelines.timeline.eventHasEventRendererScreenReaderOnly', {
values: { row },
defaultMessage:
'The event in row {row} has an event renderer. Press shift + down arrow to focus it.',
});
export const EVENT_HAS_NOTES = ({ notesCount, row }: { notesCount: number; row: number }) =>
i18n.translate('xpack.timelines.timeline.eventHasNotesScreenReaderOnly', {
values: { notesCount, row },
defaultMessage:
'The event in row {row} has {notesCount, plural, =1 {a note} other {{notesCount} notes}}. Press shift + right arrow to focus notes.',
});

View file

@ -1,118 +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 { mount } from 'enzyme';
import React from 'react';
import { getActionsColumnWidth } from '../column_headers/helpers';
import { EventColumnView } from './event_column_view';
import { TestCellRenderer } from '../../../../mock/cell_renderer';
import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline';
import { TestProviders } from '../../../../mock/test_providers';
import { testLeadingControlColumn } from '../../../../mock/mock_timeline_control_columns';
import { mockGlobalState } from '../../../../mock/global_state';
jest.mock('../../../../hooks/use_selector', () => ({
useShallowEqualSelector: () => mockGlobalState.timelineById.test,
useDeepEqualSelector: () => mockGlobalState.timelineById.test,
}));
describe('EventColumnView', () => {
const ACTION_BUTTON_COUNT = 4;
const props = {
ariaRowindex: 2,
id: 'event-id',
actionsColumnWidth: getActionsColumnWidth(ACTION_BUTTON_COUNT),
associateNote: jest.fn(),
columnHeaders: [],
columnRenderers: [],
data: [
{
field: 'host.name',
},
],
ecsData: {
_id: 'id',
},
eventIdToNoteIds: {},
expanded: false,
hasRowRenderers: false,
loading: false,
loadingEventIds: [],
notesCount: 0,
onEventDetailsPanelOpened: jest.fn(),
onPinEvent: jest.fn(),
onRowSelected: jest.fn(),
onUnPinEvent: jest.fn(),
refetch: jest.fn(),
renderCellValue: TestCellRenderer,
selectedEventIds: {},
showCheckboxes: false,
showNotes: false,
tabType: TimelineTabs.query,
timelineId: TimelineId.active,
toggleShowNotes: jest.fn(),
updateNote: jest.fn(),
isEventPinned: false,
leadingControlColumns: [],
trailingControlColumns: [],
setEventsLoading: jest.fn(),
setEventsDeleted: jest.fn(),
};
// TODO: next 3 tests will be re-enabled in the future.
test.skip('it render AddToCaseAction if timelineId === TimelineId.detectionsPage', () => {
const wrapper = mount(<EventColumnView {...props} timelineId={TimelineId.detectionsPage} />, {
wrappingComponent: TestProviders,
});
expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeTruthy();
});
test.skip('it render AddToCaseAction if timelineId === TimelineId.detectionsRulesDetailsPage', () => {
const wrapper = mount(
<EventColumnView {...props} timelineId={TimelineId.detectionsRulesDetailsPage} />,
{
wrappingComponent: TestProviders,
}
);
expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeTruthy();
});
test.skip('it render AddToCaseAction if timelineId === TimelineId.active', () => {
const wrapper = mount(<EventColumnView {...props} timelineId={TimelineId.active} />, {
wrappingComponent: TestProviders,
});
expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeTruthy();
});
test.skip('it does NOT render AddToCaseAction when timelineId is not in the allowed list', () => {
const wrapper = mount(<EventColumnView {...props} timelineId="timeline-test" />, {
wrappingComponent: TestProviders,
});
expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeFalsy();
});
test('it renders a custom control column in addition to the default control column', () => {
const wrapper = mount(
<EventColumnView
{...props}
timelineId="timeline-test"
leadingControlColumns={[testLeadingControlColumn]}
/>,
{
wrappingComponent: TestProviders,
}
);
expect(wrapper.find('[data-test-subj="test-body-control-column-cell"]').exists()).toBeTruthy();
});
});

View file

@ -1,192 +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 type { OnRowSelected } from '../../types';
import { EventsTrData, EventsTdGroupActions } from '../../styles';
import { DataDrivenColumns, getMappedNonEcsValue } from '../data_driven_columns';
import { TimelineTabs } from '../../../../../common/types/timeline';
import type {
CellValueElementProps,
ColumnHeaderOptions,
ControlColumnProps,
RowCellRender,
SetEventsDeleted,
SetEventsLoading,
} from '../../../../../common/types/timeline';
import type { TimelineNonEcsData } from '../../../../../common/search_strategy';
import type { Ecs } from '../../../../../common/ecs';
interface Props {
id: string;
actionsColumnWidth: number;
ariaRowindex: number;
columnHeaders: ColumnHeaderOptions[];
data: TimelineNonEcsData[];
ecsData: Ecs;
isEventViewer?: boolean;
loadingEventIds: Readonly<string[]>;
onEventDetailsPanelOpened: () => void;
onRowSelected: OnRowSelected;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
onRuleChange?: () => void;
hasRowRenderers: boolean;
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
tabType?: TimelineTabs;
timelineId: string;
leadingControlColumns: ControlColumnProps[];
trailingControlColumns: ControlColumnProps[];
setEventsLoading: SetEventsLoading;
setEventsDeleted: SetEventsDeleted;
}
export const EventColumnView = React.memo<Props>(
({
id,
actionsColumnWidth,
ariaRowindex,
columnHeaders,
data,
ecsData,
isEventViewer = false,
loadingEventIds,
onEventDetailsPanelOpened,
onRowSelected,
hasRowRenderers,
onRuleChange,
renderCellValue,
selectedEventIds = {},
showCheckboxes,
tabType,
timelineId,
leadingControlColumns,
trailingControlColumns,
setEventsLoading,
setEventsDeleted,
}) => {
// Each action button shall announce itself to screen readers via an `aria-label`
// in the following format:
// "button description, for the event in row {ariaRowindex}, with columns {columnValues}",
// so we combine the column values here:
const columnValues = useMemo(
() =>
columnHeaders
.map(
(header) =>
getMappedNonEcsValue({
data,
fieldName: header.id,
}) ?? []
)
.join(' '),
[columnHeaders, data]
);
const leadingActionCells = useMemo(
() =>
leadingControlColumns ? leadingControlColumns.map((column) => column.rowCellRender) : [],
[leadingControlColumns]
);
const LeadingActions = useMemo(
() =>
leadingActionCells.map((Action: RowCellRender | undefined, index) => {
const width = leadingControlColumns[index].width
? leadingControlColumns[index].width
: actionsColumnWidth;
return (
<EventsTdGroupActions
width={width}
data-test-subj="event-actions-container"
tabIndex={0}
key={index}
>
{Action && (
<Action
width={width}
rowIndex={ariaRowindex}
ariaRowindex={ariaRowindex}
checked={Object.keys(selectedEventIds).includes(id)}
columnId={leadingControlColumns[index].id || ''}
columnValues={columnValues}
onRowSelected={onRowSelected}
data-test-subj="actions"
eventId={id}
data={data}
index={index}
ecsData={ecsData}
loadingEventIds={loadingEventIds}
onEventDetailsPanelOpened={onEventDetailsPanelOpened}
showCheckboxes={showCheckboxes}
isEventViewer={isEventViewer}
onRuleChange={onRuleChange}
tabType={tabType}
timelineId={timelineId}
setEventsLoading={setEventsLoading}
setEventsDeleted={setEventsDeleted}
/>
)}
</EventsTdGroupActions>
);
}),
[
actionsColumnWidth,
ariaRowindex,
columnValues,
data,
ecsData,
id,
isEventViewer,
leadingActionCells,
leadingControlColumns,
loadingEventIds,
onEventDetailsPanelOpened,
onRowSelected,
onRuleChange,
selectedEventIds,
showCheckboxes,
tabType,
timelineId,
setEventsLoading,
setEventsDeleted,
]
);
return (
<EventsTrData data-test-subj="event-column-view">
{LeadingActions}
<DataDrivenColumns
id={id}
actionsColumnWidth={actionsColumnWidth}
ariaRowindex={ariaRowindex}
columnHeaders={columnHeaders}
data={data}
ecsData={ecsData}
hasRowRenderers={hasRowRenderers}
renderCellValue={renderCellValue}
tabType={tabType}
timelineId={timelineId}
trailingControlColumns={trailingControlColumns}
leadingControlColumns={leadingControlColumns}
checked={Object.keys(selectedEventIds).includes(id)}
columnValues={columnValues}
onRowSelected={onRowSelected}
data-test-subj="actions"
loadingEventIds={loadingEventIds}
onEventDetailsPanelOpened={onEventDetailsPanelOpened}
showCheckboxes={showCheckboxes}
isEventViewer={isEventViewer}
onRuleChange={onRuleChange}
selectedEventIds={selectedEventIds}
setEventsLoading={setEventsLoading}
setEventsDeleted={setEventsDeleted}
/>
</EventsTrData>
);
}
);
EventColumnView.displayName = 'EventColumnView';

View file

@ -1,99 +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 from 'react';
import { isEmpty } from 'lodash';
import { EventsTbody } from '../../styles';
import { StatefulEvent } from './stateful_event';
import type { BrowserFields } from '../../../../../common/search_strategy/index_fields';
import { TimelineTabs } from '../../../../../common/types/timeline';
import type {
CellValueElementProps,
ColumnHeaderOptions,
ControlColumnProps,
OnRowSelected,
RowRenderer,
} from '../../../../../common/types/timeline';
import { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy';
/** This offset begins at two, because the header row counts as "row 1", and aria-rowindex starts at "1" */
const ARIA_ROW_INDEX_OFFSET = 2;
interface Props {
actionsColumnWidth: number;
browserFields: BrowserFields;
columnHeaders: ColumnHeaderOptions[];
containerRef: React.MutableRefObject<HTMLDivElement | null>;
data: TimelineItem[];
id: string;
isEventViewer?: boolean;
lastFocusedAriaColindex: number;
loadingEventIds: Readonly<string[]>;
onRowSelected: OnRowSelected;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
onRuleChange?: () => void;
rowRenderers: RowRenderer[];
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
tabType?: TimelineTabs;
leadingControlColumns: ControlColumnProps[];
trailingControlColumns: ControlColumnProps[];
}
const EventsComponent: React.FC<Props> = ({
actionsColumnWidth,
browserFields,
columnHeaders,
containerRef,
data,
id,
isEventViewer = false,
lastFocusedAriaColindex,
loadingEventIds,
onRowSelected,
onRuleChange,
renderCellValue,
rowRenderers,
selectedEventIds,
showCheckboxes,
tabType,
leadingControlColumns,
trailingControlColumns,
}) => (
<EventsTbody data-test-subj="events">
{data.map((event, i) => (
<StatefulEvent
actionsColumnWidth={actionsColumnWidth}
ariaRowindex={i + ARIA_ROW_INDEX_OFFSET}
browserFields={browserFields}
columnHeaders={columnHeaders}
containerRef={containerRef}
event={event}
isEventViewer={isEventViewer}
key={`${id}_${tabType}_${event._id}_${event._index}_${
!isEmpty(event.ecs.eql?.sequenceNumber) ? event.ecs.eql?.sequenceNumber : ''
}`}
lastFocusedAriaColindex={lastFocusedAriaColindex}
loadingEventIds={loadingEventIds}
onRowSelected={onRowSelected}
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
onRuleChange={onRuleChange}
selectedEventIds={selectedEventIds}
showCheckboxes={showCheckboxes}
tabType={tabType}
timelineId={id}
leadingControlColumns={leadingControlColumns}
trailingControlColumns={trailingControlColumns}
/>
))}
</EventsTbody>
);
export const Events = React.memo(EventsComponent);

View file

@ -1,218 +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, { useCallback, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../../helpers';
import { EventsTrGroup, EventsTrSupplement } from '../../styles';
import type { OnRowSelected } from '../../types';
import { isEventBuildingBlockType, getEventType, isEvenEqlSequence } from '../helpers';
import { EventColumnView } from './event_column_view';
import { getRowRenderer } from '../renderers/get_row_renderer';
import { StatefulRowRenderer } from './stateful_row_renderer';
import { getMappedNonEcsValue } from '../data_driven_columns';
import { StatefulEventContext } from './stateful_event_context';
import type { BrowserFields } from '../../../../../common/search_strategy/index_fields';
import {
SetEventsDeleted,
SetEventsLoading,
TimelineTabs,
} from '../../../../../common/types/timeline';
import type {
CellValueElementProps,
ColumnHeaderOptions,
ControlColumnProps,
RowRenderer,
TimelineExpandedDetailType,
} from '../../../../../common/types/timeline';
import type { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy';
import { tGridActions, tGridSelectors } from '../../../../store/t_grid';
import { useDeepEqualSelector } from '../../../../hooks/use_selector';
interface Props {
actionsColumnWidth: number;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
browserFields: BrowserFields;
columnHeaders: ColumnHeaderOptions[];
event: TimelineItem;
isEventViewer?: boolean;
lastFocusedAriaColindex: number;
loadingEventIds: Readonly<string[]>;
onRowSelected: OnRowSelected;
ariaRowindex: number;
onRuleChange?: () => void;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
tabType?: TimelineTabs;
timelineId: string;
leadingControlColumns: ControlColumnProps[];
trailingControlColumns: ControlColumnProps[];
}
const StatefulEventComponent: React.FC<Props> = ({
actionsColumnWidth,
browserFields,
containerRef,
columnHeaders,
event,
isEventViewer = false,
lastFocusedAriaColindex,
loadingEventIds,
onRowSelected,
renderCellValue,
rowRenderers,
onRuleChange,
ariaRowindex,
selectedEventIds,
showCheckboxes,
tabType,
timelineId,
leadingControlColumns,
trailingControlColumns,
}) => {
const trGroupRef = useRef<HTMLDivElement | null>(null);
const dispatch = useDispatch();
// Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created
const [activeStatefulEventContext] = useState({ timelineID: timelineId, tabType });
const getTGrid = useMemo(() => tGridSelectors.getTGridByIdSelector(), []);
const expandedDetail = useDeepEqualSelector(
(state) => getTGrid(state, timelineId).expandedDetail ?? {}
);
const hostName = useMemo(() => {
const hostNameArr = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.name' });
return hostNameArr && hostNameArr.length > 0 ? hostNameArr[0] : null;
}, [event?.data]);
const hostIPAddresses = useMemo(() => {
const hostIpList = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.ip' }) ?? [];
const sourceIpList = getMappedNonEcsValue({ data: event?.data, fieldName: 'source.ip' }) ?? [];
const destinationIpList =
getMappedNonEcsValue({
data: event?.data,
fieldName: 'destination.ip',
}) ?? [];
return new Set([...hostIpList, ...sourceIpList, ...destinationIpList]);
}, [event?.data]);
const activeTab = tabType ?? TimelineTabs.query;
const activeExpandedDetail = expandedDetail[activeTab];
const isDetailPanelExpanded: boolean =
(activeExpandedDetail?.panelView === 'eventDetail' &&
activeExpandedDetail?.params?.eventId === event._id) ||
(activeExpandedDetail?.panelView === 'hostDetail' &&
activeExpandedDetail?.params?.hostName === hostName) ||
(activeExpandedDetail?.panelView === 'networkDetail' &&
activeExpandedDetail?.params?.ip &&
hostIPAddresses?.has(activeExpandedDetail?.params?.ip)) ||
false;
const hasRowRenderers: boolean = useMemo(
() => getRowRenderer(event.ecs, rowRenderers) != null,
[event.ecs, rowRenderers]
);
const handleOnEventDetailPanelOpened = useCallback(() => {
const eventId = event._id;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const indexName = event._index!;
const updatedExpandedDetail: TimelineExpandedDetailType = {
panelView: 'eventDetail',
params: {
eventId,
indexName,
},
};
dispatch(
tGridActions.toggleDetailPanel({
...updatedExpandedDetail,
tabType,
timelineId,
})
);
}, [dispatch, event._id, event._index, tabType, timelineId]);
const setEventsLoading = useCallback<SetEventsLoading>(
({ eventIds, isLoading }) => {
dispatch(tGridActions.setEventsLoading({ id: timelineId, eventIds, isLoading }));
},
[dispatch, timelineId]
);
const setEventsDeleted = useCallback<SetEventsDeleted>(
({ eventIds, isDeleted }) => {
dispatch(tGridActions.setEventsDeleted({ id: timelineId, eventIds, isDeleted }));
},
[dispatch, timelineId]
);
const RowRendererContent = useMemo(
() => (
<EventsTrSupplement>
<StatefulRowRenderer
ariaRowindex={ariaRowindex}
containerRef={containerRef}
event={event}
lastFocusedAriaColindex={lastFocusedAriaColindex}
rowRenderers={rowRenderers}
timelineId={timelineId}
/>
</EventsTrSupplement>
),
[ariaRowindex, containerRef, event, lastFocusedAriaColindex, rowRenderers, timelineId]
);
return (
<StatefulEventContext.Provider value={activeStatefulEventContext}>
<EventsTrGroup
$ariaRowindex={ariaRowindex}
className={STATEFUL_EVENT_CSS_CLASS_NAME}
data-test-subj="event"
eventType={getEventType(event.ecs)}
isBuildingBlockType={isEventBuildingBlockType(event.ecs)}
isEvenEqlSequence={isEvenEqlSequence(event.ecs)}
isExpanded={isDetailPanelExpanded}
ref={trGroupRef}
showLeftBorder={!isEventViewer}
>
<EventColumnView
id={event._id}
actionsColumnWidth={actionsColumnWidth}
ariaRowindex={ariaRowindex}
columnHeaders={columnHeaders}
data={event.data}
ecsData={event.ecs}
hasRowRenderers={hasRowRenderers}
isEventViewer={isEventViewer}
loadingEventIds={loadingEventIds}
onEventDetailsPanelOpened={handleOnEventDetailPanelOpened}
onRowSelected={onRowSelected}
renderCellValue={renderCellValue}
onRuleChange={onRuleChange}
selectedEventIds={selectedEventIds}
showCheckboxes={showCheckboxes}
tabType={tabType}
timelineId={timelineId}
leadingControlColumns={leadingControlColumns}
trailingControlColumns={trailingControlColumns}
setEventsLoading={setEventsLoading}
setEventsDeleted={setEventsDeleted}
/>
<div>{RowRendererContent}</div>
</EventsTrGroup>
</StatefulEventContext.Provider>
);
};
export const StatefulEvent = React.memo(StatefulEventComponent);

View file

@ -8,13 +8,11 @@
import { omit } from 'lodash/fp';
import { ColumnHeaderOptions } from '../../../../common/types';
import { Ecs } from '../../../../common/ecs';
import {
allowSorting,
hasCellActions,
mapSortDirectionToDirection,
mapSortingColumns,
stringifyEvent,
addBuildingBlockStyle,
} from './helpers';
@ -22,173 +20,6 @@ import { euiThemeVars } from '@kbn/ui-theme';
import { mockDnsEvent } from '../../../mock';
describe('helpers', () => {
describe('stringifyEvent', () => {
test('it omits __typename when it appears at arbitrary levels', () => {
const toStringify: Ecs = {
__typename: 'level 0',
_id: '4',
timestamp: '2018-11-08T19:03:25.937Z',
host: {
__typename: 'level 1',
name: ['suricata'],
ip: ['192.168.0.1'],
},
event: {
id: ['4'],
category: ['Attempted Administrator Privilege Gain'],
type: ['Alert'],
module: ['suricata'],
severity: [1],
},
source: {
ip: ['192.168.0.3'],
port: [53],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
suricata: {
eve: {
flow_id: [4],
proto: [''],
alert: {
signature: ['ET PHONE HOME Stack Overflow (CVE-2019-90210)'],
signature_id: [4],
__typename: 'level 2',
},
},
},
user: {
id: ['4'],
name: ['jack.black'],
},
geo: {
region_name: ['neither'],
country_iso_code: ['sasquatch'],
},
} as Ecs; // as cast so that `__typename` can be added for the tests even though it is not part of ECS
const expected: Ecs = {
_id: '4',
timestamp: '2018-11-08T19:03:25.937Z',
host: {
name: ['suricata'],
ip: ['192.168.0.1'],
},
event: {
id: ['4'],
category: ['Attempted Administrator Privilege Gain'],
type: ['Alert'],
module: ['suricata'],
severity: [1],
},
source: {
ip: ['192.168.0.3'],
port: [53],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
suricata: {
eve: {
flow_id: [4],
proto: [''],
alert: {
signature: ['ET PHONE HOME Stack Overflow (CVE-2019-90210)'],
signature_id: [4],
},
},
},
user: {
id: ['4'],
name: ['jack.black'],
},
geo: {
region_name: ['neither'],
country_iso_code: ['sasquatch'],
},
};
expect(JSON.parse(stringifyEvent(toStringify))).toEqual(expected);
});
test('it omits null and undefined values at arbitrary levels, for arbitrary data types', () => {
const expected: Ecs = {
_id: '4',
host: {},
event: {
id: ['4'],
category: ['theory'],
type: ['Alert'],
module: ['me'],
severity: [1],
},
source: {
port: [53],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
suricata: {
eve: {
flow_id: [4],
proto: [''],
alert: {
signature: ['dance moves'],
},
},
},
user: {
id: ['4'],
name: ['no use for a'],
},
geo: {
region_name: ['bizzaro'],
country_iso_code: ['world'],
},
};
const toStringify: Ecs = {
_id: '4',
host: {},
event: {
id: ['4'],
category: ['theory'],
type: ['Alert'],
module: ['me'],
severity: [1],
},
source: {
ip: undefined,
port: [53],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
suricata: {
eve: {
flow_id: [4],
proto: [''],
alert: {
signature: ['dance moves'],
signature_id: undefined,
},
},
},
user: {
id: ['4'],
name: ['no use for a'],
},
geo: {
region_name: ['bizzaro'],
country_iso_code: ['world'],
},
};
expect(JSON.parse(stringifyEvent(toStringify))).toEqual(expected);
});
});
describe('mapSortDirectionToDirection', () => {
test('it returns the expected direction when sortDirection is `asc`', () => {
expect(mapSortDirectionToDirection('asc')).toBe('asc');

View file

@ -20,15 +20,8 @@ import type {
ColumnHeaderOptions,
SortColumnTimeline,
SortDirection,
TimelineEventsType,
} from '../../../../common/types/timeline';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const omitTypenameAndEmpty = (k: string, v: any): any | undefined =>
k !== '__typename' && v != null ? v : undefined;
export const stringifyEvent = (ecs: Ecs): string => JSON.stringify(ecs, omitTypenameAndEmpty, 2);
/**
* Creates mapping of eventID -> fieldData for given fieldsToKeep. Used to store additional field
* data necessary for custom timeline actions in conjunction with selection state
@ -76,27 +69,6 @@ export const getEventIdToDataMapping = (
export const isEventBuildingBlockType = (event: Ecs): boolean =>
!isEmpty(event.kibana?.alert?.building_block_type);
export const isEvenEqlSequence = (event: Ecs): boolean => {
if (!isEmpty(event.eql?.sequenceNumber)) {
try {
const sequenceNumber = (event.eql?.sequenceNumber ?? '').split('-')[0];
return parseInt(sequenceNumber, 10) % 2 === 0;
} catch {
return false;
}
}
return false;
};
/** Return eventType raw or signal or eql */
export const getEventType = (event: Ecs): Omit<TimelineEventsType, 'all'> => {
if (!isEmpty(event.signal?.rule?.id)) {
return 'signal';
} else if (!isEmpty(event.eql?.parentId)) {
return 'eql';
}
return 'raw';
};
/** Maps (Redux) `SortDirection` to the `direction` values used by `EuiDataGrid` */
export const mapSortDirectionToDirection = (sortDirection: SortDirection): 'asc' | 'desc' => {
switch (sortDirection) {

View file

@ -5,13 +5,4 @@
* 2.0.
*/
export type {
OnColumnSorted,
OnColumnsSorted,
OnColumnRemoved,
OnColumnResized,
OnChangePage,
OnRowSelected,
OnSelectAll,
OnUpdateColumns,
} from '../../../common/types/timeline';
export type { OnChangePage, OnRowSelected, OnSelectAll } from '../../../common/types/timeline';

View file

@ -28050,13 +28050,6 @@
"xpack.timelines.clipboard.copy.to.the.clipboard": "Copier dans le presse-papiers",
"xpack.timelines.clipboard.to.the.clipboard": "dans le presse-papiers",
"xpack.timelines.copyToClipboardTooltip": "Copier dans le presse-papiers",
"xpack.timelines.draggables.field.categoryLabel": "Catégorie",
"xpack.timelines.draggables.field.fieldLabel": "Champ",
"xpack.timelines.draggables.field.typeLabel": "Type",
"xpack.timelines.draggables.field.viewCategoryTooltip": "Afficher la catégorie",
"xpack.timelines.emptyString.emptyStringDescription": "Chaîne vide",
"xpack.timelines.eventDetails.copyToClipboardTooltip": "Copier dans le presse-papiers",
"xpack.timelines.exitFullScreenButton": "Quitter le plein écran",
"xpack.timelines.fieldBrowser.categoriesCountTitle": "{totalCount} {totalCount, plural, =1 {catégorie} other {catégories}}",
"xpack.timelines.fieldBrowser.categoriesTitle": "Catégories",
"xpack.timelines.fieldBrowser.categoryLabel": "Catégorie",
@ -28147,8 +28140,6 @@
"xpack.timelines.timeline.closedAlertSuccessToastMessage": "Fermeture réussie de {totalAlerts} {totalAlerts, plural, =1 {alerte} other {alertes}}.",
"xpack.timelines.timeline.closeSelectedTitle": "Marquer comme fermé",
"xpack.timelines.timeline.descriptionTooltip": "Description",
"xpack.timelines.timeline.eventHasEventRendererScreenReaderOnly": "L'événement de la ligne {row} possède un outil de rendu d'événement. Appuyez sur Maj + flèche vers le bas pour faire la mise au point dessus.",
"xpack.timelines.timeline.eventHasNotesScreenReaderOnly": "L'événement de la ligne {row} possède {notesCount, plural, =1 {une note} other {{notesCount} des notes}}. Appuyez sur Maj + flèche vers la droite pour faire la mise au point sur les notes.",
"xpack.timelines.timeline.eventsTableAriaLabel": "events; Page {activePage} sur {totalPages}",
"xpack.timelines.timeline.fieldTooltip": "Champ",
"xpack.timelines.timeline.flyout.pane.removeColumnButtonLabel": "Retirer une colonne",
@ -28165,7 +28156,6 @@
"xpack.timelines.timeline.updateAlertStatusFailedDetailed": "{ updated } {updated, plural, =1 {alerte a été mise à jour} other {alertes ont été mises à jour}} correctement, mais { conflicts } n'ont pas pu être mis à jour\n car { conflicts, plural, =1 {elle était} other {elles étaient}} déjà en cours de modification.",
"xpack.timelines.timeline.updateAlertStatusFailedSingleAlert": "Impossible de mettre à jour l'alerte, car elle était déjà en cours de modification.",
"xpack.timelines.timeline.youAreInAnEventRendererScreenReaderOnly": "Vous êtes dans un outil de rendu d'événement pour la ligne : {row}. Appuyez sur la touche fléchée vers le haut pour quitter et revenir à la ligne en cours, ou sur la touche fléchée vers le bas pour quitter et passer à la ligne suivante.",
"xpack.timelines.timeline.youAreInATableCellScreenReaderOnly": "Vous êtes dans une cellule de tableau. Ligne : {row}, colonne : {column}",
"xpack.timelines.timelineEvents.errorSearchDescription": "Une erreur s'est produite lors de la recherche d'événements de la chronologie",
"xpack.timelines.toolbar.bulkActions.clearSelectionTitle": "Effacer la sélection",
"xpack.timelines.toolbar.bulkActions.selectAllAlertsTitle": "Sélectionner un total de {totalAlertsFormatted} {totalAlerts, plural, =1 {alerte} other {alertes}}",

View file

@ -28227,13 +28227,6 @@
"xpack.timelines.clipboard.copy.to.the.clipboard": "クリップボードにコピー",
"xpack.timelines.clipboard.to.the.clipboard": "クリップボードに",
"xpack.timelines.copyToClipboardTooltip": "クリップボードにコピー",
"xpack.timelines.draggables.field.categoryLabel": "カテゴリー",
"xpack.timelines.draggables.field.fieldLabel": "フィールド",
"xpack.timelines.draggables.field.typeLabel": "型",
"xpack.timelines.draggables.field.viewCategoryTooltip": "カテゴリーを表示します",
"xpack.timelines.emptyString.emptyStringDescription": "空の文字列",
"xpack.timelines.eventDetails.copyToClipboardTooltip": "クリップボードにコピー",
"xpack.timelines.exitFullScreenButton": "全画面を終了",
"xpack.timelines.fieldBrowser.categoriesCountTitle": "{totalCount} {totalCount, plural, other {カテゴリ}}",
"xpack.timelines.fieldBrowser.categoriesTitle": "カテゴリー",
"xpack.timelines.fieldBrowser.categoryLabel": "カテゴリー",
@ -28324,8 +28317,6 @@
"xpack.timelines.timeline.closedAlertSuccessToastMessage": "{totalAlerts} {totalAlerts, plural, other {件のアラート}}を正常にクローズしました。",
"xpack.timelines.timeline.closeSelectedTitle": "クローズ済みに設定",
"xpack.timelines.timeline.descriptionTooltip": "説明",
"xpack.timelines.timeline.eventHasEventRendererScreenReaderOnly": "行{row}のイベントにはイベントレンダラーがあります。Shiftと下矢印を押すとフォーカスします。",
"xpack.timelines.timeline.eventHasNotesScreenReaderOnly": "行{row}のイベントには{notesCount, plural, other {{notesCount}個のメモ}}があります。Shiftと右矢印を押すとメモをフォーカスします。",
"xpack.timelines.timeline.eventsTableAriaLabel": "イベント; {activePage}/{totalPages} ページ",
"xpack.timelines.timeline.fieldTooltip": "フィールド",
"xpack.timelines.timeline.flyout.pane.removeColumnButtonLabel": "列を削除",
@ -28342,7 +28333,6 @@
"xpack.timelines.timeline.updateAlertStatusFailedDetailed": "{ updated } {updated, plural, other {アラート}}が正常に更新されましたが、{ conflicts }は更新できませんでした。\n { conflicts, plural, other {}}すでに修正されています。",
"xpack.timelines.timeline.updateAlertStatusFailedSingleAlert": "アラートを更新できませんでした。アラートはすでに修正されています。",
"xpack.timelines.timeline.youAreInAnEventRendererScreenReaderOnly": "行 {row} のイベントレンダラーを表示しています。上矢印キーを押すと、終了して現在の行に戻ります。下矢印キーを押すと、終了して次の行に進みます。",
"xpack.timelines.timeline.youAreInATableCellScreenReaderOnly": "表セルの行 {row}、列 {column} にいます",
"xpack.timelines.timelineEvents.errorSearchDescription": "タイムラインイベント検索でエラーが発生しました",
"xpack.timelines.toolbar.bulkActions.clearSelectionTitle": "選択した項目をクリア",
"xpack.timelines.toolbar.bulkActions.selectAllAlertsTitle": "すべての{totalAlertsFormatted} {totalAlerts, plural, other {件のアラート}}を選択",

View file

@ -28261,13 +28261,6 @@
"xpack.timelines.clipboard.copy.to.the.clipboard": "复制到剪贴板",
"xpack.timelines.clipboard.to.the.clipboard": "至剪贴板",
"xpack.timelines.copyToClipboardTooltip": "复制到剪贴板",
"xpack.timelines.draggables.field.categoryLabel": "类别",
"xpack.timelines.draggables.field.fieldLabel": "字段",
"xpack.timelines.draggables.field.typeLabel": "类型",
"xpack.timelines.draggables.field.viewCategoryTooltip": "查看类别",
"xpack.timelines.emptyString.emptyStringDescription": "空字符串",
"xpack.timelines.eventDetails.copyToClipboardTooltip": "复制到剪贴板",
"xpack.timelines.exitFullScreenButton": "退出全屏",
"xpack.timelines.fieldBrowser.categoriesCountTitle": "{totalCount} {totalCount, plural, other {个类别}}",
"xpack.timelines.fieldBrowser.categoriesTitle": "类别",
"xpack.timelines.fieldBrowser.categoryLabel": "类别",
@ -28358,8 +28351,6 @@
"xpack.timelines.timeline.closedAlertSuccessToastMessage": "已成功关闭 {totalAlerts} 个{totalAlerts, plural, other {告警}}。",
"xpack.timelines.timeline.closeSelectedTitle": "标记为已关闭",
"xpack.timelines.timeline.descriptionTooltip": "描述",
"xpack.timelines.timeline.eventHasEventRendererScreenReaderOnly": "位于行 {row} 的事件具有事件呈现程序。按 shift + 向下箭头键以对其聚焦。",
"xpack.timelines.timeline.eventHasNotesScreenReaderOnly": "位于行 {row} 的事件有{notesCount, plural, =1 {备注} other { {notesCount} 个备注}}。按 shift + 右箭头键以聚焦备注。",
"xpack.timelines.timeline.eventsTableAriaLabel": "事件;第 {activePage} 页,共 {totalPages} 页",
"xpack.timelines.timeline.fieldTooltip": "字段",
"xpack.timelines.timeline.flyout.pane.removeColumnButtonLabel": "移除列",
@ -28376,7 +28367,6 @@
"xpack.timelines.timeline.updateAlertStatusFailedDetailed": "{ updated } 个{updated, plural, other {告警}}已成功更新,但是 { conflicts } 个无法更新,\n 因为{ conflicts, plural, other {其}}已被修改。",
"xpack.timelines.timeline.updateAlertStatusFailedSingleAlert": "无法更新告警,因为它已被修改。",
"xpack.timelines.timeline.youAreInAnEventRendererScreenReaderOnly": "您正处于第 {row} 行的事件呈现器中。按向上箭头键退出并返回当前行,或按向下箭头键退出并前进到下一行。",
"xpack.timelines.timeline.youAreInATableCellScreenReaderOnly": "您处在表单元格中。行:{row},列:{column}",
"xpack.timelines.timelineEvents.errorSearchDescription": "搜索时间线事件时发生错误",
"xpack.timelines.toolbar.bulkActions.clearSelectionTitle": "清除所选内容",
"xpack.timelines.toolbar.bulkActions.selectAllAlertsTitle": "选择全部 {totalAlertsFormatted} 个{totalAlerts, plural, other {告警}}",