[SIEM] Detection engine timeline (#53783)

* change create to only have only one form to be open at the same time

* add tick to risk score

* remove compressed

* fix select in schedule

* fix bug to not  allow more than one step panel to be open at a time

* Add a color/health indicator to severity selector

* Move and reword tags placeholder to bottom helper text

* fix ux on the index patterns field

* Reorganize MITRE ATT&CK threat

* add url validation + some cleaning to prerp work for UT

* add feature to get back timeline + be able to disable action on timeline modal

* Add option to import the query from a saved timeline.

* wip

* Add timeline template selector

* fix few bugs from last commit

* review I

* fix unit test for timeline_title

* ui review

* fix truncation on timeline selectable
This commit is contained in:
Xavier Mouligneau 2020-01-08 19:32:10 -05:00 committed by GitHub
parent 404c42f955
commit 1e2cbb3710
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 2174 additions and 684 deletions

View file

@ -37,6 +37,7 @@ import {
RadioGroupField,
RangeField,
SelectField,
SuperSelectField,
ToggleField,
} from './fields';
@ -50,6 +51,7 @@ const mapTypeToFieldComponent = {
[FIELD_TYPES.RADIO_GROUP]: RadioGroupField,
[FIELD_TYPES.RANGE]: RangeField,
[FIELD_TYPES.SELECT]: SelectField,
[FIELD_TYPES.SUPER_SELECT]: SuperSelectField,
[FIELD_TYPES.TOGGLE]: ToggleField,
};

View file

@ -25,5 +25,6 @@ export * from './multi_select_field';
export * from './radio_group_field';
export * from './range_field';
export * from './select_field';
export * from './super_select_field';
export * from './toggle_field';
export * from './text_area_field';

View file

@ -0,0 +1,58 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { EuiFormRow, EuiSuperSelect } from '@elastic/eui';
import { FieldHook, getFieldValidityAndErrorMessage } from '../../hook_form_lib';
interface Props {
field: FieldHook;
euiFieldProps?: Record<string, any>;
idAria?: string;
[key: string]: any;
}
export const SuperSelectField = ({ field, euiFieldProps = {}, ...rest }: Props) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
return (
<EuiFormRow
label={field.label}
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={rest['data-test-subj']}
describedByIds={rest.idAria ? [rest.idAria] : undefined}
>
<EuiSuperSelect
fullWidth
valueOfSelected={field.value as string}
onChange={value => {
field.setValue(value);
}}
options={[]}
isInvalid={isInvalid}
data-test-subj="select"
{...euiFieldProps}
/>
</EuiFormRow>
);
};

View file

@ -28,6 +28,7 @@ export const FIELD_TYPES = {
RADIO_GROUP: 'radioGroup',
RANGE: 'range',
SELECT: 'select',
SUPER_SELECT: 'superSelect',
MULTI_SELECT: 'multiSelect',
};

View file

@ -181,6 +181,7 @@ export interface QueryTimelineById<TCache> {
apolloClient: ApolloClient<TCache> | ApolloClient<{}> | undefined;
duplicate: boolean;
timelineId: string;
onOpenTimeline?: (timeline: TimelineModel) => void;
openTimeline?: boolean;
updateIsLoading: ActionCreator<{ id: string; isLoading: boolean }>;
updateTimeline: DispatchUpdateTimeline;
@ -190,6 +191,7 @@ export const queryTimelineById = <TCache>({
apolloClient,
duplicate = false,
timelineId,
onOpenTimeline,
openTimeline = true,
updateIsLoading,
updateTimeline,
@ -209,7 +211,9 @@ export const queryTimelineById = <TCache>({
);
const { timeline, notes } = formatTimelineResultToModel(timelineToOpen, duplicate);
if (updateTimeline) {
if (onOpenTimeline != null) {
onOpenTimeline(timeline);
} else if (updateTimeline) {
updateTimeline({
duplicate,
from: getOr(getDefaultFromValue(), 'dateRange.start', timeline),

View file

@ -12,18 +12,20 @@ import { Dispatch } from 'redux';
import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers';
import { deleteTimelineMutation } from '../../containers/timeline/delete/persist.gql_query';
import { AllTimelinesVariables, AllTimelinesQuery } from '../../containers/timeline/all';
import { allTimelinesQuery } from '../../containers/timeline/all/index.gql_query';
import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../graphql/types';
import { State, timelineSelectors } from '../../store';
import { timelineDefaults, TimelineModel } from '../../store/timeline/model';
import {
createTimeline as dispatchCreateNewTimeline,
updateIsLoading as dispatchUpdateIsLoading,
} from '../../store/timeline/actions';
import { ColumnHeader } from '../timeline/body/column_headers/column_header';
import { OpenTimeline } from './open_timeline';
import { OPEN_TIMELINE_CLASS_NAME, queryTimelineById, dispatchUpdateTimeline } from './helpers';
import { OpenTimelineModalBody } from './open_timeline_modal/open_timeline_modal_body';
import {
ActionTimelineToShow,
DeleteTimelines,
EuiSearchBarQuery,
OnDeleteSelected,
@ -41,14 +43,14 @@ import {
OpenTimelineReduxProps,
} from './types';
import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants';
import { ColumnHeader } from '../timeline/body/column_headers/column_header';
import { timelineDefaults } from '../../store/timeline/model';
interface OwnProps<TCache = object> {
apolloClient: ApolloClient<TCache>;
/** Displays open timeline in modal */
isModal: boolean;
closeModalTimeline?: () => void;
hideActions?: ActionTimelineToShow[];
onOpenTimeline?: (timeline: TimelineModel) => void;
}
export type OpenTimelineOwnProps = OwnProps &
@ -69,15 +71,17 @@ export const getSelectedTimelineIds = (selectedItems: OpenTimelineResult[]): str
/** Manages the state (e.g table selection) of the (pure) `OpenTimeline` component */
export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
({
defaultPageSize,
isModal = false,
title,
apolloClient,
closeModalTimeline,
createNewTimeline,
defaultPageSize,
hideActions = [],
isModal = false,
onOpenTimeline,
timeline,
title,
updateTimeline,
updateIsLoading,
timeline,
createNewTimeline,
}) => {
/** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */
const [itemIdToExpandedNotesRowMap, setItemIdToExpandedNotesRowMap] = useState<
@ -212,6 +216,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
queryTimelineById({
apolloClient,
duplicate,
onOpenTimeline,
timelineId,
updateIsLoading,
updateTimeline,
@ -286,6 +291,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
data-test-subj={'open-timeline-modal'}
deleteTimelines={onDeleteOneTimeline}
defaultPageSize={defaultPageSize}
hideActions={hideActions}
isLoading={loading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
onAddTimelinesToFavorites={undefined}

View file

@ -143,7 +143,7 @@ describe('OpenTimeline', () => {
).toBe(true);
});
test('it shows extended columns and actions when onDeleteSelected and deleteTimelines are specified', () => {
test('it shows the delete action columns when onDeleteSelected and deleteTimelines are specified', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<OpenTimeline
@ -178,10 +178,10 @@ describe('OpenTimeline', () => {
.first()
.props() as TimelinesTableProps;
expect(props.showExtendedColumnsAndActions).toBe(true);
expect(props.actionTimelineToShow).toContain('delete');
});
test('it does NOT show extended columns and actions when is onDeleteSelected undefined and deleteTimelines is specified', () => {
test('it does NOT show the delete action columns when is onDeleteSelected undefined and deleteTimelines is specified', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<OpenTimeline
@ -215,10 +215,10 @@ describe('OpenTimeline', () => {
.first()
.props() as TimelinesTableProps;
expect(props.showExtendedColumnsAndActions).toBe(false);
expect(props.actionTimelineToShow).not.toContain('delete');
});
test('it does NOT show extended columns and actions when is onDeleteSelected provided and deleteTimelines is undefined', () => {
test('it does NOT show the delete action columns when is onDeleteSelected provided and deleteTimelines is undefined', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<OpenTimeline
@ -252,10 +252,10 @@ describe('OpenTimeline', () => {
.first()
.props() as TimelinesTableProps;
expect(props.showExtendedColumnsAndActions).toBe(false);
expect(props.actionTimelineToShow).not.toContain('delete');
});
test('it does NOT show extended columns and actions when both onDeleteSelected and deleteTimelines are undefined', () => {
test('it does NOT show the delete action when both onDeleteSelected and deleteTimelines are undefined', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<OpenTimeline
@ -288,6 +288,6 @@ describe('OpenTimeline', () => {
.first()
.props() as TimelinesTableProps;
expect(props.showExtendedColumnsAndActions).toBe(false);
expect(props.actionTimelineToShow).not.toContain('delete');
});
});

View file

@ -57,6 +57,11 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
/>
<TimelinesTable
actionTimelineToShow={
onDeleteSelected != null && deleteTimelines != null
? ['delete', 'duplicate', 'selectable']
: ['duplicate', 'selectable']
}
data-test-subj="timelines-table"
deleteTimelines={deleteTimelines}
defaultPageSize={defaultPageSize}
@ -69,7 +74,7 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
pageIndex={pageIndex}
pageSize={pageSize}
searchResults={searchResults}
showExtendedColumnsAndActions={onDeleteSelected != null && deleteTimelines != null}
showExtendedColumns={true}
sortDirection={sortDirection}
sortField={sortField}
totalSearchResultsCount={totalSearchResultsCount}

View file

@ -7,39 +7,49 @@
import { EuiModal, EuiOverlayMask } from '@elastic/eui';
import React from 'react';
import { TimelineModel } from '../../../store/timeline/model';
import { useApolloClient } from '../../../utils/apollo_context';
import * as i18n from '../translations';
import { ActionTimelineToShow } from '../types';
import { StatefulOpenTimeline } from '..';
export interface OpenTimelineModalProps {
onClose: () => void;
hideActions?: ActionTimelineToShow[];
modalTitle?: string;
onOpen?: (timeline: TimelineModel) => void;
}
const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10;
const OPEN_TIMELINE_MODAL_WIDTH = 1000; // px
export const OpenTimelineModal = React.memo<OpenTimelineModalProps>(({ onClose }) => {
const apolloClient = useApolloClient();
export const OpenTimelineModal = React.memo<OpenTimelineModalProps>(
({ hideActions = [], modalTitle, onClose, onOpen }) => {
const apolloClient = useApolloClient();
if (!apolloClient) return null;
if (!apolloClient) return null;
return (
<EuiOverlayMask>
<EuiModal
data-test-subj="open-timeline-modal"
maxWidth={OPEN_TIMELINE_MODAL_WIDTH}
onClose={onClose}
>
<StatefulOpenTimeline
apolloClient={apolloClient}
closeModalTimeline={onClose}
isModal={true}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
title={i18n.OPEN_TIMELINE_TITLE}
/>
</EuiModal>
</EuiOverlayMask>
);
});
return (
<EuiOverlayMask>
<EuiModal
data-test-subj="open-timeline-modal"
maxWidth={OPEN_TIMELINE_MODAL_WIDTH}
onClose={onClose}
>
<StatefulOpenTimeline
apolloClient={apolloClient}
closeModalTimeline={onClose}
hideActions={hideActions}
isModal={true}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
onOpenTimeline={onOpen}
title={modalTitle ?? i18n.OPEN_TIMELINE_TITLE}
/>
</EuiModal>
</EuiOverlayMask>
);
}
);
OpenTimelineModal.displayName = 'OpenTimelineModal';

View file

@ -143,7 +143,7 @@ describe('OpenTimelineModal', () => {
).toBe(true);
});
test('it shows extended columns and actions when onDeleteSelected and deleteTimelines are specified', () => {
test('it shows the delete action when onDeleteSelected and deleteTimelines are specified', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<OpenTimelineModalBody
@ -178,10 +178,10 @@ describe('OpenTimelineModal', () => {
.first()
.props() as TimelinesTableProps;
expect(props.showExtendedColumnsAndActions).toBe(true);
expect(props.actionTimelineToShow).toContain('delete');
});
test('it does NOT show extended columns and actions when is onDeleteSelected undefined and deleteTimelines is specified', () => {
test('it does NOT show the delete when is onDeleteSelected undefined and deleteTimelines is specified', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<OpenTimelineModalBody
@ -215,10 +215,10 @@ describe('OpenTimelineModal', () => {
.first()
.props() as TimelinesTableProps;
expect(props.showExtendedColumnsAndActions).toBe(false);
expect(props.actionTimelineToShow).not.toContain('delete');
});
test('it does NOT show extended columns and actions when is onDeleteSelected provided and deleteTimelines is undefined', () => {
test('it does NOT show the delete action when is onDeleteSelected provided and deleteTimelines is undefined', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<OpenTimelineModalBody
@ -252,10 +252,10 @@ describe('OpenTimelineModal', () => {
.first()
.props() as TimelinesTableProps;
expect(props.showExtendedColumnsAndActions).toBe(false);
expect(props.actionTimelineToShow).not.toContain('delete');
});
test('it does NOT show extended columns and actions when both onDeleteSelected and deleteTimelines are undefined', () => {
test('it does NOT show extended columns when both onDeleteSelected and deleteTimelines are undefined', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<OpenTimelineModalBody
@ -288,6 +288,6 @@ describe('OpenTimelineModal', () => {
.first()
.props() as TimelinesTableProps;
expect(props.showExtendedColumnsAndActions).toBe(false);
expect(props.actionTimelineToShow).not.toContain('delete');
});
});

View file

@ -5,10 +5,10 @@
*/
import { EuiModalBody, EuiModalHeader } from '@elastic/eui';
import React from 'react';
import React, { memo, useMemo } from 'react';
import styled from 'styled-components';
import { OpenTimelineProps } from '../types';
import { OpenTimelineProps, ActionTimelineToShow } from '../types';
import { SearchRow } from '../search_row';
import { TimelinesTable } from '../timelines_table';
import { TitleRow } from '../title_row';
@ -19,10 +19,11 @@ export const HeaderContainer = styled.div`
HeaderContainer.displayName = 'HeaderContainer';
export const OpenTimelineModalBody = React.memo<OpenTimelineProps>(
export const OpenTimelineModalBody = memo<OpenTimelineProps>(
({
deleteTimelines,
defaultPageSize,
hideActions = [],
isLoading,
itemIdToExpandedNotesRowMap,
onAddTimelinesToFavorites,
@ -43,51 +44,61 @@ export const OpenTimelineModalBody = React.memo<OpenTimelineProps>(
sortField,
title,
totalSearchResultsCount,
}) => (
<>
<EuiModalHeader>
<HeaderContainer>
<TitleRow
data-test-subj="title-row"
onDeleteSelected={onDeleteSelected}
onAddTimelinesToFavorites={onAddTimelinesToFavorites}
selectedTimelinesCount={selectedItems.length}
title={title}
/>
}) => {
const actionsToShow = useMemo(() => {
const actions: ActionTimelineToShow[] =
onDeleteSelected != null && deleteTimelines != null
? ['delete', 'duplicate']
: ['duplicate'];
return actions.filter(action => !hideActions.includes(action));
}, [onDeleteSelected, deleteTimelines, hideActions]);
return (
<>
<EuiModalHeader>
<HeaderContainer>
<TitleRow
data-test-subj="title-row"
onDeleteSelected={onDeleteSelected}
onAddTimelinesToFavorites={onAddTimelinesToFavorites}
selectedTimelinesCount={selectedItems.length}
title={title}
/>
<SearchRow
data-test-subj="search-row"
onlyFavorites={onlyFavorites}
onQueryChange={onQueryChange}
onToggleOnlyFavorites={onToggleOnlyFavorites}
query={query}
<SearchRow
data-test-subj="search-row"
onlyFavorites={onlyFavorites}
onQueryChange={onQueryChange}
onToggleOnlyFavorites={onToggleOnlyFavorites}
query={query}
totalSearchResultsCount={totalSearchResultsCount}
/>
</HeaderContainer>
</EuiModalHeader>
<EuiModalBody>
<TimelinesTable
actionTimelineToShow={actionsToShow}
data-test-subj="timelines-table"
deleteTimelines={deleteTimelines}
defaultPageSize={defaultPageSize}
loading={isLoading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
onOpenTimeline={onOpenTimeline}
onSelectionChange={onSelectionChange}
onTableChange={onTableChange}
onToggleShowNotes={onToggleShowNotes}
pageIndex={pageIndex}
pageSize={pageSize}
searchResults={searchResults}
showExtendedColumns={false}
sortDirection={sortDirection}
sortField={sortField}
totalSearchResultsCount={totalSearchResultsCount}
/>
</HeaderContainer>
</EuiModalHeader>
<EuiModalBody>
<TimelinesTable
data-test-subj="timelines-table"
deleteTimelines={deleteTimelines}
defaultPageSize={defaultPageSize}
loading={isLoading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
onOpenTimeline={onOpenTimeline}
onSelectionChange={onSelectionChange}
onTableChange={onTableChange}
onToggleShowNotes={onToggleShowNotes}
pageIndex={pageIndex}
pageSize={pageSize}
searchResults={searchResults}
showExtendedColumnsAndActions={onDeleteSelected != null && deleteTimelines != null}
sortDirection={sortDirection}
sortField={sortField}
totalSearchResultsCount={totalSearchResultsCount}
/>
</EuiModalBody>
</>
)
</EuiModalBody>
</>
);
}
);
OpenTimelineModalBody.displayName = 'OpenTimelineModalBody';

View file

@ -27,10 +27,11 @@ describe('#getActionsColumns', () => {
mockResults = cloneDeep(mockTimelineResults);
});
test('it renders the delete timeline (trash icon) when showDeleteAction is true (because showExtendedColumnsAndActions is true)', () => {
test('it renders the delete timeline (trash icon) when actionTimelineToShow is including the action delete', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -42,7 +43,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -53,10 +54,11 @@ describe('#getActionsColumns', () => {
expect(wrapper.find('[data-test-subj="delete-timeline"]').exists()).toBe(true);
});
test('it does NOT render the delete timeline (trash icon) when showDeleteAction is false (because showExtendedColumnsAndActions is false)', () => {
test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow is NOT including the action delete', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={[]}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -68,7 +70,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={false}
showExtendedColumns={false}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -79,10 +81,12 @@ describe('#getActionsColumns', () => {
expect(wrapper.find('[data-test-subj="delete-timeline"]').exists()).toBe(false);
});
test('it does NOT render the delete timeline (trash icon) when deleteTimelines is not provided', () => {
test('it renders the duplicate icon timeline when actionTimelineToShow is including the action duplicate', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['duplicate']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
itemIdToExpandedNotesRowMap={{}}
@ -93,7 +97,60 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBe(true);
});
test('it does NOT render the duplicate timeline when actionTimelineToShow is NOT including the action duplicate)', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={[]}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
itemIdToExpandedNotesRowMap={{}}
onOpenTimeline={jest.fn()}
onSelectionChange={jest.fn()}
onTableChange={jest.fn()}
onToggleShowNotes={jest.fn()}
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumns={false}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBe(false);
});
test('it does NOT render the delete timeline (trash icon) when deleteTimelines is not provided', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete']}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
itemIdToExpandedNotesRowMap={{}}
onOpenTimeline={jest.fn()}
onSelectionChange={jest.fn()}
onTableChange={jest.fn()}
onToggleShowNotes={jest.fn()}
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -111,6 +168,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -122,7 +180,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={missingSavedObjectId}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={missingSavedObjectId.length}
@ -141,6 +199,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -152,7 +211,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={false}
showExtendedColumns={false}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -174,6 +233,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -185,7 +245,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={false}
showExtendedColumns={false}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}

View file

@ -12,19 +12,24 @@ import React from 'react';
import { ACTION_COLUMN_WIDTH } from './common_styles';
import { DeleteTimelineModalButton } from '../delete_timeline_modal';
import * as i18n from '../translations';
import { DeleteTimelines, OnOpenTimeline, OpenTimelineResult } from '../types';
import {
ActionTimelineToShow,
DeleteTimelines,
OnOpenTimeline,
OpenTimelineResult,
} from '../types';
/**
* Returns the action columns (e.g. delete, open duplicate timeline)
*/
export const getActionsColumns = ({
actionTimelineToShow,
onOpenTimeline,
deleteTimelines,
showDeleteAction,
}: {
actionTimelineToShow: ActionTimelineToShow[];
deleteTimelines?: DeleteTimelines;
onOpenTimeline: OnOpenTimeline;
showDeleteAction: boolean;
}) => {
const openAsDuplicateColumn = {
align: 'center',
@ -67,7 +72,10 @@ export const getActionsColumns = ({
width: ACTION_COLUMN_WIDTH,
};
return showDeleteAction && deleteTimelines != null
? [openAsDuplicateColumn, deleteTimelineColumn]
: [openAsDuplicateColumn];
return [
actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null,
actionTimelineToShow.includes('delete') && deleteTimelines != null
? deleteTimelineColumn
: null,
].filter(action => action != null);
};

View file

@ -37,6 +37,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -48,7 +49,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={hasNotes}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={hasNotes.length}
@ -63,6 +64,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -74,7 +76,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={missingNotes}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={missingNotes.length}
@ -89,6 +91,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -100,7 +103,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={nullNotes}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={nullNotes.length}
@ -115,6 +118,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -126,7 +130,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={emptylNotes}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={emptylNotes.length}
@ -143,6 +147,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -154,7 +159,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={missingSavedObjectId}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={missingSavedObjectId.length}
@ -169,6 +174,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -180,7 +186,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={nullSavedObjectId}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={nullSavedObjectId.length}
@ -195,6 +201,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -206,7 +213,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={hasNotes}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={hasNotes.length}
@ -231,6 +238,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -242,7 +250,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={hasNotes}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={hasNotes.length}
@ -269,6 +277,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -280,7 +289,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={hasNotes}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={hasNotes.length}
@ -311,6 +320,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -322,7 +332,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={hasNotes}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={hasNotes.length}
@ -346,6 +356,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -357,7 +368,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -377,6 +388,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -388,7 +400,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -411,6 +423,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -422,7 +435,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={missingSavedObjectId}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={missingSavedObjectId.length}
@ -442,6 +455,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -453,7 +467,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={missingTitle}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={missingTitle.length}
@ -475,6 +489,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -486,7 +501,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={withMissingSavedObjectIdAndTitle}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={withMissingSavedObjectIdAndTitle.length}
@ -508,6 +523,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -519,7 +535,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={withJustWhitespaceTitle}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={withJustWhitespaceTitle.length}
@ -541,6 +557,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -552,7 +569,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={withMissingSavedObjectId}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={withMissingSavedObjectId.length}
@ -571,6 +588,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -582,7 +600,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -605,6 +623,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -616,7 +635,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={missingSavedObjectId}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={missingSavedObjectId.length}
@ -637,6 +656,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -648,7 +668,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -673,6 +693,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -684,7 +705,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -704,6 +725,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -715,7 +737,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -737,6 +759,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -748,7 +771,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={missingDescription}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={missingDescription.length}
@ -771,6 +794,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -782,7 +806,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={justWhitespaceDescription}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={justWhitespaceDescription.length}
@ -803,6 +827,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -814,7 +839,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -834,6 +859,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -845,7 +871,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -868,6 +894,7 @@ describe('#getCommonColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -879,7 +906,7 @@ describe('#getCommonColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={missingUpdated}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={missingUpdated.length}

View file

@ -27,11 +27,9 @@ export const getCommonColumns = ({
itemIdToExpandedNotesRowMap,
onOpenTimeline,
onToggleShowNotes,
showExtendedColumnsAndActions,
}: {
onOpenTimeline: OnOpenTimeline;
onToggleShowNotes: OnToggleShowNotes;
showExtendedColumnsAndActions: boolean;
itemIdToExpandedNotesRowMap: Record<string, JSX.Element>;
}) => [
{

View file

@ -35,6 +35,7 @@ describe('#getExtendedColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -46,7 +47,7 @@ describe('#getExtendedColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -66,6 +67,7 @@ describe('#getExtendedColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -77,7 +79,7 @@ describe('#getExtendedColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -99,6 +101,7 @@ describe('#getExtendedColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -110,7 +113,7 @@ describe('#getExtendedColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={missingUpdatedBy}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={missingUpdatedBy.length}

View file

@ -30,6 +30,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -41,7 +42,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -57,6 +58,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -68,7 +70,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={with6Events}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={with6Events.length}
@ -82,6 +84,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -93,7 +96,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -109,6 +112,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -120,7 +124,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={with4Notes}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={with4Notes.length}
@ -134,6 +138,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -145,7 +150,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -161,6 +166,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -172,7 +178,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={undefinedFavorite}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={undefinedFavorite.length}
@ -187,6 +193,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -198,7 +205,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={nullFavorite}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={nullFavorite.length}
@ -213,6 +220,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -224,7 +232,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={emptyFavorite}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={emptyFavorite.length}
@ -249,6 +257,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -260,7 +269,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={emptyFavorite}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={emptyFavorite.length}
@ -289,6 +298,7 @@ describe('#getActionsColumns', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -300,7 +310,7 @@ describe('#getActionsColumns', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={emptyFavorite}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={emptyFavorite.length}

View file

@ -28,10 +28,11 @@ describe('TimelinesTable', () => {
mockResults = cloneDeep(mockTimelineResults);
});
test('it renders the select all timelines header checkbox when showExtendedColumnsAndActions is true', () => {
test('it renders the select all timelines header checkbox when actionTimelineToShow has the action selectable', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -43,7 +44,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -59,10 +60,11 @@ describe('TimelinesTable', () => {
).toBe(true);
});
test('it does NOT render the select all timelines header checkbox when showExtendedColumnsAndActions is false', () => {
test('it does NOT render the select all timelines header checkbox when actionTimelineToShow has not the action selectable', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -74,7 +76,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={false}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -90,10 +92,11 @@ describe('TimelinesTable', () => {
).toBe(false);
});
test('it renders the Modified By column when showExtendedColumnsAndActions is true ', () => {
test('it renders the Modified By column when showExtendedColumns is true ', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -105,7 +108,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -121,10 +124,11 @@ describe('TimelinesTable', () => {
).toContain(i18n.MODIFIED_BY);
});
test('it renders the notes column in the position of the Modified By column when showExtendedColumnsAndActions is false', () => {
test('it renders the notes column in the position of the Modified By column when showExtendedColumns is false', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -136,7 +140,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={false}
showExtendedColumns={false}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -148,16 +152,17 @@ describe('TimelinesTable', () => {
wrapper
.find('thead tr th')
.at(5)
.find('[data-test-subj="notes-count-header-icon"]')
.find('svg[data-test-subj="notes-count-header-icon"]')
.first()
.exists()
).toBe(true);
});
test('it renders the delete timeline (trash icon) when showExtendedColumnsAndActions is true', () => {
test('it renders the delete timeline (trash icon) when actionTimelineToShow has the delete action', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -169,7 +174,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={false}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -185,10 +190,11 @@ describe('TimelinesTable', () => {
).toBe(true);
});
test('it does NOT render the delete timeline (trash icon) when showExtendedColumnsAndActions is false', () => {
test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow has NOT the delete action', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -200,7 +206,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={false}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -216,10 +222,11 @@ describe('TimelinesTable', () => {
).toBe(false);
});
test('it renders the rows per page selector when showExtendedColumnsAndActions is true', () => {
test('it renders the rows per page selector when showExtendedColumns is true', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -231,7 +238,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -247,10 +254,11 @@ describe('TimelinesTable', () => {
).toBe(true);
});
test('it does NOT render the rows per page selector when showExtendedColumnsAndActions is false', () => {
test('it does NOT render the rows per page selector when showExtendedColumns is false', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -262,7 +270,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={false}
showExtendedColumns={false}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -284,6 +292,7 @@ describe('TimelinesTable', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={defaultPageSize}
loading={false}
@ -295,7 +304,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={defaultPageSize}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -311,10 +320,11 @@ describe('TimelinesTable', () => {
).toEqual('Rows per page: 123');
});
test('it sorts the Last Modified column in descending order when showExtendedColumnsAndActions is true ', () => {
test('it sorts the Last Modified column in descending order when showExtendedColumns is true ', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -326,7 +336,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -342,10 +352,11 @@ describe('TimelinesTable', () => {
).toContain(i18n.LAST_MODIFIED);
});
test('it sorts the Last Modified column in descending order when showExtendedColumnsAndActions is false ', () => {
test('it sorts the Last Modified column in descending order when showExtendedColumns is false ', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -357,7 +368,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={false}
showExtendedColumns={false}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -376,6 +387,7 @@ describe('TimelinesTable', () => {
test('it displays the expected message when no search results are found', () => {
const wrapper = mountWithIntl(
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -387,7 +399,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={[]}
showExtendedColumnsAndActions={false}
showExtendedColumns={false}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={0}
@ -408,6 +420,7 @@ describe('TimelinesTable', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -419,7 +432,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -446,6 +459,7 @@ describe('TimelinesTable', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -457,7 +471,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -479,6 +493,7 @@ describe('TimelinesTable', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={true}
@ -490,7 +505,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}
@ -510,6 +525,7 @@ describe('TimelinesTable', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<TimelinesTable
actionTimelineToShow={['delete', 'duplicate', 'selectable']}
deleteTimelines={jest.fn()}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
loading={false}
@ -521,7 +537,7 @@ describe('TimelinesTable', () => {
pageIndex={0}
pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
searchResults={mockResults}
showExtendedColumnsAndActions={true}
showExtendedColumns={true}
sortDirection={DEFAULT_SORT_DIRECTION}
sortField={DEFAULT_SORT_FIELD}
totalSearchResultsCount={mockResults.length}

View file

@ -10,6 +10,7 @@ import styled from 'styled-components';
import * as i18n from '../translations';
import {
ActionTimelineToShow,
DeleteTimelines,
OnOpenTimeline,
OnSelectionChange,
@ -36,8 +37,8 @@ const BasicTable = styled(EuiBasicTable)`
`;
BasicTable.displayName = 'BasicTable';
const getExtendedColumnsIfEnabled = (showExtendedColumnsAndActions: boolean) =>
showExtendedColumnsAndActions ? [...getExtendedColumns()] : [];
const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) =>
showExtendedColumns ? [...getExtendedColumns()] : [];
/**
* Returns the column definitions (passed as the `columns` prop to
@ -46,34 +47,36 @@ const getExtendedColumnsIfEnabled = (showExtendedColumnsAndActions: boolean) =>
* `Timelines` page
*/
const getTimelinesTableColumns = ({
actionTimelineToShow,
deleteTimelines,
itemIdToExpandedNotesRowMap,
onOpenTimeline,
onToggleShowNotes,
showExtendedColumnsAndActions,
showExtendedColumns,
}: {
actionTimelineToShow: ActionTimelineToShow[];
deleteTimelines?: DeleteTimelines;
itemIdToExpandedNotesRowMap: Record<string, JSX.Element>;
onOpenTimeline: OnOpenTimeline;
onToggleShowNotes: OnToggleShowNotes;
showExtendedColumnsAndActions: boolean;
showExtendedColumns: boolean;
}) => [
...getCommonColumns({
itemIdToExpandedNotesRowMap,
onOpenTimeline,
onToggleShowNotes,
showExtendedColumnsAndActions,
}),
...getExtendedColumnsIfEnabled(showExtendedColumnsAndActions),
...getExtendedColumnsIfEnabled(showExtendedColumns),
...getIconHeaderColumns(),
...getActionsColumns({
deleteTimelines,
onOpenTimeline,
showDeleteAction: showExtendedColumnsAndActions,
actionTimelineToShow,
}),
];
export interface TimelinesTableProps {
actionTimelineToShow: ActionTimelineToShow[];
deleteTimelines?: DeleteTimelines;
defaultPageSize: number;
loading: boolean;
@ -85,7 +88,7 @@ export interface TimelinesTableProps {
pageIndex: number;
pageSize: number;
searchResults: OpenTimelineResult[];
showExtendedColumnsAndActions: boolean;
showExtendedColumns: boolean;
sortDirection: 'asc' | 'desc';
sortField: string;
totalSearchResultsCount: number;
@ -97,6 +100,7 @@ export interface TimelinesTableProps {
*/
export const TimelinesTable = React.memo<TimelinesTableProps>(
({
actionTimelineToShow,
deleteTimelines,
defaultPageSize,
loading: isLoading,
@ -108,13 +112,13 @@ export const TimelinesTable = React.memo<TimelinesTableProps>(
pageIndex,
pageSize,
searchResults,
showExtendedColumnsAndActions,
showExtendedColumns,
sortField,
sortDirection,
totalSearchResultsCount,
}) => {
const pagination = {
hidePerPageOptions: !showExtendedColumnsAndActions,
hidePerPageOptions: !showExtendedColumns,
pageIndex,
pageSize,
pageSizeOptions: [
@ -142,16 +146,17 @@ export const TimelinesTable = React.memo<TimelinesTableProps>(
return (
<BasicTable
columns={getTimelinesTableColumns({
actionTimelineToShow,
deleteTimelines,
itemIdToExpandedNotesRowMap,
onOpenTimeline,
onToggleShowNotes,
showExtendedColumnsAndActions,
showExtendedColumns,
})}
compressed
data-test-subj="timelines-table"
isExpandable={true}
isSelectable={showExtendedColumnsAndActions}
isSelectable={actionTimelineToShow.includes('selectable')}
itemId="savedObjectId"
itemIdToExpandedRowMap={itemIdToExpandedNotesRowMap}
items={searchResults}
@ -159,7 +164,7 @@ export const TimelinesTable = React.memo<TimelinesTableProps>(
noItemsMessage={i18n.ZERO_TIMELINES_MATCH}
onChange={onTableChange}
pagination={pagination}
selection={showExtendedColumnsAndActions ? selection : undefined}
selection={actionTimelineToShow.includes('selectable') ? selection : undefined}
sorting={sorting}
/>
);

View file

@ -95,6 +95,8 @@ export interface OnTableChangeParams {
/** Invoked by the EUI table implementation when the user interacts with the table */
export type OnTableChange = (tableChange: OnTableChangeParams) => void;
export type ActionTimelineToShow = 'duplicate' | 'delete' | 'selectable';
export interface OpenTimelineProps {
/** Invoked when the user clicks the delete (trash) icon on an individual timeline */
deleteTimelines?: DeleteTimelines;
@ -140,6 +142,8 @@ export interface OpenTimelineProps {
title: string;
/** The total (server-side) count of the search results */
totalSearchResultsCount: number;
/** Hide action on timeline if needed it */
hideActions?: ActionTimelineToShow[];
}
export interface UpdateTimeline {

View file

@ -0,0 +1,276 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiHighlight,
EuiInputPopover,
EuiSuperSelect,
EuiSelectable,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiTextColor,
EuiFilterButton,
EuiFilterGroup,
EuiSpacer,
} from '@elastic/eui';
import { Option } from '@elastic/eui/src/components/selectable/types';
import { isEmpty } from 'lodash/fp';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { ListProps } from 'react-virtualized';
import styled, { createGlobalStyle } from 'styled-components';
import { AllTimelinesQuery } from '../../../containers/timeline/all';
import { getEmptyTagValue } from '../../empty_value';
import { isUntitled } from '../../../components/open_timeline/helpers';
import * as i18nTimeline from '../../../components/open_timeline/translations';
import { SortFieldTimeline, Direction } from '../../../graphql/types';
import * as i18n from './translations';
const SearchTimelineSuperSelectGlobalStyle = createGlobalStyle`
.euiPopover__panel.euiPopover__panel-isOpen.timeline-search-super-select-popover__popoverPanel {
visibility: hidden;
z-index: 0;
}
`;
const MyEuiHighlight = styled(EuiHighlight)<{ selected: boolean }>`
padding-left: ${({ selected }) => (selected ? '3px' : '0px')};
`;
const MyEuiTextColor = styled(EuiTextColor)<{ selected: boolean }>`
padding-left: ${({ selected }) => (selected ? '20px' : '0px')};
`;
interface SearchTimelineSuperSelectProps {
isDisabled: boolean;
timelineId: string | null;
timelineTitle: string | null;
onTimelineChange: (timelineTitle: string, timelineId: string | null) => void;
}
const basicSuperSelectOptions = [
{
value: '-1',
inputDisplay: i18n.DEFAULT_TIMELINE_TITLE,
},
];
const getBasicSelectableOptions = (timelineId: string) => [
{
description: i18n.DEFAULT_TIMELINE_DESCRIPTION,
label: i18n.DEFAULT_TIMELINE_TITLE,
id: null,
title: i18n.DEFAULT_TIMELINE_TITLE,
checked: timelineId === '-1' ? 'on' : undefined,
} as Option,
];
const ORIGINAL_PAGE_SIZE = 50;
const POPOVER_HEIGHT = 260;
const TIMELINE_ITEM_HEIGHT = 50;
const SearchTimelineSuperSelectComponent: React.FC<SearchTimelineSuperSelectProps> = ({
isDisabled,
timelineId,
timelineTitle,
onTimelineChange,
}) => {
const [pageSize, setPageSize] = useState(ORIGINAL_PAGE_SIZE);
const [heightTrigger, setHeightTrigger] = useState(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [searchTimelineValue, setSearchTimelineValue] = useState('');
const [onlyFavorites, setOnlyFavorites] = useState(false);
const onSearchTimeline = useCallback(val => {
setSearchTimelineValue(val);
}, []);
const handleClosePopover = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const handleOpenPopover = useCallback(() => {
setIsPopoverOpen(true);
}, []);
const handleOnToggleOnlyFavorites = useCallback(() => {
setOnlyFavorites(!onlyFavorites);
}, [onlyFavorites]);
const renderTimelineOption = useCallback((option, searchValue) => {
return (
<>
{option.checked === 'on' && <EuiIcon type="check" color="primary" />}
<MyEuiHighlight search={searchValue} selected={option.checked === 'on'}>
{isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title}
</MyEuiHighlight>
<br />
<MyEuiTextColor color="subdued" component="span" selected={option.checked === 'on'}>
<small>
{option.description != null && option.description.trim().length > 0
? option.description
: getEmptyTagValue()}
</small>
</MyEuiTextColor>
</>
);
}, []);
const handleTimelineChange = useCallback(options => {
const selectedTimeline = options.filter(
(option: { checked: string }) => option.checked === 'on'
);
if (selectedTimeline != null && selectedTimeline.length > 0 && onTimelineChange != null) {
onTimelineChange(
isEmpty(selectedTimeline[0].title)
? i18nTimeline.UNTITLED_TIMELINE
: selectedTimeline[0].title,
selectedTimeline[0].id
);
}
setIsPopoverOpen(false);
}, []);
const handleOnScroll = useCallback(
(
totalTimelines: number,
totalCount: number,
{
clientHeight,
scrollHeight,
scrollTop,
}: {
clientHeight: number;
scrollHeight: number;
scrollTop: number;
}
) => {
if (totalTimelines < totalCount) {
const clientHeightTrigger = clientHeight * 1.2;
if (
scrollTop > 10 &&
scrollHeight - scrollTop < clientHeightTrigger &&
scrollHeight > heightTrigger
) {
setHeightTrigger(scrollHeight);
setPageSize(pageSize + ORIGINAL_PAGE_SIZE);
}
}
},
[heightTrigger, pageSize]
);
const superSelect = useMemo(
() => (
<EuiSuperSelect
disabled={isDisabled}
onFocus={handleOpenPopover}
options={
timelineId == null
? basicSuperSelectOptions
: [
{
value: timelineId,
inputDisplay: timelineTitle,
},
]
}
valueOfSelected={timelineId == null ? '-1' : timelineId}
itemLayoutAlign="top"
hasDividers={false}
popoverClassName="timeline-search-super-select-popover"
/>
),
[handleOpenPopover, isDisabled, timelineId, timelineTitle]
);
return (
<EuiInputPopover
id="searchTimelinePopover"
input={superSelect}
isOpen={isPopoverOpen}
closePopover={handleClosePopover}
>
<AllTimelinesQuery
pageInfo={{
pageIndex: 1,
pageSize,
}}
search={searchTimelineValue}
sort={{ sortField: SortFieldTimeline.updated, sortOrder: Direction.desc }}
onlyUserFavorite={onlyFavorites}
>
{({ timelines, loading, totalCount }) => (
<>
<EuiFlexGroup gutterSize="s" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiFilterGroup>
<EuiFilterButton
size="xs"
data-test-subj="only-favorites-toggle"
hasActiveFilters={onlyFavorites}
onClick={handleOnToggleOnlyFavorites}
>
{i18nTimeline.ONLY_FAVORITES}
</EuiFilterButton>
</EuiFilterGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xs" />
<EuiSelectable
height={POPOVER_HEIGHT}
isLoading={loading && timelines.length === 0}
listProps={{
rowHeight: TIMELINE_ITEM_HEIGHT,
showIcons: false,
virtualizedProps: ({
onScroll: handleOnScroll.bind(null, timelines.length, totalCount),
} as unknown) as ListProps,
}}
renderOption={renderTimelineOption}
onChange={handleTimelineChange}
searchable
searchProps={{
'data-test-subj': 'timeline-super-select-search-box',
isLoading: loading,
placeholder: i18n.SEARCH_BOX_TIMELINE_PLACEHOLDER,
onSearch: onSearchTimeline,
incremental: false,
}}
singleSelection={true}
options={[
...(!onlyFavorites && searchTimelineValue === ''
? getBasicSelectableOptions(timelineId == null ? '-1' : timelineId)
: []),
...timelines.map(
(t, index) =>
({
description: t.description,
label: t.title,
id: t.savedObjectId,
key: `${t.title}-${index}`,
title: t.title,
checked: t.savedObjectId === timelineId ? 'on' : undefined,
} as Option)
),
]}
>
{(list, search) => (
<>
{search}
{list}
</>
)}
</EuiSelectable>
</>
)}
</AllTimelinesQuery>
<SearchTimelineSuperSelectGlobalStyle />
</EuiInputPopover>
);
};
export const SearchTimelineSuperSelect = memo(SearchTimelineSuperSelectComponent);

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const DEFAULT_TIMELINE_TITLE = i18n.translate('xpack.siem.timeline.defaultTimelineTitle', {
defaultMessage: 'Default blank timeline',
});
export const DEFAULT_TIMELINE_DESCRIPTION = i18n.translate(
'xpack.siem.timeline.defaultTimelineDescription',
{
defaultMessage: 'Timeline offered by default when creating new timeline.',
}
);
export const SEARCH_BOX_TIMELINE_PLACEHOLDER = i18n.translate(
'xpack.siem.timeline.searchBoxPlaceholder',
{
defaultMessage: 'e.g. timeline name or description',
}
);

View file

@ -70,8 +70,8 @@ export const RuleSchema = t.intersection([
risk_score: t.number,
rule_id: t.string,
severity: t.string,
type: t.string,
tags: t.array(t.string),
type: t.string,
to: t.string,
threats: t.array(t.unknown),
updated_at: t.string,
@ -79,6 +79,8 @@ export const RuleSchema = t.intersection([
}),
t.partial({
saved_id: t.string,
timeline_id: t.string,
timeline_title: t.string,
}),
]);

View file

@ -56,13 +56,13 @@ export const DetectionEngineContainer = React.memo<Props>(() => {
<Route exact path={`${detectionEnginePath}/rules`}>
<RulesComponent />
</Route>
<Route path={`${detectionEnginePath}/rules/create`}>
<Route exact path={`${detectionEnginePath}/rules/create`}>
<CreateRuleComponent />
</Route>
<Route exact path={`${detectionEnginePath}/rules/:ruleId`}>
<Route exact path={`${detectionEnginePath}/rules/id/:ruleId`}>
<RuleDetailsComponent signalsIndex={signalIndexName} />
</Route>
<Route path={`${detectionEnginePath}/rules/:ruleId/edit`}>
<Route exact path={`${detectionEnginePath}/rules/id/:ruleId/edit`}>
<EditRuleComponent />
</Route>
</>

View file

@ -17,7 +17,7 @@ import {
import { Action } from './reducer';
export const editRuleAction = (rule: Rule, history: H.History) => {
history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/${rule.id}/edit`);
history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${rule.id}/edit`);
};
export const runRuleAction = () => {};

View file

@ -13,7 +13,7 @@ export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[]
id: rule.id,
rule_id: rule.rule_id,
rule: {
href: `#/detection-engine/rules/${encodeURIComponent(rule.id)}`,
href: `#/detection-engine/rules/id/${encodeURIComponent(rule.id)}`,
name: rule.name,
status: 'Status Placeholder',
},

View file

@ -4,9 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButtonEmpty, EuiButtonIcon, EuiFormRow, EuiFieldText, EuiSpacer } from '@elastic/eui';
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiFieldText,
EuiSpacer,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { ChangeEvent, useCallback, useEffect, useState, useRef } from 'react';
import styled from 'styled-components';
import * as RuleI18n from '../../translations';
import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports';
@ -17,11 +26,26 @@ interface AddItemProps {
dataTestSubj: string;
idAria: string;
isDisabled: boolean;
validate?: (args: unknown) => boolean;
}
export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: AddItemProps) => {
const MyEuiFormRow = styled(EuiFormRow)`
.euiFormRow__labelWrapper {
.euiText {
padding-right: 32px;
}
}
`;
export const AddItem = ({
addText,
dataTestSubj,
field,
idAria,
isDisabled,
validate,
}: AddItemProps) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
// const [items, setItems] = useState(['']);
const [haveBeenKeyboardDeleted, setHaveBeenKeyboardDeleted] = useState(-1);
const inputsRef = useRef<HTMLInputElement[]>([]);
@ -104,7 +128,7 @@ export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: Ad
const values = field.value as string[];
return (
<EuiFormRow
<MyEuiFormRow
label={field.label}
labelAppend={field.labelAppend}
error={errorMessage}
@ -124,11 +148,15 @@ export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: Ad
inputsRef.current[index] == null
? { value: item }
: {}),
isInvalid: validate == null ? false : validate(item),
};
return (
<div key={index}>
<EuiFieldText
append={
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow>
<EuiFieldText onChange={e => updateItem(e, index)} fullWidth {...euiFieldProps} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="danger"
iconType="trash"
@ -136,21 +164,18 @@ export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: Ad
onClick={() => removeItem(index)}
aria-label={RuleI18n.DELETE}
/>
}
onChange={e => updateItem(e, index)}
compressed
fullWidth
{...euiFieldProps}
/>
</EuiFlexItem>
</EuiFlexGroup>
{values.length - 1 !== index && <EuiSpacer size="s" />}
</div>
);
})}
<EuiButtonEmpty size="xs" onClick={addItem} isDisabled={isDisabled}>
<EuiButtonEmpty size="xs" onClick={addItem} isDisabled={isDisabled} iconType="plusInCircle">
{addText}
</EuiButtonEmpty>
</>
</EuiFormRow>
</MyEuiFormRow>
);
};

View file

@ -0,0 +1,222 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiBadge,
EuiLoadingSpinner,
EuiFlexGroup,
EuiFlexItem,
EuiHealth,
EuiLink,
EuiText,
EuiListGroup,
} from '@elastic/eui';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { isEmpty } from 'lodash/fp';
import React from 'react';
import styled from 'styled-components';
import { esFilters } from '../../../../../../../../../../src/plugins/data/public';
import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques';
import { FilterLabel } from './filter_label';
import * as i18n from './translations';
import { BuildQueryBarDescription, BuildThreatsDescription, ListItems } from './types';
const EuiBadgeWrap = styled(EuiBadge)`
.euiBadge__text {
white-space: pre-wrap !important;
}
`;
export const buildQueryBarDescription = ({
field,
filters,
filterManager,
query,
savedId,
indexPatterns,
}: BuildQueryBarDescription): ListItems[] => {
let items: ListItems[] = [];
if (!isEmpty(filters)) {
filterManager.setFilters(filters);
items = [
...items,
{
title: <>{i18n.FILTERS_LABEL} </>,
description: (
<EuiFlexGroup wrap responsive={false} gutterSize="xs">
{filterManager.getFilters().map((filter, index) => (
<EuiFlexItem grow={false} key={`${field}-filter-${index}`}>
<EuiBadgeWrap color="hollow">
{indexPatterns != null ? (
<FilterLabel
filter={filter}
valueLabel={esFilters.getDisplayValueFromFilter(filter, [indexPatterns])}
/>
) : (
<EuiLoadingSpinner size="m" />
)}
</EuiBadgeWrap>
</EuiFlexItem>
))}
</EuiFlexGroup>
),
},
];
}
if (!isEmpty(query.query)) {
items = [
...items,
{
title: <>{i18n.QUERY_LABEL} </>,
description: <>{query.query} </>,
},
];
}
if (!isEmpty(savedId)) {
items = [
...items,
{
title: <>{i18n.SAVED_ID_LABEL} </>,
description: <>{savedId} </>,
},
];
}
return items;
};
const ThreatsEuiFlexGroup = styled(EuiFlexGroup)`
.euiFlexItem {
margin-bottom: 0px;
}
`;
const MyEuiListGroup = styled(EuiListGroup)`
padding: 0px;
.euiListGroupItem__button {
padding: 0px;
}
`;
export const buildThreatsDescription = ({
label,
threats,
}: BuildThreatsDescription): ListItems[] => {
if (threats.length > 0) {
return [
{
title: label,
description: (
<ThreatsEuiFlexGroup direction="column">
{threats.map((threat, index) => {
const tactic = tacticsOptions.find(t => t.name === threat.tactic.name);
return (
<EuiFlexItem key={`${threat.tactic.name}-${index}`}>
<EuiText grow={false} size="s">
<h5>
<EuiLink href={threat.tactic.reference} target="_blank">
{tactic != null ? tactic.text : ''}
</EuiLink>
</h5>
<MyEuiListGroup
flush={false}
bordered={false}
listItems={threat.techniques.map(technique => {
const myTechnique = techniquesOptions.find(t => t.name === technique.name);
return {
label: myTechnique != null ? myTechnique.label : '',
href: technique.reference,
target: '_blank',
};
})}
/>
</EuiText>
</EuiFlexItem>
);
})}
</ThreatsEuiFlexGroup>
),
},
];
}
return [];
};
export const buildStringArrayDescription = (
label: string,
field: string,
values: string[]
): ListItems[] => {
if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) {
return [
{
title: label,
description: (
<EuiFlexGroup responsive={false} gutterSize="xs" wrap>
{values.map((val: string) =>
isEmpty(val) ? null : (
<EuiFlexItem grow={false} key={`${field}-${val}`}>
<EuiBadgeWrap color="hollow">{val}</EuiBadgeWrap>
</EuiFlexItem>
)
)}
</EuiFlexGroup>
),
},
];
}
return [];
};
export const buildSeverityDescription = (label: string, value: string): ListItems[] => {
return [
{
title: label,
description: (
<EuiHealth
color={
value === 'low'
? euiLightVars.euiColorVis0
: value === 'medium'
? euiLightVars.euiColorVis5
: value === 'high'
? euiLightVars.euiColorVis7
: euiLightVars.euiColorVis9
}
>
{value}
</EuiHealth>
),
},
];
};
export const buildUrlsDescription = (label: string, values: string[]): ListItems[] => {
if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) {
return [
{
title: label,
description: (
<EuiListGroup
flush={true}
bordered={false}
listItems={values.map((val: string) => ({
label: val,
href: val,
iconType: 'link',
size: 'xs',
target: '_blank',
}))}
/>
),
},
];
}
return [];
};

View file

@ -4,19 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiBadge,
EuiDescriptionList,
EuiLoadingSpinner,
EuiFlexGroup,
EuiFlexItem,
EuiTextArea,
EuiLink,
EuiText,
EuiListGroup,
} from '@elastic/eui';
import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiTextArea } from '@elastic/eui';
import { isEmpty, chunk, get, pick } from 'lodash/fp';
import React, { memo, ReactNode, useState } from 'react';
import React, { memo, useState } from 'react';
import styled from 'styled-components';
import {
@ -25,13 +15,19 @@ import {
FilterManager,
Query,
} from '../../../../../../../../../../src/plugins/data/public';
import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/search_super_select/translations';
import { useKibana } from '../../../../../lib/kibana';
import { FilterLabel } from './filter_label';
import { FormSchema } from '../shared_imports';
import * as I18n from './translations';
import { IMitreEnterpriseAttack } from '../../types';
import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques';
import { FieldValueTimeline } from '../pick_timeline';
import { FormSchema } from '../shared_imports';
import { ListItems } from './types';
import {
buildQueryBarDescription,
buildSeverityDescription,
buildStringArrayDescription,
buildThreatsDescription,
buildUrlsDescription,
} from './helpers';
interface StepRuleDescriptionProps {
direction?: 'row' | 'column';
@ -40,29 +36,10 @@ interface StepRuleDescriptionProps {
schema: FormSchema;
}
const EuiBadgeWrap = styled(EuiBadge)`
.euiBadge__text {
white-space: pre-wrap !important;
}
`;
const EuiFlexItemWidth = styled(EuiFlexItem)<{ direction: string }>`
${props => (props.direction === 'row' ? 'width : 50%;' : 'width: 100%;')};
`;
const MyEuiListGroup = styled(EuiListGroup)`
padding: 0px;
.euiListGroupItem__button {
padding: 0px;
}
`;
const ThreatsEuiFlexGroup = styled(EuiFlexGroup)`
.euiFlexItem {
margin-bottom: 0px;
}
`;
const MyEuiTextArea = styled(EuiTextArea)`
max-width: 100%;
height: 80px;
@ -87,9 +64,9 @@ const StepRuleDescriptionComponent: React.FC<StepRuleDescriptionProps> = ({
);
return (
<EuiFlexGroup gutterSize="none" direction={direction} justifyContent="spaceAround">
{chunk(Math.ceil(listItems.length / 2), listItems).map((chunckListItems, index) => (
{chunk(Math.ceil(listItems.length / 2), listItems).map((chunkListItems, index) => (
<EuiFlexItemWidth direction={direction} key={`description-step-rule-${index}`} grow={false}>
<EuiDescriptionList listItems={chunckListItems} compressed />
<EuiDescriptionList listItems={chunkListItems} />
</EuiFlexItemWidth>
))}
</EuiFlexGroup>
@ -98,11 +75,6 @@ const StepRuleDescriptionComponent: React.FC<StepRuleDescriptionProps> = ({
export const StepRuleDescription = memo(StepRuleDescriptionComponent);
interface ListItems {
title: NonNullable<ReactNode>;
description: NonNullable<ReactNode>;
}
const buildListItems = (
data: unknown,
schema: FormSchema,
@ -130,103 +102,23 @@ const getDescriptionItem = (
filterManager: FilterManager,
indexPatterns?: IIndexPattern
): ListItems[] => {
if (field === 'useIndicesConfig') {
return [];
} else if (field === 'queryBar') {
if (field === 'queryBar') {
const filters = get('queryBar.filters', value) as esFilters.Filter[];
const query = get('queryBar.query', value) as Query;
const savedId = get('queryBar.saved_id', value);
let items: ListItems[] = [];
if (!isEmpty(filters)) {
filterManager.setFilters(filters);
items = [
...items,
{
title: <>{I18n.FILTERS_LABEL}</>,
description: (
<EuiFlexGroup wrap responsive={false} gutterSize="xs">
{filterManager.getFilters().map((filter, index) => (
<EuiFlexItem grow={false} key={`${field}-filter-${index}`}>
<EuiBadgeWrap color="hollow">
{indexPatterns != null ? (
<FilterLabel
filter={filter}
valueLabel={esFilters.getDisplayValueFromFilter(filter, [indexPatterns])}
/>
) : (
<EuiLoadingSpinner size="m" />
)}
</EuiBadgeWrap>
</EuiFlexItem>
))}
</EuiFlexGroup>
),
},
];
}
if (!isEmpty(query.query)) {
items = [
...items,
{
title: <>{I18n.QUERY_LABEL}</>,
description: <>{query.query}</>,
},
];
}
if (!isEmpty(savedId)) {
items = [
...items,
{
title: <>{I18n.SAVED_ID_LABEL}</>,
description: <>{savedId}</>,
},
];
}
return items;
return buildQueryBarDescription({
field,
filters,
filterManager,
query,
savedId,
indexPatterns,
});
} else if (field === 'threats') {
const threats: IMitreEnterpriseAttack[] = get(field, value).filter(
(threat: IMitreEnterpriseAttack) => threat.tactic.name !== 'none'
);
if (threats.length > 0) {
return [
{
title: label,
description: (
<ThreatsEuiFlexGroup direction="column">
{threats.map((threat, index) => {
const tactic = tacticsOptions.find(t => t.name === threat.tactic.name);
return (
<EuiFlexItem key={`${threat.tactic.name}-${index}`}>
<EuiText grow={false} size="s">
<h5>
<EuiLink href={threat.tactic.reference} target="_blank">
{tactic != null ? tactic.text : ''}
</EuiLink>
</h5>
<MyEuiListGroup
flush={false}
bordered={false}
listItems={threat.techniques.map(technique => {
const myTechnique = techniquesOptions.find(
t => t.name === technique.name
);
return {
label: myTechnique != null ? myTechnique.label : '',
href: technique.reference,
target: '_blank',
};
})}
/>
</EuiText>
</EuiFlexItem>
);
})}
</ThreatsEuiFlexGroup>
),
},
];
}
return [];
return buildThreatsDescription({ label, threats });
} else if (field === 'description') {
return [
{
@ -234,27 +126,23 @@ const getDescriptionItem = (
description: <MyEuiTextArea value={get(field, value)} readOnly={true} />,
},
];
} else if (field === 'references') {
const urls: string[] = get(field, value);
return buildUrlsDescription(label, urls);
} else if (Array.isArray(get(field, value))) {
const values: string[] = get(field, value);
if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) {
return [
{
title: label,
description: (
<EuiFlexGroup responsive={false} gutterSize="xs" wrap>
{values.map((val: string) =>
isEmpty(val) ? null : (
<EuiFlexItem grow={false} key={`${field}-${val}`}>
<EuiBadgeWrap color="hollow">{val}</EuiBadgeWrap>
</EuiFlexItem>
)
)}
</EuiFlexGroup>
),
},
];
}
return [];
return buildStringArrayDescription(label, field, values);
} else if (field === 'severity') {
const val: string = get(field, value);
return buildSeverityDescription(label, val);
} else if (field === 'timeline') {
const timeline = get(field, value) as FieldValueTimeline;
return [
{
title: label,
description: timeline.title ?? DEFAULT_TIMELINE_TITLE,
},
];
}
const description: string = get(field, value);
if (!isEmpty(description)) {

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ReactNode } from 'react';
import {
IIndexPattern,
esFilters,
FilterManager,
Query,
} from '../../../../../../../../../../src/plugins/data/public';
import { IMitreEnterpriseAttack } from '../../types';
export interface ListItems {
title: NonNullable<ReactNode>;
description: NonNullable<ReactNode>;
}
export interface BuildQueryBarDescription {
field: string;
filters: esFilters.Filter[];
filterManager: FilterManager;
query: Query;
savedId: string;
indexPatterns?: IIndexPattern;
}
export interface BuildThreatsDescription {
label: string;
threats: IMitreEnterpriseAttack[];
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isEmpty } from 'lodash/fp';
import { IMitreAttack } from '../../types';
export const isMitreAttackInvalid = (
tacticName: string | null | undefined,
techniques: IMitreAttack[] | null | undefined
) => {
if (isEmpty(tacticName) || (tacticName !== 'none' && isEmpty(techniques))) {
return true;
}
return false;
};

View file

@ -8,27 +8,30 @@ import {
EuiButtonEmpty,
EuiButtonIcon,
EuiFormRow,
EuiSelect,
EuiSuperSelect,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiComboBox,
EuiFormControlLayout,
EuiText,
} from '@elastic/eui';
import { isEmpty, kebabCase, camelCase } from 'lodash/fp';
import React, { ChangeEvent, useCallback } from 'react';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques';
import * as RuleI18n from '../../translations';
import * as Rulei18n from '../../translations';
import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports';
import * as I18n from './translations';
import { threatsDefault } from '../step_about_rule/default_value';
import { IMitreEnterpriseAttack } from '../../types';
import { isMitreAttackInvalid } from './helpers';
import * as i18n from './translations';
const MyEuiFormControlLayout = styled(EuiFormControlLayout)`
&.euiFormControlLayout--compressed {
height: fit-content !important;
}
const MitreContainer = styled.div`
margin-top: 16px;
`;
const MyEuiSuperSelect = styled(EuiSuperSelect)`
width: 280px;
`;
interface AddItemProps {
field: FieldHook;
@ -43,7 +46,12 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI
const removeItem = useCallback(
(index: number) => {
const values = field.value as string[];
field.setValue([...values.slice(0, index), ...values.slice(index + 1)]);
const newValues = [...values.slice(0, index), ...values.slice(index + 1)];
if (isEmpty(newValues)) {
field.setValue(threatsDefault);
} else {
field.setValue(newValues);
}
},
[field]
);
@ -61,9 +69,9 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI
}, [field]);
const updateTactic = useCallback(
(index: number, event: ChangeEvent<HTMLSelectElement>) => {
(index: number, value: string) => {
const values = field.value as IMitreEnterpriseAttack[];
const { id, reference, name } = tacticsOptions.find(t => t.value === event.target.value) || {
const { id, reference, name } = tacticsOptions.find(t => t.value === value) || {
id: '',
name: '',
reference: '',
@ -97,75 +105,104 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI
const values = field.value as IMitreEnterpriseAttack[];
const getSelectTactic = (tacticName: string, index: number, disabled: boolean) => (
<MyEuiSuperSelect
id="selectDocExample"
options={[
...(tacticName === 'none'
? [
{
inputDisplay: <>{i18n.TACTIC_PLACEHOLDER}</>,
value: 'none',
disabled,
},
]
: []),
...tacticsOptions.map(t => ({
inputDisplay: <>{t.text}</>,
value: t.value,
disabled,
})),
]}
aria-label=""
onChange={updateTactic.bind(null, index)}
fullWidth={false}
valueOfSelected={camelCase(tacticName)}
/>
);
const getSelectTechniques = (item: IMitreEnterpriseAttack, index: number, disabled: boolean) => {
const invalid = isMitreAttackInvalid(item.tactic.name, item.techniques);
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow>
<EuiComboBox
placeholder={i18n.TECHNIQUES_PLACEHOLDER}
options={techniquesOptions.filter(t => t.tactics.includes(kebabCase(item.tactic.name)))}
selectedOptions={item.techniques}
onChange={updateTechniques.bind(null, index)}
isDisabled={disabled}
fullWidth={true}
isInvalid={invalid}
/>
{invalid && (
<EuiText color="danger" size="xs">
<p>{errorMessage}</p>
</EuiText>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="danger"
iconType="trash"
isDisabled={disabled}
onClick={() => removeItem(index)}
aria-label={Rulei18n.DELETE}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
return (
<EuiFormRow
label={field.label}
labelAppend={field.labelAppend}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={dataTestSubj}
describedByIds={idAria ? [idAria] : undefined}
>
<>
{values.map((item, index) => {
const euiSelectFieldProps = {
disabled: isDisabled,
};
return (
<div key={index}>
<EuiFlexGroup gutterSize="xs" justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiSelect
id="selectDocExample"
options={[
...(item.tactic.name === 'none'
? [{ text: I18n.TACTIC_PLACEHOLDER, value: 'none' }]
: []),
...tacticsOptions.map(t => ({ text: t.text, value: t.value })),
]}
aria-label=""
onChange={updateTactic.bind(null, index)}
prepend={I18n.TACTIC}
compressed
fullWidth={false}
value={camelCase(item.tactic.name)}
{...euiSelectFieldProps}
/>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<MyEuiFormControlLayout compressed fullWidth prepend={I18n.TECHNIQUES}>
<EuiComboBox
compressed
placeholder={I18n.TECHNIQUES_PLACEHOLDER}
options={techniquesOptions.filter(t =>
t.tactics.includes(kebabCase(item.tactic.name))
)}
selectedOptions={item.techniques}
onChange={updateTechniques.bind(null, index)}
isDisabled={isDisabled}
fullWidth={true}
/>
</MyEuiFormControlLayout>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="danger"
iconType="trash"
isDisabled={isDisabled}
onClick={() => removeItem(index)}
aria-label={RuleI18n.DELETE}
/>
</EuiFlexItem>
</EuiFlexGroup>
{values.length - 1 !== index && <EuiSpacer size="s" />}
</div>
);
})}
<EuiButtonEmpty size="xs" onClick={addItem} isDisabled={isDisabled}>
{I18n.ADD_MITRE_ATTACK}
</EuiButtonEmpty>
</>
</EuiFormRow>
<MitreContainer>
{values.map((item, index) => (
<div key={index}>
<EuiFlexGroup gutterSize="s" justifyContent="spaceBetween" alignItems="flexStart">
<EuiFlexItem grow={false}>
{index === 0 ? (
<EuiFormRow
label={`${field.label} ${i18n.TACTIC}`}
labelAppend={field.labelAppend}
describedByIds={idAria ? [`${idAria} ${i18n.TACTIC}`] : undefined}
>
<>{getSelectTactic(item.tactic.name, index, isDisabled)}</>
</EuiFormRow>
) : (
getSelectTactic(item.tactic.name, index, isDisabled)
)}
</EuiFlexItem>
<EuiFlexItem grow>
{index === 0 ? (
<EuiFormRow
label={`${field.label} ${i18n.TECHNIQUE}`}
isInvalid={isInvalid}
fullWidth
describedByIds={idAria ? [`${idAria} ${i18n.TECHNIQUE}`] : undefined}
>
<>{getSelectTechniques(item, index, isDisabled)}</>
</EuiFormRow>
) : (
getSelectTechniques(item, index, isDisabled)
)}
</EuiFlexItem>
</EuiFlexGroup>
{values.length - 1 !== index && <EuiSpacer size="s" />}
</div>
))}
<EuiButtonEmpty size="xs" onClick={addItem} isDisabled={isDisabled} iconType="plusInCircle">
{i18n.ADD_MITRE_ATTACK}
</EuiButtonEmpty>
</MitreContainer>
);
};

View file

@ -7,13 +7,13 @@
import { i18n } from '@kbn/i18n';
export const TACTIC = i18n.translate('xpack.siem.detectionEngine.mitreAttack.tacticsDescription', {
defaultMessage: 'Tactic',
defaultMessage: 'tactic',
});
export const TECHNIQUES = i18n.translate(
export const TECHNIQUE = i18n.translate(
'xpack.siem.detectionEngine.mitreAttack.techniquesDescription',
{
defaultMessage: 'Techniques',
defaultMessage: 'technique',
}
);

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFormRow } from '@elastic/eui';
import React, { useCallback, useEffect, useState } from 'react';
import { SearchTimelineSuperSelect } from '../../../../../components/timeline/search_super_select';
import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports';
export interface FieldValueTimeline {
id: string | null;
title: string | null;
}
interface QueryBarDefineRuleProps {
dataTestSubj: string;
field: FieldHook;
idAria: string;
isDisabled: boolean;
}
export const PickTimeline = ({
dataTestSubj,
field,
idAria,
isDisabled = false,
}: QueryBarDefineRuleProps) => {
const [timelineId, setTimelineId] = useState<string | null>(null);
const [timelineTitle, setTimelineTitle] = useState<string | null>(null);
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
useEffect(() => {
const { id, title } = field.value as FieldValueTimeline;
if (timelineTitle !== title && timelineId !== id) {
setTimelineId(id);
setTimelineTitle(title);
}
}, [field.value]);
const handleOnTimelineChange = useCallback(
(title: string, id: string | null) => {
if (id === null) {
field.setValue({ id, title: null });
} else if (timelineTitle !== title && timelineId !== id) {
field.setValue({ id, title });
}
},
[field]
);
return (
<EuiFormRow
label={field.label}
labelAppend={field.labelAppend}
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={dataTestSubj}
describedByIds={idAria ? [idAria] : undefined}
>
<SearchTimelineSuperSelect
isDisabled={isDisabled}
timelineId={timelineId}
timelineTitle={timelineTitle}
onTimelineChange={handleOnTimelineChange}
/>
</EuiFormRow>
);
};

View file

@ -6,7 +6,7 @@
import { EuiFormRow, EuiMutationObserver } from '@elastic/eui';
import { isEqual } from 'lodash/fp';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Subscription } from 'rxjs';
import styled from 'styled-components';
@ -19,11 +19,18 @@ import {
SavedQueryTimeFilter,
} from '../../../../../../../../../../src/plugins/data/public';
import { BrowserFields } from '../../../../../containers/source';
import { OpenTimelineModal } from '../../../../../components/open_timeline/open_timeline_modal';
import { ActionTimelineToShow } from '../../../../../components/open_timeline/types';
import { QueryBar } from '../../../../../components/query_bar';
import { buildGlobalQuery } from '../../../../../components/timeline/helpers';
import { getDataProviderFilter } from '../../../../../components/timeline/query_bar';
import { convertKueryToElasticSearchQuery } from '../../../../../lib/keury';
import { useKibana } from '../../../../../lib/kibana';
import { TimelineModel } from '../../../../../store/timeline/model';
import { useSavedQueryServices } from '../../../../../utils/saved_query_services';
import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports';
import * as i18n from './translations';
export interface FieldValueQueryBar {
filters: esFilters.Filter[];
@ -31,11 +38,14 @@ export interface FieldValueQueryBar {
saved_id: string | null;
}
interface QueryBarDefineRuleProps {
browserFields: BrowserFields;
dataTestSubj: string;
field: FieldHook;
idAria: string;
isLoading: boolean;
indexPattern: IIndexPattern;
onCloseTimelineSearch: () => void;
openTimelineSearch: boolean;
resizeParentContainer?: (height: number) => void;
}
@ -56,14 +66,18 @@ const StyledEuiFormRow = styled(EuiFormRow)`
// TODO need to add disabled in the SearchBar
export const QueryBarDefineRule = ({
browserFields,
dataTestSubj,
field,
idAria,
indexPattern,
isLoading = false,
onCloseTimelineSearch,
openTimelineSearch = false,
resizeParentContainer,
}: QueryBarDefineRuleProps) => {
const [originalHeight, setOriginalHeight] = useState(-1);
const [loadingTimeline, setLoadingTimeline] = useState(false);
const [savedQuery, setSavedQuery] = useState<SavedQuery | null>(null);
const [queryDraft, setQueryDraft] = useState<Query>({ query: '', language: 'kuery' });
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
@ -168,6 +182,38 @@ export const QueryBarDefineRule = ({
[field.value]
);
const onCloseTimelineModal = useCallback(() => {
setLoadingTimeline(true);
onCloseTimelineSearch();
}, [onCloseTimelineSearch]);
const onOpenTimeline = useCallback(
(timeline: TimelineModel) => {
setLoadingTimeline(false);
const newQuery = {
query: timeline.kqlQuery.filterQuery?.kuery?.expression ?? '',
language: timeline.kqlQuery.filterQuery?.kuery?.kind ?? 'kuery',
};
const dataProvidersDsl =
timeline.dataProviders != null && timeline.dataProviders.length > 0
? convertKueryToElasticSearchQuery(
buildGlobalQuery(timeline.dataProviders, browserFields),
indexPattern
)
: '';
const newFilters = timeline.filters ?? [];
field.setValue({
filters:
dataProvidersDsl !== ''
? [...newFilters, getDataProviderFilter(dataProvidersDsl)]
: newFilters,
query: newQuery,
saved_id: '',
});
},
[browserFields, field, indexPattern]
);
const onMutation = (event: unknown, observer: unknown) => {
if (resizeParentContainer != null) {
const suggestionContainer = document.getElementById('kbnTypeahead__items');
@ -189,39 +235,51 @@ export const QueryBarDefineRule = ({
}
};
const actionTimelineToHide = useMemo<ActionTimelineToShow[]>(() => ['duplicate'], []);
return (
<StyledEuiFormRow
label={field.label}
labelAppend={field.labelAppend}
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={dataTestSubj}
describedByIds={idAria ? [idAria] : undefined}
>
<EuiMutationObserver
observerOptions={{ subtree: true, attributes: true, childList: true }}
onMutation={onMutation}
<>
<StyledEuiFormRow
label={field.label}
labelAppend={field.labelAppend}
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={dataTestSubj}
describedByIds={idAria ? [idAria] : undefined}
>
{mutationRef => (
<div ref={mutationRef}>
<QueryBar
indexPattern={indexPattern}
isLoading={isLoading}
isRefreshPaused={false}
filterQuery={queryDraft}
filterManager={filterManager}
filters={filterManager.getFilters() || []}
onChangedQuery={onChangedQuery}
onSubmitQuery={onSubmitQuery}
savedQuery={savedQuery}
onSavedQuery={onSavedQuery}
hideSavedQuery={false}
/>
</div>
)}
</EuiMutationObserver>
</StyledEuiFormRow>
<EuiMutationObserver
observerOptions={{ subtree: true, attributes: true, childList: true }}
onMutation={onMutation}
>
{mutationRef => (
<div ref={mutationRef}>
<QueryBar
indexPattern={indexPattern}
isLoading={isLoading || loadingTimeline}
isRefreshPaused={false}
filterQuery={queryDraft}
filterManager={filterManager}
filters={filterManager.getFilters() || []}
onChangedQuery={onChangedQuery}
onSubmitQuery={onSubmitQuery}
savedQuery={savedQuery}
onSavedQuery={onSavedQuery}
hideSavedQuery={false}
/>
</div>
)}
</EuiMutationObserver>
</StyledEuiFormRow>
{openTimelineSearch ? (
<OpenTimelineModal
hideActions={actionTimelineToHide}
modalTitle={i18n.IMPORT_TIMELINE_MODAL}
onClose={onCloseTimelineModal}
onOpen={onOpenTimeline}
/>
) : null}
</>
);
};

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const IMPORT_TIMELINE_MODAL = i18n.translate(
'xpack.siem.detectionEngine.createRule.stepDefineRule.importTimelineModalTitle',
{
defaultMessage: 'Import query from saved timeline',
}
);

View file

@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFieldNumber, EuiFormRow, EuiSelect } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiFieldNumber, EuiFormRow, EuiSelect } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports';
@ -32,6 +32,10 @@ const StyledEuiFormRow = styled(EuiFormRow)`
}
`;
const MyEuiSelect = styled(EuiSelect)`
width: auto;
`;
export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: ScheduleItemProps) => {
const [timeType, setTimeType] = useState('s');
const [timeVal, setTimeVal] = useState<number>(0);
@ -79,22 +83,33 @@ export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: Schedu
// EUI missing some props
const rest = { disabled: isDisabled };
const label = useMemo(
() => (
<EuiFlexGroup gutterSize="s" justifyContent="flexStart" alignItems="center">
<EuiFlexItem grow={false} component="span">
{field.label}
</EuiFlexItem>
<EuiFlexItem grow={false} component="span">
{field.labelAppend}
</EuiFlexItem>
</EuiFlexGroup>
),
[field.label, field.labelAppend]
);
return (
<StyledEuiFormRow
label={field.label}
labelAppend={field.labelAppend}
label={label}
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
fullWidth={false}
data-test-subj={dataTestSubj}
describedByIds={idAria ? [idAria] : undefined}
>
<EuiFieldNumber
append={
<EuiSelect
compressed={true}
<MyEuiSelect
fullWidth={false}
options={timeTypeOptions}
onChange={onChangeTimeType}
@ -102,7 +117,6 @@ export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: Schedu
{...rest}
/>
}
compressed
fullWidth
min={0}
onChange={onChangeTimeVal}

View file

@ -27,7 +27,7 @@ const RuleStatusIconStyled = styled.div`
const RuleStatusIconComponent: React.FC<RuleStatusIconProps> = ({ name, type }) => {
const theme = useEuiTheme();
const color = type === 'passive' ? theme.euiColorLightestShade : theme.euiColorDarkestShade;
const color = type === 'passive' ? theme.euiColorLightestShade : theme.euiColorPrimary;
return (
<RuleStatusIconStyled>
<EuiAvatar color={color} name={type === 'valid' ? '' : name} size="l" />

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;
* you may not use this file except in compliance with the Elastic License.
*/
import * as I18n from './translations';
export type SeverityValue = 'low' | 'medium' | 'high' | 'critical';
interface SeverityOptionItem {
value: SeverityValue;
text: string;
}
export const severityOptions: SeverityOptionItem[] = [
{ value: 'low', text: I18n.LOW },
{ value: 'medium', text: I18n.MEDIUM },
{ value: 'high', text: I18n.HIGH },
{ value: 'critical', text: I18n.CRITICAL },
];
export const defaultRiskScoreBySeverity: Record<SeverityValue, number> = {
low: 21,
medium: 47,
high: 73,
critical: 99,
};

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiHealth } from '@elastic/eui';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import React from 'react';
import * as I18n from './translations';
export type SeverityValue = 'low' | 'medium' | 'high' | 'critical';
interface SeverityOptionItem {
value: SeverityValue;
inputDisplay: React.ReactElement;
}
export const severityOptions: SeverityOptionItem[] = [
{
value: 'low',
inputDisplay: <EuiHealth color={euiLightVars.euiColorVis0}>{I18n.LOW}</EuiHealth>,
},
{
value: 'medium',
inputDisplay: <EuiHealth color={euiLightVars.euiColorVis5}>{I18n.MEDIUM} </EuiHealth>,
},
{
value: 'high',
inputDisplay: <EuiHealth color={euiLightVars.euiColorVis7}>{I18n.HIGH} </EuiHealth>,
},
{
value: 'critical',
inputDisplay: <EuiHealth color={euiLightVars.euiColorVis9}>{I18n.CRITICAL} </EuiHealth>,
},
];
export const defaultRiskScoreBySeverity: Record<SeverityValue, number> = {
low: 21,
medium: 47,
high: 73,
critical: 99,
};

View file

@ -6,6 +6,14 @@
import { AboutStepRule } from '../../types';
export const threatsDefault = [
{
framework: 'MITRE ATT&CK',
tactic: { id: 'none', name: 'none', reference: 'none' },
techniques: [],
},
];
export const stepAboutDefaultValue: AboutStepRule = {
name: '',
description: '',
@ -15,11 +23,9 @@ export const stepAboutDefaultValue: AboutStepRule = {
references: [''],
falsePositives: [''],
tags: [],
threats: [
{
framework: 'MITRE ATT&CK',
tactic: { id: 'none', name: 'none', reference: 'none' },
techniques: [],
},
],
timeline: {
id: null,
title: null,
},
threats: threatsDefault,
};

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isEmpty } from 'lodash/fp';
const urlExpression = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi;
export const isUrlInvalid = (url: string | null | undefined) => {
if (!isEmpty(url) && url != null && url.match(urlExpression) == null) {
return true;
}
return false;
};

View file

@ -7,17 +7,21 @@
import { EuiButton, EuiHorizontalRule, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { isEqual, get } from 'lodash/fp';
import React, { memo, useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { RuleStepProps, RuleStep, AboutStepRule } from '../../types';
import * as RuleI18n from '../../translations';
import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports';
import { AddItem } from '../add_item_form';
import { defaultRiskScoreBySeverity, severityOptions, SeverityValue } from './data';
import { stepAboutDefaultValue } from './default_value';
import { schema } from './schema';
import * as I18n from './translations';
import { StepRuleDescription } from '../description_step';
import { AddMitreThreat } from '../mitre';
import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports';
import { defaultRiskScoreBySeverity, severityOptions, SeverityValue } from './data';
import { stepAboutDefaultValue } from './default_value';
import { isUrlInvalid } from './helpers';
import { schema } from './schema';
import * as I18n from './translations';
import { PickTimeline } from '../pick_timeline';
const CommonUseField = getUseField({ component: Field });
@ -25,6 +29,10 @@ interface StepAboutRuleProps extends RuleStepProps {
defaultValues?: AboutStepRule | null;
}
const TagContainer = styled.div`
margin-top: 16px;
`;
export const StepAboutRule = memo<StepAboutRuleProps>(
({
defaultValues,
@ -90,7 +98,6 @@ export const StepAboutRule = memo<StepAboutRuleProps>(
idAria: 'detectionEngineStepAboutRuleName',
'data-test-subj': 'detectionEngineStepAboutRuleName',
euiFieldProps: {
compressed: true,
fullWidth: false,
disabled: isLoading,
},
@ -99,11 +106,9 @@ export const StepAboutRule = memo<StepAboutRuleProps>(
<CommonUseField
path="description"
componentProps={{
compressed: true,
idAria: 'detectionEngineStepAboutRuleDescription',
'data-test-subj': 'detectionEngineStepAboutRuleDescription',
euiFieldProps: {
compressed: true,
disabled: isLoading,
},
}}
@ -114,7 +119,6 @@ export const StepAboutRule = memo<StepAboutRuleProps>(
idAria: 'detectionEngineStepAboutRuleSeverity',
'data-test-subj': 'detectionEngineStepAboutRuleSeverity',
euiFieldProps: {
compressed: true,
fullWidth: false,
disabled: isLoading,
options: severityOptions,
@ -129,29 +133,38 @@ export const StepAboutRule = memo<StepAboutRuleProps>(
euiFieldProps: {
max: 100,
min: 0,
compressed: true,
fullWidth: false,
disabled: isLoading,
options: severityOptions,
showTicks: true,
tickInterval: 25,
},
}}
/>
<UseField
path="timeline"
component={PickTimeline}
componentProps={{
idAria: 'detectionEngineStepAboutRuleTimeline',
isDisabled: isLoading,
dataTestSubj: 'detectionEngineStepAboutRuleTimeline',
}}
/>
<UseField
path="references"
component={AddItem}
componentProps={{
compressed: true,
addText: I18n.ADD_REFERENCE,
idAria: 'detectionEngineStepAboutRuleReferenceUrls',
isDisabled: isLoading,
dataTestSubj: 'detectionEngineStepAboutRuleReferenceUrls',
validate: isUrlInvalid,
}}
/>
<UseField
path="falsePositives"
component={AddItem}
componentProps={{
compressed: true,
addText: I18n.ADD_FALSE_POSITIVE,
idAria: 'detectionEngineStepAboutRuleFalsePositives',
isDisabled: isLoading,
@ -162,24 +175,25 @@ export const StepAboutRule = memo<StepAboutRuleProps>(
path="threats"
component={AddMitreThreat}
componentProps={{
compressed: true,
idAria: 'detectionEngineStepAboutRuleMitreThreats',
isDisabled: isLoading,
dataTestSubj: 'detectionEngineStepAboutRuleMitreThreats',
}}
/>
<CommonUseField
path="tags"
componentProps={{
idAria: 'detectionEngineStepAboutRuleTags',
'data-test-subj': 'detectionEngineStepAboutRuleTags',
euiFieldProps: {
compressed: true,
fullWidth: true,
isDisabled: isLoading,
},
}}
/>
<TagContainer>
<CommonUseField
path="tags"
componentProps={{
idAria: 'detectionEngineStepAboutRuleTags',
'data-test-subj': 'detectionEngineStepAboutRuleTags',
euiFieldProps: {
fullWidth: true,
isDisabled: isLoading,
placeholder: '',
},
}}
/>
</TagContainer>
<FormDataProvider pathsToWatch="severity">
{({ severity }) => {
const newRiskScore = defaultRiskScoreBySeverity[severity as SeverityValue];
@ -202,7 +216,7 @@ export const StepAboutRule = memo<StepAboutRuleProps>(
>
<EuiFlexItem grow={false}>
<EuiButton fill onClick={onSubmit} isDisabled={isLoading}>
{myStepData.isNew ? RuleI18n.CONTINUE : RuleI18n.UPDATE}
{RuleI18n.CONTINUE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -6,7 +6,6 @@
import { EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash/fp';
import React from 'react';
import * as RuleI18n from '../../translations';
@ -18,6 +17,8 @@ import {
ValidationFunc,
ERROR_CODE,
} from '../shared_imports';
import { isMitreAttackInvalid } from '../mitre/helpers';
import { isUrlInvalid } from './helpers';
import * as I18n from './translations';
const { emptyField } = fieldValidators;
@ -63,7 +64,7 @@ export const schema: FormSchema = {
],
},
severity: {
type: FIELD_TYPES.SELECT,
type: FIELD_TYPES.SUPER_SELECT,
label: i18n.translate(
'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldSeverityLabel',
{
@ -92,6 +93,14 @@ export const schema: FormSchema = {
}
),
},
timeline: {
label: i18n.translate(
'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateLabel',
{
defaultMessage: 'Timeline template',
}
),
},
references: {
label: i18n.translate(
'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel',
@ -100,6 +109,28 @@ export const schema: FormSchema = {
}
),
labelAppend: <EuiText size="xs">{RuleI18n.OPTIONAL_FIELD}</EuiText>,
validations: [
{
validator: (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
const [{ value, path }] = args;
let hasError = false;
(value as string[]).forEach(url => {
if (isUrlInvalid(url)) {
hasError = true;
}
});
return hasError
? {
code: 'ERR_FIELD_FORMAT',
path,
message: I18n.URL_FORMAT_INVALID,
}
: undefined;
},
},
],
},
falsePositives: {
label: i18n.translate(
@ -126,7 +157,7 @@ export const schema: FormSchema = {
const [{ value, path }] = args;
let hasError = false;
(value as IMitreEnterpriseAttack[]).forEach(v => {
if (isEmpty(v.tactic.name) || (v.tactic.name !== 'none' && isEmpty(v.techniques))) {
if (isMitreAttackInvalid(v.tactic.name, v.techniques)) {
hasError = true;
}
});
@ -146,6 +177,13 @@ export const schema: FormSchema = {
label: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTagsLabel', {
defaultMessage: 'Tags',
}),
helpText: i18n.translate(
'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTagsHelpText',
{
defaultMessage:
'Type one or more custom identifying tags for this rule. Press enter after each tag to begin a new one.',
}
),
labelAppend: <EuiText size="xs">{RuleI18n.OPTIONAL_FIELD}</EuiText>,
},
};

View file

@ -54,3 +54,10 @@ export const CUSTOM_MITRE_ATTACK_TECHNIQUES_REQUIRED = i18n.translate(
defaultMessage: 'At least one Technique is required with a Tactic.',
}
);
export const URL_FORMAT_INVALID = i18n.translate(
'xpack.siem.detectionEngine.createRule.stepDefineRule.referencesUrlInvalidError',
{
defaultMessage: 'Url is invalid format',
}
);

View file

@ -4,8 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui';
import { isEqual, get } from 'lodash/fp';
import {
EuiButtonEmpty,
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
} from '@elastic/eui';
import { isEmpty, isEqual, get } from 'lodash/fp';
import React, { memo, useCallback, useState, useEffect } from 'react';
import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/public';
@ -18,7 +24,7 @@ import { StepRuleDescription } from '../description_step';
import { QueryBarDefineRule } from '../query_bar';
import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports';
import { schema } from './schema';
import * as I18n from './translations';
import * as i18n from './translations';
const CommonUseField = getUseField({ component: Field });
@ -34,7 +40,6 @@ const stepDefineDefaultValue = {
filters: [],
saved_id: null,
},
useIndicesConfig: 'true',
};
const getStepDefaultValue = (
@ -45,7 +50,6 @@ const getStepDefaultValue = (
return {
...defaultValues,
isNew: false,
useIndicesConfig: `${isEqual(defaultValues.index, indicesConfig)}`,
};
} else {
return {
@ -66,13 +70,22 @@ export const StepDefineRule = memo<StepDefineRuleProps>(
setForm,
setStepData,
}) => {
const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState('');
const [openTimelineSearch, setOpenTimelineSearch] = useState(false);
const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false);
const [indicesConfig] = useUiSetting$<string[]>(DEFAULT_INDEX_KEY);
const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState(
defaultValues != null ? defaultValues.index : indicesConfig ?? []
);
const [
{ indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar },
setIndices,
] = useFetchIndexPatterns(defaultValues != null ? defaultValues.index : indicesConfig ?? []);
const [myStepData, setMyStepData] = useState<DefineStepRule>(stepDefineDefaultValue);
{
browserFields,
indexPatterns: indexPatternQueryBar,
isLoading: indexPatternLoadingQueryBar,
},
] = useFetchIndexPatterns(mylocalIndicesConfig);
const [myStepData, setMyStepData] = useState<DefineStepRule>(
getStepDefaultValue(indicesConfig, null)
);
const { form } = useForm({
defaultValue: myStepData,
@ -96,7 +109,7 @@ export const StepDefineRule = memo<StepDefineRuleProps>(
const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues);
if (!isEqual(myDefaultValues, myStepData)) {
setMyStepData(myDefaultValues);
setLocalUseIndicesConfig(myDefaultValues.useIndicesConfig);
setLocalUseIndicesConfig(isEqual(myDefaultValues.index, indicesConfig));
if (!isReadOnlyView) {
Object.keys(schema).forEach(key => {
const val = get(key, myDefaultValues);
@ -115,6 +128,19 @@ export const StepDefineRule = memo<StepDefineRuleProps>(
}
}, [form]);
const handleResetIndices = useCallback(() => {
const indexField = form.getFields().index;
indexField.setValue(indicesConfig);
}, [form, indicesConfig]);
const handleOpenTimelineSearch = useCallback(() => {
setOpenTimelineSearch(true);
}, []);
const handleCloseTimelineSearch = useCallback(() => {
setOpenTimelineSearch(false);
}, []);
return isReadOnlyView && myStepData != null ? (
<StepRuleDescription
direction={descriptionDirection}
@ -125,74 +151,63 @@ export const StepDefineRule = memo<StepDefineRuleProps>(
) : (
<>
<Form form={form} data-test-subj="stepDefineRule">
<CommonUseField
path="useIndicesConfig"
componentProps={{
idAria: 'detectionEngineStepDefineRuleUseIndicesConfig',
'data-test-subj': 'detectionEngineStepDefineRuleUseIndicesConfig',
euiFieldProps: {
disabled: isLoading,
options: [
{
id: 'true',
label: I18n.CONFIG_INDICES,
},
{
id: 'false',
label: I18n.CUSTOM_INDICES,
},
],
},
}}
/>
<CommonUseField
path="index"
config={{
...schema.index,
labelAppend: !localUseIndicesConfig ? (
<EuiButtonEmpty size="xs" onClick={handleResetIndices}>
<small>{i18n.RESET_DEFAULT_INDEX}</small>
</EuiButtonEmpty>
) : null,
}}
componentProps={{
idAria: 'detectionEngineStepDefineRuleIndices',
'data-test-subj': 'detectionEngineStepDefineRuleIndices',
euiFieldProps: {
compressed: true,
fullWidth: true,
isDisabled: isLoading,
placeholder: '',
},
}}
/>
<UseField
path="queryBar"
config={{
...schema.queryBar,
labelAppend: (
<EuiButtonEmpty size="xs" onClick={handleOpenTimelineSearch}>
<small>{i18n.IMPORT_TIMELINE_QUERY}</small>
</EuiButtonEmpty>
),
}}
component={QueryBarDefineRule}
componentProps={{
compressed: true,
browserFields,
loading: indexPatternLoadingQueryBar,
idAria: 'detectionEngineStepDefineRuleQueryBar',
indexPattern: indexPatternQueryBar,
isDisabled: isLoading,
isLoading: indexPatternLoadingQueryBar,
dataTestSubj: 'detectionEngineStepDefineRuleQueryBar',
openTimelineSearch,
onCloseTimelineSearch: handleCloseTimelineSearch,
resizeParentContainer,
}}
/>
<FormDataProvider pathsToWatch="useIndicesConfig">
{({ useIndicesConfig }) => {
if (localUseIndicesConfig !== useIndicesConfig) {
const indexField = form.getFields().index;
if (
indexField != null &&
useIndicesConfig === 'true' &&
!isEqual(indexField.value, indicesConfig)
) {
indexField.setValue(indicesConfig);
setIndices(indicesConfig);
} else if (
indexField != null &&
useIndicesConfig === 'false' &&
isEqual(indexField.value, indicesConfig)
) {
indexField.setValue([]);
setIndices([]);
<FormDataProvider pathsToWatch="index">
{({ index }) => {
if (index != null) {
if (isEqual(index, indicesConfig) && !localUseIndicesConfig) {
setLocalUseIndicesConfig(true);
}
if (!isEqual(index, indicesConfig) && localUseIndicesConfig) {
setLocalUseIndicesConfig(false);
}
if (index != null && !isEmpty(index) && !isEqual(index, mylocalIndicesConfig)) {
setMyLocalIndicesConfig(index);
}
setLocalUseIndicesConfig(useIndicesConfig);
}
return null;
}}
</FormDataProvider>
@ -208,7 +223,7 @@ export const StepDefineRule = memo<StepDefineRuleProps>(
>
<EuiFlexItem grow={false}>
<EuiButton fill onClick={onSubmit} isDisabled={isLoading}>
{myStepData.isNew ? RuleI18n.CONTINUE : RuleI18n.UPDATE}
{RuleI18n.CONTINUE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -10,7 +10,6 @@ import { isEmpty } from 'lodash/fp';
import React from 'react';
import { esKuery } from '../../../../../../../../../../src/plugins/data/public';
import * as RuleI18n from '../../translations';
import { FieldValueQueryBar } from '../query_bar';
import {
ERROR_CODE,
@ -19,33 +18,27 @@ import {
FormSchema,
ValidationFunc,
} from '../shared_imports';
import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY } from './translations';
import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations';
const { emptyField } = fieldValidators;
export const schema: FormSchema = {
useIndicesConfig: {
type: FIELD_TYPES.RADIO_GROUP,
label: i18n.translate(
'xpack.siem.detectionEngine.createRule.stepDefineRule.fieldIndicesTypeLabel',
{
defaultMessage: 'Indices type',
}
),
},
index: {
type: FIELD_TYPES.COMBO_BOX,
label: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.fiedIndicesLabel', {
defaultMessage: 'Indices',
}),
labelAppend: <EuiText size="xs">{RuleI18n.OPTIONAL_FIELD}</EuiText>,
label: i18n.translate(
'xpack.siem.detectionEngine.createRule.stepAboutRule.fiedIndexPatternsLabel',
{
defaultMessage: 'Index patterns',
}
),
helpText: <EuiText size="xs">{INDEX_HELPER_TEXT}</EuiText>,
validations: [
{
validator: emptyField(
i18n.translate(
'xpack.siem.detectionEngine.createRule.stepDefineRule.outputIndiceNameFieldRequiredError',
{
defaultMessage: 'An output indice name for signals is required.',
defaultMessage: 'Index patterns for signals is required.',
}
)
),

View file

@ -33,3 +33,25 @@ export const CUSTOM_INDICES = i18n.translate(
defaultMessage: 'Provide custom list of indices',
}
);
export const INDEX_HELPER_TEXT = i18n.translate(
'xpack.siem.detectionEngine.createRule.stepDefineRule.indicesHelperDescription',
{
defaultMessage:
'Enter the pattern of Elasticsearch indices where you would like this rule to run. By default, these will include index patterns defined in SIEM advanced settings.',
}
);
export const RESET_DEFAULT_INDEX = i18n.translate(
'xpack.siem.detectionEngine.createRule.stepDefineRule.resetDefaultIndicesButton',
{
defaultMessage: 'Reset to default index patterns',
}
);
export const IMPORT_TIMELINE_QUERY = i18n.translate(
'xpack.siem.detectionEngine.createRule.stepDefineRule.importTimelineQueryButton',
{
defaultMessage: 'Import query from saved timeline',
}
);

View file

@ -92,7 +92,6 @@ export const StepScheduleRule = memo<StepScheduleRuleProps>(
path="interval"
component={ScheduleItem}
componentProps={{
compressed: true,
idAria: 'detectionEngineStepScheduleRuleInterval',
isDisabled: isLoading,
dataTestSubj: 'detectionEngineStepScheduleRuleInterval',
@ -102,7 +101,6 @@ export const StepScheduleRule = memo<StepScheduleRuleProps>(
path="from"
component={ScheduleItem}
componentProps={{
compressed: true,
idAria: 'detectionEngineStepScheduleRuleFrom',
isDisabled: isLoading,
dataTestSubj: 'detectionEngineStepScheduleRuleFrom',

View file

@ -40,14 +40,14 @@ const getTimeTypeValue = (time: string): { unit: string; value: number } => {
};
const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => {
const { queryBar, useIndicesConfig, isNew, ...rest } = defineStepData;
const { queryBar, isNew, ...rest } = defineStepData;
const { filters, query, saved_id: savedId } = queryBar;
return {
...rest,
language: query.language,
filters,
query: query.query as string,
...(savedId != null ? { saved_id: savedId } : {}),
...(savedId != null && savedId !== '' ? { saved_id: savedId } : {}),
};
};
@ -72,11 +72,21 @@ const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRul
};
const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson => {
const { falsePositives, references, riskScore, threats, isNew, ...rest } = aboutStepData;
const {
falsePositives,
references,
riskScore,
threats,
timeline,
isNew,
...rest
} = aboutStepData;
return {
false_positives: falsePositives.filter(item => !isEmpty(item)),
references: references.filter(item => !isEmpty(item)),
risk_score: riskScore,
timeline_id: timeline.id,
timeline_title: timeline.title,
threats: threats
.filter(threat => threat.tactic.name !== 'none')
.map(threat => ({
@ -97,7 +107,7 @@ export const formatRule = (
scheduleData: ScheduleStepRule,
ruleId?: string
): NewRule => {
const type: FormatRuleType = defineStepData.queryBar.saved_id != null ? 'saved_query' : 'query';
const type: FormatRuleType = !isEmpty(defineStepData.queryBar.saved_id) ? 'saved_query' : 'query';
const persistData = {
type,
...formatDefineStepData(defineStepData),

View file

@ -12,12 +12,13 @@ import styled from 'styled-components';
import { HeaderPage } from '../../../../components/header_page';
import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine';
import { WrapperPage } from '../../../../components/wrapper_page';
import { usePersistRule } from '../../../../containers/detection_engine/rules';
import { SpyRoute } from '../../../../utils/route/spy_routes';
import { AccordionTitle } from '../components/accordion_title';
import { FormData, FormHook } from '../components/shared_imports';
import { StepAboutRule } from '../components/step_about_rule';
import { StepDefineRule } from '../components/step_define_rule';
import { StepScheduleRule } from '../components/step_schedule_rule';
import { usePersistRule } from '../../../../containers/detection_engine/rules';
import { SpyRoute } from '../../../../utils/route/spy_routes';
import * as RuleI18n from '../translations';
import { AboutStepRule, DefineStepRule, RuleStep, RuleStepData, ScheduleStepRule } from '../types';
import { formatRule } from './helpers';
@ -28,17 +29,43 @@ const stepsRuleOrder = [RuleStep.defineRule, RuleStep.aboutRule, RuleStep.schedu
const ResizeEuiPanel = styled(EuiPanel)<{
height?: number;
}>`
.euiAccordion__iconWrapper {
display: none;
}
.euiAccordion__childWrapper {
height: ${props => (props.height !== -1 ? `${props.height}px !important` : 'auto')};
}
.euiAccordion__button {
cursor: default !important;
&:hover {
text-decoration: none !important;
}
}
`;
const MyEuiPanel = styled(EuiPanel)`
.euiAccordion__iconWrapper {
display: none;
}
.euiAccordion__button {
cursor: default !important;
&:hover {
text-decoration: none !important;
}
}
`;
export const CreateRuleComponent = React.memo(() => {
const [heightAccordion, setHeightAccordion] = useState(-1);
const [openAccordionId, setOpenAccordionId] = useState<RuleStep | null>(RuleStep.defineRule);
const [openAccordionId, setOpenAccordionId] = useState<RuleStep>(RuleStep.defineRule);
const defineRuleRef = useRef<EuiAccordion | null>(null);
const aboutRuleRef = useRef<EuiAccordion | null>(null);
const scheduleRuleRef = useRef<EuiAccordion | null>(null);
const stepsForm = useRef<Record<RuleStep, FormHook<FormData> | null>>({
[RuleStep.defineRule]: null,
[RuleStep.aboutRule]: null,
[RuleStep.scheduleRule]: null,
});
const stepsData = useRef<Record<RuleStep, RuleStepData>>({
[RuleStep.defineRule]: { isValid: false, data: {} },
[RuleStep.aboutRule]: { isValid: false, data: {} },
@ -57,11 +84,17 @@ export const CreateRuleComponent = React.memo(() => {
if (isValid) {
const stepRuleIdx = stepsRuleOrder.findIndex(item => step === item);
if ([0, 1].includes(stepRuleIdx)) {
setIsStepRuleInEditView({
...isStepRuleInReadOnlyView,
[step]: true,
});
if (openAccordionId !== stepsRuleOrder[stepRuleIdx + 1]) {
if (isStepRuleInReadOnlyView[stepsRuleOrder[stepRuleIdx + 1]]) {
setIsStepRuleInEditView({
...isStepRuleInReadOnlyView,
[step]: true,
[stepsRuleOrder[stepRuleIdx + 1]]: false,
});
} else if (openAccordionId !== stepsRuleOrder[stepRuleIdx + 1]) {
setIsStepRuleInEditView({
...isStepRuleInReadOnlyView,
[step]: true,
});
openCloseAccordion(stepsRuleOrder[stepRuleIdx + 1]);
setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]);
}
@ -80,9 +113,13 @@ export const CreateRuleComponent = React.memo(() => {
}
}
},
[openAccordionId, stepsData.current, setRule]
[isStepRuleInReadOnlyView, openAccordionId, stepsData.current, setRule]
);
const setStepsForm = useCallback((step: RuleStep, form: FormHook<FormData>) => {
stepsForm.current[step] = form;
}, []);
const getAccordionType = useCallback(
(accordionId: RuleStep) => {
if (accordionId === openAccordionId) {
@ -135,42 +172,38 @@ export const CreateRuleComponent = React.memo(() => {
(id: RuleStep, isOpen: boolean) => {
const activeRuleIdx = stepsRuleOrder.findIndex(step => step === openAccordionId);
const stepRuleIdx = stepsRuleOrder.findIndex(step => step === id);
const isLatestStepsRuleValid =
stepRuleIdx === 0
? true
: stepsRuleOrder
.filter((stepRule, index) => index < stepRuleIdx)
.every(stepRule => stepsData.current[stepRule].isValid);
if (stepRuleIdx < activeRuleIdx && !isOpen) {
if ((id === openAccordionId || stepRuleIdx < activeRuleIdx) && !isOpen) {
openCloseAccordion(id);
} else if (stepRuleIdx >= activeRuleIdx) {
if (
openAccordionId != null &&
openAccordionId !== id &&
!stepsData.current[openAccordionId].isValid &&
!isStepRuleInReadOnlyView[id] &&
isOpen
) {
openCloseAccordion(id);
} else if (!isLatestStepsRuleValid && isOpen) {
openCloseAccordion(id);
} else if (id !== openAccordionId && isOpen) {
setOpenAccordionId(id);
}
}
},
[isStepRuleInReadOnlyView, openAccordionId]
[isStepRuleInReadOnlyView, openAccordionId, stepsData]
);
const manageIsEditable = useCallback(
(id: RuleStep) => {
setIsStepRuleInEditView({
...isStepRuleInReadOnlyView,
[id]: false,
});
async (id: RuleStep) => {
const activeForm = await stepsForm.current[openAccordionId]?.submit();
if (activeForm != null && activeForm?.isValid) {
setOpenAccordionId(id);
openCloseAccordion(openAccordionId);
setIsStepRuleInEditView({
...isStepRuleInReadOnlyView,
[openAccordionId]: openAccordionId === RuleStep.scheduleRule ? false : true,
[id]: false,
});
}
},
[isStepRuleInReadOnlyView]
[isStepRuleInReadOnlyView, openAccordionId]
);
if (isSaved) {
@ -201,7 +234,7 @@ export const CreateRuleComponent = React.memo(() => {
size="xs"
onClick={manageIsEditable.bind(null, RuleStep.defineRule)}
>
{`Edit`}
{i18n.EDIT_RULE}
</EuiButtonEmpty>
)
}
@ -210,13 +243,14 @@ export const CreateRuleComponent = React.memo(() => {
<StepDefineRule
isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.defineRule]}
isLoading={isLoading}
setForm={setStepsForm}
setStepData={setStepData}
resizeParentContainer={height => setHeightAccordion(height)}
/>
</EuiAccordion>
</ResizeEuiPanel>
<EuiSpacer size="s" />
<EuiPanel>
<MyEuiPanel>
<EuiAccordion
initialIsOpen={false}
id={RuleStep.aboutRule}
@ -231,7 +265,7 @@ export const CreateRuleComponent = React.memo(() => {
size="xs"
onClick={manageIsEditable.bind(null, RuleStep.aboutRule)}
>
{`Edit`}
{i18n.EDIT_RULE}
</EuiButtonEmpty>
)
}
@ -240,12 +274,13 @@ export const CreateRuleComponent = React.memo(() => {
<StepAboutRule
isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.aboutRule]}
isLoading={isLoading}
setForm={setStepsForm}
setStepData={setStepData}
/>
</EuiAccordion>
</EuiPanel>
</MyEuiPanel>
<EuiSpacer size="s" />
<EuiPanel>
<MyEuiPanel>
<EuiAccordion
initialIsOpen={false}
id={RuleStep.scheduleRule}
@ -260,7 +295,7 @@ export const CreateRuleComponent = React.memo(() => {
size="xs"
onClick={manageIsEditable.bind(null, RuleStep.scheduleRule)}
>
{`Edit`}
{i18n.EDIT_RULE}
</EuiButtonEmpty>
)
}
@ -269,10 +304,11 @@ export const CreateRuleComponent = React.memo(() => {
<StepScheduleRule
isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.scheduleRule]}
isLoading={isLoading}
setForm={setStepsForm}
setStepData={setStepData}
/>
</EuiAccordion>
</EuiPanel>
</MyEuiPanel>
</WrapperPage>
<SpyRoute />

View file

@ -9,3 +9,7 @@ import { i18n } from '@kbn/i18n';
export const PAGE_TITLE = i18n.translate('xpack.siem.detectionEngine.createRule.pageTitle', {
defaultMessage: 'Create new rule',
});
export const EDIT_RULE = i18n.translate('xpack.siem.detectionEngine.createRule.editRuleButton', {
defaultMessage: 'Edit',
});

View file

@ -113,7 +113,10 @@ export const RuleDetailsComponent = memo<RuleDetailsComponentProps>(({ signalsIn
<WrapperPage>
<HeaderPage
backOptions={{ href: '#detection-engine/rules', text: i18n.BACK_TO_RULES }}
backOptions={{
href: `#${DETECTION_ENGINE_PAGE_NAME}/rules`,
text: i18n.BACK_TO_RULES,
}}
badgeOptions={{ text: i18n.EXPERIMENTAL }}
border
subtitle={subTitle}
@ -142,7 +145,7 @@ export const RuleDetailsComponent = memo<RuleDetailsComponentProps>(({ signalsIn
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButton
href={`#${DETECTION_ENGINE_PAGE_NAME}/rules/${ruleId}/edit`}
href={`#${DETECTION_ENGINE_PAGE_NAME}/rules/id/${ruleId}/edit`}
iconType="visControls"
isDisabled={rule?.immutable ?? true}
>

View file

@ -49,6 +49,7 @@ interface ScheduleStepRuleForm extends StepRuleForm {
export const EditRuleComponent = memo(() => {
const { ruleId } = useParams();
const [loading, rule] = useRule(ruleId);
const [initForm, setInitForm] = useState(false);
const [myAboutRuleForm, setMyAboutRuleForm] = useState<AboutStepRuleForm>({
data: null,
@ -249,7 +250,7 @@ export const EditRuleComponent = memo(() => {
}, []);
if (isSaved || (rule != null && rule.immutable)) {
return <Redirect to={`/${DETECTION_ENGINE_PAGE_NAME}/rules/${ruleId}`} />;
return <Redirect to={`/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${ruleId}`} />;
}
return (
@ -257,7 +258,7 @@ export const EditRuleComponent = memo(() => {
<WrapperPage restrictWidth>
<HeaderPage
backOptions={{
href: `#/${DETECTION_ENGINE_PAGE_NAME}/rules/${ruleId}`,
href: `#/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${ruleId}`,
text: `${i18n.BACK_TO} ${rule?.name ?? ''}`,
}}
isLoading={isLoading}
@ -303,7 +304,7 @@ export const EditRuleComponent = memo(() => {
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButton iconType="cross" href={`#/${DETECTION_ENGINE_PAGE_NAME}/rules/${ruleId}`}>
<EuiButton iconType="cross" href={`#/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${ruleId}`}>
{i18n.CANCEL}
</EuiButton>
</EuiFlexItem>

View file

@ -33,7 +33,6 @@ export const getStepsData = ({
filters: rule.filters as esFilters.Filter[],
saved_id: rule.saved_id ?? null,
},
useIndicesConfig: 'true',
}
: null;
const aboutRuleData: AboutStepRule | null =
@ -45,6 +44,10 @@ export const getStepsData = ({
threats: rule.threats as IMitreEnterpriseAttack[],
falsePositives: rule.false_positives,
riskScore: rule.risk_score,
timeline: {
id: rule.timeline_id ?? null,
title: rule.timeline_title ?? null,
},
}
: null;
const scheduleRuleData: ScheduleStepRule | null =

View file

@ -8,6 +8,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { DETECTION_ENGINE_PAGE_NAME } from '../../../components/link_to/redirect_to_detection_engine';
import { FormattedRelativePreferenceDate } from '../../../components/formatted_date';
import { getEmptyTagValue } from '../../../components/empty_value';
import { HeaderPage } from '../../../components/header_page';
@ -32,7 +33,10 @@ export const RulesComponent = React.memo(() => {
/>
<WrapperPage>
<HeaderPage
backOptions={{ href: '#detection-engine', text: i18n.BACK_TO_DETECTION_ENGINE }}
backOptions={{
href: `#${DETECTION_ENGINE_PAGE_NAME}`,
text: i18n.BACK_TO_DETECTION_ENGINE,
}}
subtitle={
lastCompletedRun ? (
<FormattedMessage
@ -61,7 +65,11 @@ export const RulesComponent = React.memo(() => {
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill href="#/detection-engine/rules/create" iconType="plusInCircle">
<EuiButton
fill
href={`#${DETECTION_ENGINE_PAGE_NAME}/rules/create`}
iconType="plusInCircle"
>
{i18n.ADD_NEW_RULE}
</EuiButton>
</EuiFlexItem>

View file

@ -8,6 +8,7 @@ import { esFilters } from '../../../../../../../../src/plugins/data/common';
import { Rule } from '../../../containers/detection_engine/rules';
import { FieldValueQueryBar } from './components/query_bar';
import { FormData, FormHook } from './components/shared_imports';
import { FieldValueTimeline } from './components/pick_timeline';
export interface EuiBasicTableSortTypes {
field: string;
@ -76,11 +77,11 @@ export interface AboutStepRule extends StepRuleData {
references: string[];
falsePositives: string[];
tags: string[];
timeline: FieldValueTimeline;
threats: IMitreEnterpriseAttack[];
}
export interface DefineStepRule extends StepRuleData {
useIndicesConfig: string;
index: string[];
queryBar: FieldValueQueryBar;
}
@ -108,6 +109,8 @@ export interface AboutStepRuleJson {
references: string[];
false_positives: string[];
tags: string[];
timeline_id: string | null;
timeline_title: string | null;
threats: IMitreEnterpriseAttack[];
}

View file

@ -52,6 +52,7 @@ export const fullRuleAlertParamsRest = (): RuleAlertParamsRest => ({
created_at: '2019-12-13T16:40:33.400Z',
updated_at: '2019-12-13T16:40:33.400Z',
timeline_id: 'timeline-id',
timeline_title: 'timeline-title',
});
export const typicalPayload = (): Partial<RuleAlertParamsRest> => ({
@ -271,6 +272,7 @@ export const getResult = (): RuleAlertType => ({
outputIndex: '.siem-signals',
savedId: 'some-id',
timelineId: 'some-timeline-id',
timelineTitle: 'some-timeline-title',
meta: { someMeta: 'someField' },
filters: [
{

View file

@ -36,6 +36,9 @@
"timeline_id": {
"type": "keyword"
},
"timeline_title": {
"type": "keyword"
},
"max_signals": {
"type": "keyword"
},

View file

@ -74,6 +74,7 @@ export const createCreateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou
updated_at: updatedAt,
references,
timeline_id: timelineId,
timeline_title: timelineTitle,
version,
} = payloadRule;
const ruleIdOrUuid = ruleId ?? uuid.v4();
@ -112,6 +113,7 @@ export const createCreateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou
outputIndex: finalIndex,
savedId,
timelineId,
timelineTitle,
meta,
filters,
ruleId: ruleIdOrUuid,

View file

@ -44,6 +44,7 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute =
output_index: outputIndex,
saved_id: savedId,
timeline_id: timelineId,
timeline_title: timelineTitle,
meta,
filters,
rule_id: ruleId,
@ -101,6 +102,7 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute =
outputIndex: finalIndex,
savedId,
timelineId,
timelineTitle,
meta,
filters,
ruleId: ruleId != null ? ruleId : uuid.v4(),

View file

@ -50,6 +50,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou
output_index: outputIndex,
saved_id: savedId,
timeline_id: timelineId,
timeline_title: timelineTitle,
meta,
filters,
rule_id: ruleId,
@ -82,6 +83,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou
outputIndex,
savedId,
timelineId,
timelineTitle,
meta,
filters,
id,

View file

@ -38,6 +38,7 @@ export const createUpdateRulesRoute: Hapi.ServerRoute = {
output_index: outputIndex,
saved_id: savedId,
timeline_id: timelineId,
timeline_title: timelineTitle,
meta,
filters,
rule_id: ruleId,
@ -77,6 +78,7 @@ export const createUpdateRulesRoute: Hapi.ServerRoute = {
outputIndex,
savedId,
timelineId,
timelineTitle,
meta,
filters,
id,

View file

@ -79,6 +79,7 @@ describe('utils', () => {
},
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
to: 'now',
type: 'query',
version: 1,
@ -141,6 +142,7 @@ describe('utils', () => {
},
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
to: 'now',
type: 'query',
version: 1,
@ -205,6 +207,7 @@ describe('utils', () => {
},
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
to: 'now',
type: 'query',
version: 1,
@ -269,6 +272,7 @@ describe('utils', () => {
},
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
to: 'now',
type: 'query',
version: 1,
@ -331,6 +335,7 @@ describe('utils', () => {
},
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
to: 'now',
type: 'query',
version: 1,
@ -396,6 +401,7 @@ describe('utils', () => {
},
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
to: 'now',
type: 'query',
version: 1,
@ -461,6 +467,7 @@ describe('utils', () => {
},
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
to: 'now',
type: 'query',
version: 1,
@ -526,6 +533,7 @@ describe('utils', () => {
},
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
to: 'now',
type: 'query',
version: 1,
@ -642,6 +650,7 @@ describe('utils', () => {
},
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
version: 1,
};
expect(output).toEqual({
@ -714,6 +723,7 @@ describe('utils', () => {
},
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
version: 1,
};
expect(output).toEqual(expected);
@ -875,6 +885,7 @@ describe('utils', () => {
},
saved_id: 'some-id',
timeline_id: 'some-timeline-id',
timeline_title: 'some-timeline-title',
version: 1,
};
expect(output).toEqual(expected);

View file

@ -85,6 +85,7 @@ export const transformAlertToRule = (alert: RuleAlertType): Partial<OutputRuleAl
references: alert.params.references,
saved_id: alert.params.savedId,
timeline_id: alert.params.timelineId,
timeline_title: alert.params.timelineTitle,
meta: alert.params.meta,
severity: alert.params.severity,
updated_by: alert.updatedBy,

View file

@ -1076,9 +1076,7 @@ describe('add prepackaged rules schema', () => {
test('You can omit the query string when filters are present', () => {
expect(
addPrepackagedRulesSchema.validate<
Partial<Omit<RuleAlertParamsRest, 'meta'> & { meta: string }>
>({
addPrepackagedRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
risk_score: 50,
description: 'some description',
@ -1099,7 +1097,7 @@ describe('add prepackaged rules schema', () => {
).toBeFalsy();
});
test('validates with timeline_id', () => {
test('validates with timeline_id and timeline_title', () => {
expect(
addPrepackagedRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
@ -1117,7 +1115,131 @@ describe('add prepackaged rules schema', () => {
language: 'kuery',
version: 1,
timeline_id: 'timeline-id',
timeline_title: 'timeline-title',
}).error
).toBeFalsy();
});
test('You cannot omit timeline_title when timeline_id is present', () => {
expect(
addPrepackagedRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
immutable: true,
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
references: ['index-1'],
language: 'kuery',
filters: [],
max_signals: 1,
version: 1,
timeline_id: 'timeline-id',
}).error
).toBeTruthy();
});
test('You cannot have a null value for timeline_title when timeline_id is present', () => {
expect(
addPrepackagedRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
immutable: true,
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
references: ['index-1'],
language: 'kuery',
filters: [],
max_signals: 1,
version: 1,
timeline_id: 'timeline-id',
timeline_title: null,
}).error
).toBeTruthy();
});
test('You cannot have empty string for timeline_title when timeline_id is present', () => {
expect(
addPrepackagedRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
immutable: true,
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
references: ['index-1'],
language: 'kuery',
filters: [],
max_signals: 1,
version: 1,
timeline_id: 'timeline-id',
timeline_title: '',
}).error
).toBeTruthy();
});
test('You cannot have timeline_title with an empty timeline_id', () => {
expect(
addPrepackagedRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
immutable: true,
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
references: ['index-1'],
language: 'kuery',
filters: [],
max_signals: 1,
version: 1,
timeline_id: '',
timeline_title: 'some-title',
}).error
).toBeTruthy();
});
test('You cannot have timeline_title without timeline_id', () => {
expect(
addPrepackagedRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
immutable: true,
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
references: ['index-1'],
language: 'kuery',
filters: [],
max_signals: 1,
version: 1,
timeline_title: 'some-title',
}).error
).toBeTruthy();
});
});

View file

@ -21,6 +21,7 @@ import {
language,
saved_id,
timeline_id,
timeline_title,
meta,
risk_score,
max_signals,
@ -63,6 +64,7 @@ export const addPrepackagedRulesSchema = Joi.object({
otherwise: Joi.forbidden(),
}),
timeline_id,
timeline_title,
meta,
risk_score: risk_score.required(),
max_signals: max_signals.default(DEFAULT_MAX_SIGNALS),

View file

@ -1024,7 +1024,7 @@ describe('create rules schema', () => {
test('You can omit the query string when filters are present', () => {
expect(
createRulesSchema.validate<Partial<Omit<RuleAlertParamsRest, 'meta'> & { meta: string }>>({
createRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
@ -1045,7 +1045,30 @@ describe('create rules schema', () => {
).toBeFalsy();
});
test('timeline_id validates', () => {
test('validates with timeline_id and timeline_title', () => {
expect(
createRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
references: ['index-1'],
query: 'some query',
language: 'kuery',
timeline_id: 'timeline-id',
timeline_title: 'timeline-title',
}).error
).toBeFalsy();
});
test('You cannot omit timeline_title when timeline_id is present', () => {
expect(
createRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
@ -1064,6 +1087,97 @@ describe('create rules schema', () => {
language: 'kuery',
timeline_id: 'some_id',
}).error
).toBeFalsy();
).toBeTruthy();
});
test('You cannot have a null value for timeline_title when timeline_id is present', () => {
expect(
createRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
references: ['index-1'],
query: 'some query',
language: 'kuery',
timeline_id: 'some_id',
timeline_title: null,
}).error
).toBeTruthy();
});
test('You cannot have empty string for timeline_title when timeline_id is present', () => {
expect(
createRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
references: ['index-1'],
query: 'some query',
language: 'kuery',
timeline_id: 'some_id',
timeline_title: '',
}).error
).toBeTruthy();
});
test('You cannot have timeline_title with an empty timeline_id', () => {
expect(
createRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
references: ['index-1'],
query: 'some query',
language: 'kuery',
timeline_id: '',
timeline_title: 'some-title',
}).error
).toBeTruthy();
});
test('You cannot have timeline_title without timeline_id', () => {
expect(
createRulesSchema.validate<Partial<RuleAlertParamsRest>>({
rule_id: 'rule-1',
output_index: '.siem-signals',
risk_score: 50,
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'query',
references: ['index-1'],
query: 'some query',
language: 'kuery',
timeline_title: 'some-title',
}).error
).toBeTruthy();
});
});

View file

@ -22,6 +22,7 @@ import {
output_index,
saved_id,
timeline_id,
timeline_title,
meta,
risk_score,
max_signals,
@ -57,6 +58,7 @@ export const createRulesSchema = Joi.object({
otherwise: Joi.forbidden(),
}),
timeline_id,
timeline_title,
meta,
risk_score: risk_score.required(),
max_signals: max_signals.default(DEFAULT_MAX_SIGNALS),

View file

@ -24,6 +24,11 @@ export const language = Joi.string().valid('kuery', 'lucene');
export const output_index = Joi.string();
export const saved_id = Joi.string();
export const timeline_id = Joi.string();
export const timeline_title = Joi.string().when('timeline_id', {
is: Joi.exist(),
then: Joi.required(),
otherwise: Joi.forbidden(),
});
export const meta = Joi.object();
export const max_signals = Joi.number().greater(0);
export const name = Joi.string();

View file

@ -867,7 +867,26 @@ describe('update rules schema', () => {
).toBeTruthy();
});
test('timeline_id validates', () => {
test('validates with timeline_id and timeline_title', () => {
expect(
updateRulesSchema.validate<Partial<UpdateRuleAlertParamsRest>>({
id: 'rule-1',
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'saved_query',
saved_id: 'some id',
timeline_id: 'some-id',
timeline_title: 'some-title',
}).error
).toBeFalsy();
});
test('You cannot omit timeline_title when timeline_id is present', () => {
expect(
updateRulesSchema.validate<Partial<UpdateRuleAlertParamsRest>>({
id: 'rule-1',
@ -882,6 +901,81 @@ describe('update rules schema', () => {
saved_id: 'some id',
timeline_id: 'some-id',
}).error
).toBeFalsy();
).toBeTruthy();
});
test('You cannot have a null value for timeline_title when timeline_id is present', () => {
expect(
updateRulesSchema.validate<Partial<UpdateRuleAlertParamsRest>>({
id: 'rule-1',
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'saved_query',
saved_id: 'some id',
timeline_id: 'timeline-id',
timeline_title: null,
}).error
).toBeTruthy();
});
test('You cannot have empty string for timeline_title when timeline_id is present', () => {
expect(
updateRulesSchema.validate<Partial<UpdateRuleAlertParamsRest>>({
id: 'rule-1',
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'saved_query',
saved_id: 'some id',
timeline_id: 'some-id',
timeline_title: '',
}).error
).toBeTruthy();
});
test('You cannot have timeline_title with an empty timeline_id', () => {
expect(
updateRulesSchema.validate<Partial<UpdateRuleAlertParamsRest>>({
id: 'rule-1',
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'saved_query',
saved_id: 'some id',
timeline_id: '',
timeline_title: 'some-title',
}).error
).toBeTruthy();
});
test('You cannot have timeline_title without timeline_id', () => {
expect(
updateRulesSchema.validate<Partial<UpdateRuleAlertParamsRest>>({
id: 'rule-1',
description: 'some description',
from: 'now-5m',
to: 'now',
index: ['index-1'],
name: 'some-name',
severity: 'severity',
interval: '5m',
type: 'saved_query',
saved_id: 'some id',
timeline_title: 'some-title',
}).error
).toBeTruthy();
});
});

View file

@ -22,6 +22,7 @@ import {
output_index,
saved_id,
timeline_id,
timeline_title,
meta,
risk_score,
max_signals,
@ -53,6 +54,7 @@ export const updateRulesSchema = Joi.object({
output_index,
saved_id,
timeline_id,
timeline_title,
meta,
risk_score,
max_signals,

View file

@ -19,6 +19,7 @@ export const createRules = async ({
language,
savedId,
timelineId,
timelineTitle,
meta,
filters,
ruleId,
@ -56,6 +57,7 @@ export const createRules = async ({
outputIndex,
savedId,
timelineId,
timelineTitle,
meta,
filters,
maxSignals,

View file

@ -26,6 +26,7 @@ export const installPrepackagedRules = async (
language,
saved_id: savedId,
timeline_id: timelineId,
timeline_title: timelineTitle,
meta,
filters,
rule_id: ruleId,
@ -55,6 +56,7 @@ export const installPrepackagedRules = async (
outputIndex,
savedId,
timelineId,
timelineTitle,
meta,
filters,
ruleId,

View file

@ -74,6 +74,7 @@ export const updateRules = async ({
outputIndex,
savedId,
timelineId,
timelineTitle,
meta,
filters,
from,
@ -118,6 +119,7 @@ export const updateRules = async ({
outputIndex,
savedId,
timelineId,
timelineTitle,
meta,
filters,
index,

View file

@ -7,5 +7,6 @@
"from": "now-6m",
"to": "now",
"query": "user.name: root or user.name: admin",
"timeline_id": "timeline-id"
"timeline_id": "timeline-id",
"timeline_title": "timeline_title"
}

View file

@ -78,5 +78,6 @@
"Some plain text string here explaining why this is a valid thing to look out for"
],
"timeline_id": "timeline_id",
"timeline_title": "timeline_title",
"version": 1
}

View file

@ -78,5 +78,6 @@
"Some plain text string here explaining why this is a valid thing to look out for"
],
"saved_id": "test-saved-id",
"timeline_id": "test-timeline-id"
"timeline_id": "test-timeline-id",
"timeline_title": "test-timeline-title"
}

View file

@ -78,5 +78,6 @@
"Some plain text string here explaining why this is a valid thing to look out for"
],
"timeline_id": "other-timeline-id",
"timeline_title": "other-timeline-title",
"version": 42
}

View file

@ -1,4 +1,5 @@
{
"rule_id": "query-rule-id",
"timeline_id": "other-timeline-id"
"timeline_id": "other-timeline-id",
"timeline_title": "other-timeline-title"
}

View file

@ -31,6 +31,7 @@ export const sampleRuleAlertParams = (
filters: undefined,
savedId: undefined,
timelineId: undefined,
timelineTitle: undefined,
meta: undefined,
threats: undefined,
version: 1,

View file

@ -34,6 +34,7 @@ export const buildRule = ({
false_positives: ruleParams.falsePositives,
saved_id: ruleParams.savedId,
timeline_id: ruleParams.timelineId,
timeline_title: ruleParams.timelineTitle,
meta: ruleParams.meta,
max_signals: ruleParams.maxSignals,
risk_score: ruleParams.riskScore,

View file

@ -42,6 +42,7 @@ export const signalRulesAlertType = ({
outputIndex: schema.nullable(schema.string()),
savedId: schema.nullable(schema.string()),
timelineId: schema.nullable(schema.string()),
timelineTitle: schema.nullable(schema.string()),
meta: schema.nullable(schema.object({}, { allowUnknowns: true })),
query: schema.nullable(schema.string()),
filters: schema.nullable(schema.arrayOf(schema.object({}, { allowUnknowns: true }))),

View file

@ -44,6 +44,7 @@ export interface RuleAlertParams {
tags: string[];
to: string;
timelineId: string | undefined | null;
timelineTitle: string | undefined | null;
threats: ThreatParams[] | undefined | null;
type: 'query' | 'saved_query';
version: number;
@ -60,6 +61,7 @@ export type RuleAlertParamsRest = Omit<
| 'savedId'
| 'riskScore'
| 'timelineId'
| 'timelineTitle'
| 'outputIndex'
| 'updatedAt'
| 'createdAt'
@ -68,6 +70,7 @@ export type RuleAlertParamsRest = Omit<
false_positives: RuleAlertParams['falsePositives'];
saved_id: RuleAlertParams['savedId'];
timeline_id: RuleAlertParams['timelineId'];
timeline_title: RuleAlertParams['timelineTitle'];
max_signals: RuleAlertParams['maxSignals'];
risk_score: RuleAlertParams['riskScore'];
output_index: RuleAlertParams['outputIndex'];