[TIP] Add filter in and out to barchart legend (#140245)

- split FilterInOut component into FilterIn and FilterOut
- each FilterIn, FilterOut and AddToTimeline components now support icon, EuiDataGrid and EuiContextMenu displays
- replace testId props with data-test-subj to follow better convention
- fix broken storybooks
- add e2e tests for addToTimeline and Filter In/Out for the Flyout new Overview tab
- fix icon alignment in flyout table actions
This commit is contained in:
Philippe Oberti 2022-09-13 23:42:28 +02:00 committed by GitHub
parent 8647bd8c64
commit 0541eb6112
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1265 additions and 260 deletions

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/**
* Used in multiple component to drive the render of the component depending on where they're used.
*/
export enum ComponentType {
EuiDataGrid = 'EuiDataGrid',
ContextMenu = 'ContextMenu',
}

View file

@ -6,14 +6,23 @@
*/
import {
FILTER_IN_BUTTON,
FILTER_OUT_BUTTON,
FILTER_IN_COMPONENT,
FILTER_OUT_COMPONENT,
INDICATOR_TYPE_CELL,
TOGGLE_FLYOUT_BUTTON,
FLYOUT_CLOSE_BUTTON,
KQL_FILTER,
INDICATORS_TABLE_CELL_FILTER_IN_BUTTON,
INDICATORS_TABLE_CELL_FILTER_OUT_BUTTON,
FLYOUT_TABLE_TAB_ROW_FILTER_IN_BUTTON,
FLYOUT_TABLE_TAB_ROW_FILTER_OUT_BUTTON,
BARCHART_POPOVER_BUTTON,
BARCHART_FILTER_IN_BUTTON,
BARCHART_FILTER_OUT_BUTTON,
FLYOUT_OVERVIEW_TAB_BLOCKS_FILTER_IN_BUTTON,
FLYOUT_OVERVIEW_TAB_BLOCKS_FILTER_OUT_BUTTON,
FLYOUT_OVERVIEW_TAB_TABLE_ROW_FILTER_IN_BUTTON,
FLYOUT_OVERVIEW_TAB_TABLE_ROW_FILTER_OUT_BUTTON,
FLYOUT_OVERVIEW_TAB_BLOCKS_ITEM,
FLYOUT_TABS,
} from '../screens/indicators';
import { selectRange } from '../tasks/select_range';
import { login } from '../tasks/login';
@ -40,24 +49,100 @@ describe('Indicators', () => {
selectRange();
});
it('should filter in and out values when clicking in an indicators table cell', () => {
cy.get(INDICATOR_TYPE_CELL).first().trigger('mouseover');
cy.get(FILTER_IN_COMPONENT).should('exist');
cy.get(FILTER_OUT_COMPONENT).should('exist');
it('should add filter to kql and filter in values when clicking in the barchart legend', () => {
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
cy.get(BARCHART_POPOVER_BUTTON).should('exist').first().click();
cy.get(BARCHART_FILTER_IN_BUTTON).should('exist').click();
cy.get(KQL_FILTER).should('exist');
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
});
it('should filter in and out values when clicking in an indicators flyout table action column', () => {
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(FILTER_OUT_BUTTON).should('exist');
cy.get(FILTER_IN_BUTTON).should('exist').first().click();
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
it('should add negated filter to kql and filter out values when clicking in the barchart legend', () => {
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
cy.get(BARCHART_POPOVER_BUTTON).should('exist').first().click();
cy.get(BARCHART_FILTER_OUT_BUTTON).should('exist').click();
cy.get(KQL_FILTER).should('exist');
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
});
it('should add filter to kql and filter in and out values when clicking in an indicators table cell', () => {
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
cy.get(INDICATOR_TYPE_CELL).first().trigger('mouseover');
cy.get(INDICATORS_TABLE_CELL_FILTER_IN_BUTTON).should('exist').click();
cy.get(KQL_FILTER).should('exist');
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
});
it('should add negated filter and filter out and out values when clicking in an indicators table cell', () => {
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
cy.get(INDICATOR_TYPE_CELL).first().trigger('mouseover');
cy.get(INDICATORS_TABLE_CELL_FILTER_OUT_BUTTON).should('exist').click();
cy.get(KQL_FILTER).should('exist');
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
});
it('should add filter to kql and filter in values when clicking in an indicators flyout overview tab block', () => {
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(FILTER_IN_BUTTON).should('exist');
cy.get(FILTER_OUT_BUTTON).should('exist').first().click();
cy.get(FLYOUT_OVERVIEW_TAB_BLOCKS_ITEM).first().trigger('mouseover');
cy.get(FLYOUT_OVERVIEW_TAB_BLOCKS_FILTER_IN_BUTTON)
.should('exist')
.first()
.click({ force: true });
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
cy.get(KQL_FILTER).should('exist');
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
});
it('should add negated filter to kql filter out values when clicking in an indicators flyout overview block', () => {
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(FLYOUT_OVERVIEW_TAB_BLOCKS_ITEM).first().trigger('mouseover');
cy.get(FLYOUT_OVERVIEW_TAB_BLOCKS_FILTER_OUT_BUTTON)
.should('exist')
.first()
.click({ force: true });
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
cy.get(KQL_FILTER).should('exist');
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
});
it('should add filter to kql and filter in values when clicking in an indicators flyout overview tab table row', () => {
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(FLYOUT_OVERVIEW_TAB_TABLE_ROW_FILTER_IN_BUTTON).should('exist').first().click();
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
cy.get(KQL_FILTER).should('exist');
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
});
it('should add negated filter to kql filter out values when clicking in an indicators flyout overview tab row', () => {
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(FLYOUT_OVERVIEW_TAB_TABLE_ROW_FILTER_OUT_BUTTON).should('exist').first().click();
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
cy.get(KQL_FILTER).should('exist');
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
});
it('should add filter to kql and filter in values when clicking in an indicators flyout table tab action column', () => {
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(`${FLYOUT_TABS} button:nth-child(2)`).click();
cy.get(FLYOUT_TABLE_TAB_ROW_FILTER_IN_BUTTON).should('exist').first().click();
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
cy.get(KQL_FILTER).should('exist');
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
});
it('should add negated filter to kql filter out values when clicking in an indicators flyout table tab action column', () => {
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(`${FLYOUT_TABS} button:nth-child(2)`).click();
cy.get(FLYOUT_TABLE_TAB_ROW_FILTER_OUT_BUTTON).should('exist').first().click();
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
cy.get(KQL_FILTER).should('exist');
cy.get(INDICATOR_TYPE_CELL).its('length').should('be.gte', 0);
});
});
});

View file

@ -6,14 +6,19 @@
*/
import {
BARCHART_POPOVER_BUTTON,
BARCHART_TIMELINE_BUTTON,
FLYOUT_CLOSE_BUTTON,
FLYOUT_TABLE_ROW_TIMELINE_BUTTON,
FLYOUT_OVERVIEW_TAB_TABLE_ROW_TIMELINE_BUTTON,
FLYOUT_TABLE_TAB_ROW_TIMELINE_BUTTON,
FLYOUT_TABS,
INDICATOR_TYPE_CELL,
INDICATORS_TABLE_CELL_TIMELINE_BUTTON,
TIMELINE_DRAGGABLE_ITEM,
TOGGLE_FLYOUT_BUTTON,
UNTITLED_TIMELINE_BUTTON,
FLYOUT_OVERVIEW_TAB_BLOCKS_TIMELINE_BUTTON,
FLYOUT_OVERVIEW_TAB_BLOCKS_ITEM,
} from '../screens/indicators';
import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver';
import { login } from '../tasks/login';
@ -41,6 +46,7 @@ describe('Indicators', () => {
});
it('should add entry in timeline when clicking in the barchart legend', () => {
cy.get(BARCHART_POPOVER_BUTTON).should('exist').first().click();
cy.get(BARCHART_TIMELINE_BUTTON).should('exist').first().click();
cy.get(UNTITLED_TIMELINE_BUTTON).should('exist').first().click();
cy.get(TIMELINE_DRAGGABLE_ITEM).should('exist');
@ -53,9 +59,31 @@ describe('Indicators', () => {
cy.get(TIMELINE_DRAGGABLE_ITEM).should('exist');
});
it('should add entry in timeline when clicking in an indicators flyout row', () => {
it('should add entry in timeline when clicking in an indicator flyout overview tab table row', () => {
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(FLYOUT_TABLE_ROW_TIMELINE_BUTTON).should('exist').first().click();
cy.get(FLYOUT_OVERVIEW_TAB_TABLE_ROW_TIMELINE_BUTTON).should('exist').first().click();
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
cy.get(UNTITLED_TIMELINE_BUTTON).should('exist').first().click();
cy.get(TIMELINE_DRAGGABLE_ITEM).should('exist');
});
it('should add entry in timeline when clicking in an indicator flyout overview block', () => {
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(FLYOUT_OVERVIEW_TAB_BLOCKS_ITEM).first().trigger('mouseover');
cy.get(FLYOUT_OVERVIEW_TAB_BLOCKS_TIMELINE_BUTTON)
.should('exist')
.first()
.click({ force: true });
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
cy.get(UNTITLED_TIMELINE_BUTTON).should('exist').first().click();
cy.get(TIMELINE_DRAGGABLE_ITEM).should('exist');
});
it('should add entry in timeline when clicking in an indicator flyout table tab', () => {
cy.get(TOGGLE_FLYOUT_BUTTON).first().click({ force: true });
cy.get(FLYOUT_TABS).should('exist');
cy.get(`${FLYOUT_TABS} button:nth-child(2)`).click();
cy.get(FLYOUT_TABLE_TAB_ROW_TIMELINE_BUTTON).should('exist').first().click();
cy.get(FLYOUT_CLOSE_BUTTON).should('exist').click();
cy.get(UNTITLED_TIMELINE_BUTTON).should('exist').first().click();
cy.get(TIMELINE_DRAGGABLE_ITEM).should('exist');

View file

@ -49,23 +49,55 @@ export const FIELD_BROWSER_MODAL_SOURCE_CHECKBOX = `[data-test-subj="field-_sour
export const FIELD_BROWSER_CLOSE = `[data-test-subj="close"]`;
export const BARCHART_TIMELINE_BUTTON = '[data-test-subj="tiTimelineButton"]';
export const BARCHART_POPOVER_BUTTON = '[data-test-subj="tiBarchartPopoverButton"]';
export const BARCHART_TIMELINE_BUTTON = '[data-test-subj="tiBarchartTimelineButton"]';
export const BARCHART_FILTER_IN_BUTTON = '[data-test-subj="tiBarchartFilterInButton"]';
export const BARCHART_FILTER_OUT_BUTTON = '[data-test-subj="tiBarchartFilterOutButton"]';
export const INDICATORS_TABLE_CELL_TIMELINE_BUTTON =
'[data-test-subj="tiIndicatorsTableCellTimelineButton"]';
export const FLYOUT_TABLE_ROW_TIMELINE_BUTTON = '[data-test-subj="tiFlyoutTableRowTimelineButton"]';
export const INDICATORS_TABLE_CELL_FILTER_IN_BUTTON =
'[data-test-subj="tiIndicatorsTableCellFilterInButton"]';
export const INDICATORS_TABLE_CELL_FILTER_OUT_BUTTON =
'[data-test-subj="tiIndicatorsTableCellFilterOutButton"]';
export const FLYOUT_OVERVIEW_TAB_TABLE_ROW_TIMELINE_BUTTON =
'[data-test-subj="tiFlyoutOverviewTableRowTimelineButton"]';
export const FLYOUT_OVERVIEW_TAB_TABLE_ROW_FILTER_IN_BUTTON =
'[data-test-subj="tiFlyoutOverviewTableRowFilterInButton"]';
export const FLYOUT_OVERVIEW_TAB_TABLE_ROW_FILTER_OUT_BUTTON =
'[data-test-subj="tiFlyoutOverviewTableRowFilterOutButton"]';
export const FLYOUT_OVERVIEW_TAB_BLOCKS_ITEM =
'[data-test-subj="tiFlyoutOverviewHighLevelBlocksItem"]';
export const FLYOUT_OVERVIEW_TAB_BLOCKS_TIMELINE_BUTTON =
'[data-test-subj="tiFlyoutOverviewHighLevelBlocksTimelineButton"]';
export const FLYOUT_OVERVIEW_TAB_BLOCKS_FILTER_IN_BUTTON =
'[data-test-subj="tiFlyoutOverviewHighLevelBlocksFilterInButton"]';
export const FLYOUT_OVERVIEW_TAB_BLOCKS_FILTER_OUT_BUTTON =
'[data-test-subj="tiFlyoutOverviewHighLevelBlocksFilterOutButton"]';
export const FLYOUT_TABLE_TAB_ROW_TIMELINE_BUTTON =
'[data-test-subj="tiFlyoutTableTabRowTimelineButton"]';
export const FLYOUT_TABLE_TAB_ROW_FILTER_IN_BUTTON =
'[data-test-subj="tiFlyoutTableTabRowFilterInButton"]';
export const FLYOUT_TABLE_TAB_ROW_FILTER_OUT_BUTTON =
'[data-test-subj="tiFlyoutTableTabRowFilterOutButton"]';
export const UNTITLED_TIMELINE_BUTTON = '[data-test-subj="flyoutOverlay"]';
export const TIMELINE_DRAGGABLE_ITEM = '[data-test-subj="providerContainer"]';
export const FILTER_IN_BUTTON = '[data-test-subj="tiFilterInIcon"]';
export const FILTER_OUT_BUTTON = '[data-test-subj="tiFilterOutIcon"]';
export const FILTER_IN_COMPONENT = '[data-test-subj="tiFilterInComponent"]';
export const FILTER_OUT_COMPONENT = '[data-test-subj="tiFilterOutComponent"]';
export const KQL_FILTER = '[id="popoverFor_filter0"]';

View file

@ -11,8 +11,10 @@ import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { CoreStart, IUiSettingsClient } from '@kbn/core/public';
import { TimelinesUIStart } from '@kbn/timelines-plugin/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { mockIndicatorsFiltersContext } from './mock_indicators_filters_context';
import { SecuritySolutionContext } from '../../containers/security_solution_context';
import { getSecuritySolutionContextMock } from './mock_security_context';
import { IndicatorsFiltersContext } from '../../modules/indicators/context';
import { FieldTypesContext } from '../../containers/field_types_provider';
import { generateFieldTypeMap } from './mock_field_type_map';
import { mockUiSettingsService } from './mock_kibana_ui_settings_service';
@ -81,7 +83,9 @@ export const StoryProvidersComponent: VFC<StoryProvidersComponentProps> = ({
<EuiThemeProvider>
<FieldTypesContext.Provider value={generateFieldTypeMap()}>
<SecuritySolutionContext.Provider value={securitySolutionContextMock}>
<KibanaReactContext.Provider>{children}</KibanaReactContext.Provider>
<IndicatorsFiltersContext.Provider value={mockIndicatorsFiltersContext}>
<KibanaReactContext.Provider>{children}</KibanaReactContext.Provider>
</IndicatorsFiltersContext.Provider>
</SecuritySolutionContext.Provider>
</FieldTypesContext.Provider>
</EuiThemeProvider>

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, VFC } from 'react';
import { EuiButtonIcon, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { ComponentType } from '../../../../../common/types/component_type';
import { FilterIn } from '../../../query_bar/components/filter_in';
import { FilterOut } from '../../../query_bar/components/filter_out';
import { AddToTimeline } from '../../../timeline/components/add_to_timeline';
export const POPOVER_BUTTON_TEST_ID = 'tiBarchartPopoverButton';
export const TIMELINE_BUTTON_TEST_ID = 'tiBarchartTimelineButton';
export const FILTER_IN_BUTTON_TEST_ID = 'tiBarchartFilterInButton';
export const FILTER_OUT_BUTTON_TEST_ID = 'tiBarchartFilterOutButton';
export interface IndicatorBarchartLegendActionProps {
/**
* Indicator
*/
data: string;
/**
* Indicator field selected in the IndicatorFieldSelector component, passed to the {@link AddToTimeline} to populate the timeline.
*/
field: string;
}
export const IndicatorBarchartLegendAction: VFC<IndicatorBarchartLegendActionProps> = ({
data,
field,
}) => {
const [isPopoverOpen, setPopover] = useState(false);
const popoverItems = [
<FilterIn
data={data}
field={field}
type={ComponentType.ContextMenu}
data-test-subj={FILTER_IN_BUTTON_TEST_ID}
/>,
<FilterOut
data={data}
field={field}
type={ComponentType.ContextMenu}
data-test-subj={FILTER_OUT_BUTTON_TEST_ID}
/>,
<AddToTimeline
data={data}
field={field}
type={ComponentType.ContextMenu}
data-test-subj={TIMELINE_BUTTON_TEST_ID}
/>,
];
return (
<EuiPopover
data-test-subj={POPOVER_BUTTON_TEST_ID}
button={
<EuiButtonIcon
iconType="boxesHorizontal"
iconSize="s"
size="xs"
onClick={() => setPopover(!isPopoverOpen)}
/>
}
isOpen={isPopoverOpen}
closePopover={() => setPopover(false)}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel size="s" items={popoverItems} />
</EuiPopover>
);
};

View file

@ -9,22 +9,39 @@ import type { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import React, { VFC } from 'react';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { Indicator } from '../../../../../common/types/indicator';
import { FilterInOut } from '../../../query_bar/components/filter_in_out';
import { FilterIn } from '../../../query_bar/components/filter_in';
import { FilterOut } from '../../../query_bar/components/filter_out';
import { AddToTimeline } from '../../../timeline/components/add_to_timeline';
import { getIndicatorFieldAndValue } from '../../lib/field_value';
export const TIMELINE_BUTTON_TEST_ID = 'TimelineButton';
export const FILTER_IN_BUTTON_TEST_ID = 'FilterInButton';
export const FILTER_OUT_BUTTON_TEST_ID = 'FilterOutButton';
interface IndicatorValueActions {
/**
* Indicator complete object.
*/
indicator: Indicator;
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
/**
* Indicator field used for the filter in/out and add to timeline feature.
*/
field: string;
testId?: string;
/**
* Only used with `EuiDataGrid` (see {@link AddToTimelineButtonProps}).
*/
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
/**
* Used for unit and e2e tests.
*/
['data-test-subj']?: string;
}
export const IndicatorValueActions: VFC<IndicatorValueActions> = ({
indicator,
field,
testId,
Component,
...props
}) => {
const { key, value } = getIndicatorFieldAndValue(indicator, field);
@ -32,10 +49,20 @@ export const IndicatorValueActions: VFC<IndicatorValueActions> = ({
return null;
}
const filterInTestId = `${props['data-test-subj']}${FILTER_IN_BUTTON_TEST_ID}`;
const filterOutTestId = `${props['data-test-subj']}${FILTER_OUT_BUTTON_TEST_ID}`;
const timelineTestId = `${props['data-test-subj']}${TIMELINE_BUTTON_TEST_ID}`;
return (
<>
<FilterInOut as={Component} data={indicator} field={field} />
<AddToTimeline as={Component} data={indicator} field={field} testId={testId} />
<FilterIn as={Component} data={indicator} field={field} data-test-subj={filterInTestId} />
<FilterOut as={Component} data={indicator} field={field} data-test-subj={filterOutTestId} />
<AddToTimeline
as={Component}
data={indicator}
field={field}
data-test-subj={timelineTestId}
/>
</>
);
};

View file

@ -9,8 +9,7 @@ import moment from 'moment';
import React from 'react';
import { Story } from '@storybook/react';
import { TimeRangeBounds } from '@kbn/data-plugin/common';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { StoryProvidersComponent } from '../../../../common/mocks/story_providers';
import { mockKibanaTimelinesService } from '../../../../common/mocks/mock_kibana_timelines_service';
import { ChartSeries } from '../../hooks/use_aggregated_indicators';
import { IndicatorsBarChart } from './indicators_barchart';
@ -58,38 +57,34 @@ const mockDateRange: TimeRangeBounds = {
const mockField: string = 'threat.indicator.ip';
const KibanaReactContext = createKibanaReactContext({
timelines: mockKibanaTimelinesService,
} as unknown as CoreStart);
export default {
component: IndicatorsBarChart,
title: 'IndicatorsBarChart',
};
export const Default: Story<void> = () => (
<KibanaReactContext.Provider>
<StoryProvidersComponent kibana={{ timelines: mockKibanaTimelinesService }}>
<IndicatorsBarChart indicators={mockIndicators} field={mockField} dateRange={mockDateRange} />
</KibanaReactContext.Provider>
</StoryProvidersComponent>
);
export const NoData: Story<void> = () => (
<KibanaReactContext.Provider>
<StoryProvidersComponent kibana={{ timelines: mockKibanaTimelinesService }}>
<IndicatorsBarChart indicators={[]} field={''} dateRange={mockDateRange} />
</KibanaReactContext.Provider>
</StoryProvidersComponent>
);
export const CustomHeight: Story<void> = () => {
const mockHeight = '500px';
return (
<KibanaReactContext.Provider>
<StoryProvidersComponent kibana={{ timelines: mockKibanaTimelinesService }}>
<IndicatorsBarChart
indicators={mockIndicators}
field={mockField}
dateRange={mockDateRange}
height={mockHeight}
/>
</KibanaReactContext.Provider>
</StoryProvidersComponent>
);
};

View file

@ -9,12 +9,10 @@ import React, { VFC } from 'react';
import { Axis, BarSeries, Chart, Position, ScaleType, Settings } from '@elastic/charts';
import { EuiThemeProvider } from '@elastic/eui';
import { TimeRangeBounds } from '@kbn/data-plugin/common';
import { AddToTimeline } from '../../../timeline/components/add_to_timeline';
import { IndicatorBarchartLegendAction } from '../indicator_barchart_legend_action/indicator_barchart_legend_action';
import { barChartTimeAxisLabelFormatter } from '../../../../common/utils/dates';
import { ChartSeries } from '../../hooks/use_aggregated_indicators';
export const TIMELINE_BUTTON_TEST_ID = 'tiTimelineButton';
const ID = 'tiIndicator';
const DEFAULT_CHART_HEIGHT = '200px';
const DEFAULT_CHART_WIDTH = '100%';
@ -54,9 +52,7 @@ export const IndicatorsBarChart: VFC<IndicatorsBarChartProps> = ({
showLegend
showLegendExtra
legendPosition={Position.Right}
legendAction={({ label }) => (
<AddToTimeline data={label} field={field} testId={TIMELINE_BUTTON_TEST_ID} />
)}
legendAction={({ label }) => <IndicatorBarchartLegendAction field={field} data={label} />}
/>
<Axis
id={`${ID}TimeAxis`}

View file

@ -49,14 +49,14 @@ export const IndicatorFieldsTable: VFC<IndicatorFieldsTableProps> = ({
actions: [
{
render: (field: string) => (
<IndicatorValueActions field={field} indicator={indicator} />
<IndicatorValueActions field={field} indicator={indicator} {...rest} />
),
width: '72px',
},
],
},
] as Array<EuiBasicTableColumn<string>>,
[indicator]
[indicator, rest]
);
return <EuiInMemoryTable items={fields} columns={columns} sorting={true} {...rest} />;

View file

@ -44,15 +44,16 @@ const panelProps = {
export interface IndicatorBlockProps {
indicator: Indicator;
field: string;
['data-test-subj']?: string;
}
/**
* Renders indicator field value in a rectangle, to highlight it even more
*/
export const IndicatorBlock: VFC<IndicatorBlockProps> = ({ field, indicator }) => {
export const IndicatorBlock: VFC<IndicatorBlockProps> = ({ field, indicator, ...props }) => {
return (
<EuiPanel {...panelProps}>
<VisibleOnHover>
<VisibleOnHover data-test-subj={`${props['data-test-subj']}Item`}>
<EuiText>
<IndicatorFieldLabel field={field} />
</EuiText>
@ -60,7 +61,7 @@ export const IndicatorBlock: VFC<IndicatorBlockProps> = ({ field, indicator }) =
<EuiText size="s">
<IndicatorFieldValue indicator={indicator} field={field} />
<span className="actionsWrapper">
<IndicatorValueActions indicator={indicator} field={field} />
<IndicatorValueActions indicator={indicator} field={field} {...props} />
</span>
</EuiText>
</VisibleOnHover>

View file

@ -31,7 +31,7 @@ const highLevelFields = [
RawIndicatorFieldId.Confidence,
];
export const TI_FLYOUT_OVERVIEW_TABLE = 'tiFlyoutOverviewTable';
export const TI_FLYOUT_OVERVIEW_TABLE = 'tiFlyoutOverviewTableRow';
export const TI_FLYOUT_OVERVIEW_HIGH_LEVEL_BLOCKS = 'tiFlyoutOverviewHighLevelBlocks';
export interface IndicatorsFlyoutOverviewProps {
@ -50,7 +50,11 @@ export const IndicatorsFlyoutOverview: VFC<IndicatorsFlyoutOverviewProps> = ({
<EuiFlexGroup data-test-subj={TI_FLYOUT_OVERVIEW_HIGH_LEVEL_BLOCKS}>
{highLevelFields.map((field) => (
<EuiFlexItem key={field}>
<IndicatorBlock indicator={indicator} field={field} />
<IndicatorBlock
indicator={indicator}
field={field}
data-test-subj={TI_FLYOUT_OVERVIEW_HIGH_LEVEL_BLOCKS}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>

View file

@ -10,8 +10,7 @@ import { Indicator } from '../../../../../../../common/types/indicator';
import { IndicatorEmptyPrompt } from '../../components/indicator_empty_prompt';
import { IndicatorFieldsTable } from '../../components/indicator_fields_table';
export const TABLE_TEST_ID = 'tiFlyoutTableMemoryTable';
export const TIMELINE_BUTTON_TEST_ID = 'tiFlyoutTableRowTimelineButton';
export const TABLE_TEST_ID = 'tiFlyoutTableTabRow';
const search = {
box: {

View file

@ -7,14 +7,18 @@
import React, { VFC } from 'react';
import { EuiDataGridColumnCellActionProps } from '@elastic/eui/src/components/datagrid/data_grid_types';
import { FilterInOut } from '../../../query_bar/components/filter_in_out';
import { ComponentType } from '../../../../../common/types/component_type';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { Indicator } from '../../../../../common/types/indicator';
import { Pagination } from '../../hooks/use_indicators';
import { AddToTimeline } from '../../../timeline/components/add_to_timeline';
import { getIndicatorFieldAndValue } from '../../lib/field_value';
import { FilterIn } from '../../../query_bar/components/filter_in';
import { FilterOut } from '../../../query_bar/components/filter_out';
export const CELL_TIMELINE_BUTTON_TEST_ID = 'tiIndicatorsTableCellTimelineButton';
export const CELL_FILTER_IN_BUTTON_TEST_ID = 'tiIndicatorsTableCellFilterInButton';
export const CELL_FILTER_OUT_BUTTON_TEST_ID = 'tiIndicatorsTableCellFilterOutButton';
export interface CellActionsProps
extends Omit<EuiDataGridColumnCellActionProps, 'colIndex' | 'isExpanded'> {
@ -50,12 +54,25 @@ export const CellActions: VFC<CellActionsProps> = ({
return (
<>
<FilterInOut as={Component} data={indicator} field={key} />
<FilterIn
as={Component}
data={indicator}
field={key}
type={ComponentType.EuiDataGrid}
data-test-subj={CELL_FILTER_IN_BUTTON_TEST_ID}
/>
<FilterOut
as={Component}
data={indicator}
field={key}
type={ComponentType.EuiDataGrid}
data-test-subj={CELL_FILTER_OUT_BUTTON_TEST_ID}
/>
<AddToTimeline
data={indicator}
field={key}
as={Component}
testId={CELL_TIMELINE_BUTTON_TEST_ID}
data-test-subj={CELL_TIMELINE_BUTTON_TEST_ID}
/>
</>
);

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FilterInOut /> should render an empty component (wrong data input) 1`] = `
exports[`<FilterIn /> should render an empty component (wrong data input) 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
@ -61,7 +61,7 @@ Object {
}
`;
exports[`<FilterInOut /> should render an empty component (wrong field input) 1`] = `
exports[`<FilterIn /> should render an empty component (wrong field input) 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
@ -122,30 +122,13 @@ Object {
}
`;
exports[`<FilterInOut /> should render two Component (for DataGrid use) 1`] = `
exports[`<FilterIn /> should render one Component (for EuiDataGrid use) 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<div
css="[object Object]"
data-test-subj="tiFilterInComponent"
>
<button
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</div>
<div
css="[object Object]"
data-test-subj="tiFilterOutComponent"
>
<button
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
@ -164,23 +147,6 @@ Object {
"container": <div>
<div
css="[object Object]"
data-test-subj="tiFilterInComponent"
>
<button
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</div>
<div
css="[object Object]"
data-test-subj="tiFilterOutComponent"
>
<button
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
@ -249,7 +215,7 @@ Object {
}
`;
exports[`<FilterInOut /> should render two EuiButtonIcon 1`] = `
exports[`<FilterIn /> should render one EuiButtonIcon 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
@ -257,7 +223,7 @@ Object {
<button
aria-label="Filter In"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="tiFilterInIcon"
data-test-subj="abc"
type="button"
>
<span
@ -267,26 +233,13 @@ Object {
data-euiicon-type="plusInCircle"
/>
</button>
<button
aria-label="Filter Out"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="tiFilterOutIcon"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="minusInCircle"
/>
</button>
</div>
</body>,
"container": <div>
<button
aria-label="Filter In"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="tiFilterInIcon"
data-test-subj="abc"
type="button"
>
<span
@ -296,18 +249,106 @@ Object {
data-euiicon-type="plusInCircle"
/>
</button>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<FilterIn /> should render one EuiContextMenuItem (for EuiContextMenu use) 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<button
class="euiContextMenuItem euiContextMenuItem--small"
type="button"
>
<span
class="euiContextMenu__itemLayout"
>
<span
class="euiContextMenu__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
<span
class="euiContextMenuItem__text"
>
Filter In
</span>
</span>
</button>
</div>
</body>,
"container": <div>
<button
aria-label="Filter Out"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="tiFilterOutIcon"
class="euiContextMenuItem euiContextMenuItem--small"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="minusInCircle"
/>
class="euiContextMenu__itemLayout"
>
<span
class="euiContextMenu__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
<span
class="euiContextMenuItem__text"
>
Filter In
</span>
</span>
</button>
</div>,
"debug": [Function],

View file

@ -8,13 +8,13 @@
import React from 'react';
import { Story } from '@storybook/react';
import { mockIndicatorsFiltersContext } from '../../../../common/mocks/mock_indicators_filters_context';
import { FilterInOut } from './filter_in_out';
import { generateMockIndicator, Indicator } from '../../../../../common/types/indicator';
import { IndicatorsFiltersContext } from '../../../indicators/context';
import { FilterIn } from '.';
export default {
component: FilterInOut,
title: 'FilterInOut',
component: FilterIn,
title: 'FilterIn',
};
export const Default: Story<void> = () => {
@ -23,7 +23,7 @@ export const Default: Story<void> = () => {
return (
<IndicatorsFiltersContext.Provider value={mockIndicatorsFiltersContext}>
<FilterInOut data={mockIndicator} field={mockField} />
<FilterIn data={mockIndicator} field={mockField} />
</IndicatorsFiltersContext.Provider>
);
};

View file

@ -7,11 +7,12 @@
import React, { FunctionComponent } from 'react';
import { render } from '@testing-library/react';
import { FilterInOut, IN_ICON_TEST_ID, OUT_ICON_TEST_ID } from './filter_in_out';
import { generateMockIndicator, Indicator } from '../../../../../common/types/indicator';
import { EuiButtonIcon } from '@elastic/eui';
import { generateMockIndicator, Indicator } from '../../../../../common/types/indicator';
import { useIndicatorsFiltersContext } from '../../../indicators/hooks/use_indicators_filters_context';
import { mockIndicatorsFiltersContext } from '../../../../common/mocks/mock_indicators_filters_context';
import { FilterIn } from '.';
import { ComponentType } from '../../../../../common/types/component_type';
jest.mock('../../../indicators/hooks/use_indicators_filters_context');
@ -19,39 +20,50 @@ const mockIndicator: Indicator = generateMockIndicator();
const mockField: string = 'threat.feed.name';
describe('<FilterInOut />', () => {
const mockTestId: string = 'abc';
describe('<FilterIn />', () => {
beforeEach(() => {
(
useIndicatorsFiltersContext as jest.MockedFunction<typeof useIndicatorsFiltersContext>
).mockReturnValue(mockIndicatorsFiltersContext);
});
it('should render two EuiButtonIcon', () => {
const component = render(<FilterInOut data={mockIndicator} field={mockField} />);
it('should render one EuiButtonIcon', () => {
const component = render(
<FilterIn data={mockIndicator} field={mockField} data-test-subj={mockTestId} />
);
expect(component.getByTestId(IN_ICON_TEST_ID)).toBeInTheDocument();
expect(component.getByTestId(OUT_ICON_TEST_ID)).toBeInTheDocument();
expect(component.getByTestId(mockTestId)).toBeInTheDocument();
expect(component).toMatchSnapshot();
});
it('should render two Component (for DataGrid use)', () => {
it('should render one Component (for EuiDataGrid use)', () => {
const mockType: ComponentType = ComponentType.EuiDataGrid;
const mockComponent: FunctionComponent = () => <EuiButtonIcon iconType="plusInCircle" />;
const component = render(
<FilterInOut data={mockIndicator} field={mockField} as={mockComponent} />
<FilterIn data={mockIndicator} field={mockField} type={mockType} as={mockComponent} />
);
expect(component).toMatchSnapshot();
});
it('should render one EuiContextMenuItem (for EuiContextMenu use)', () => {
const mockType: ComponentType = ComponentType.ContextMenu;
const component = render(<FilterIn data={mockIndicator} field={mockField} type={mockType} />);
expect(component).toMatchSnapshot();
});
it('should render an empty component (wrong data input)', () => {
const component = render(<FilterInOut data={''} field={mockField} />);
const component = render(<FilterIn data={''} field={mockField} />);
expect(component).toMatchSnapshot();
});
it('should render an empty component (wrong field input)', () => {
const component = render(<FilterInOut data={mockIndicator} field={''} />);
const component = render(<FilterIn data={mockIndicator} field={''} />);
expect(component).toMatchSnapshot();
});

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, VFC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty, EuiButtonIcon, EuiContextMenuItem } from '@elastic/eui';
import { Filter } from '@kbn/es-query';
import { ComponentType } from '../../../../../common/types/component_type';
import { useIndicatorsFiltersContext } from '../../../indicators/hooks/use_indicators_filters_context';
import { getIndicatorFieldAndValue } from '../../../indicators/lib/field_value';
import { FilterIn as FilterInConst, updateFiltersArray } from '../../lib/filter';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { Indicator } from '../../../../../common/types/indicator';
import { useStyles } from './styles';
const ICON_TYPE = 'plusInCircle';
const ICON_TITLE = i18n.translate('xpack.threatIntelligence.queryBar.filterInButton', {
defaultMessage: 'Filter In',
});
export interface FilterInProps {
/**
* Value used to filter in/out in the KQL bar. Used in combination with field if is type of {@link Indicator}.
*/
data: Indicator | string;
/**
* Value used to filter in /out in the KQL bar.
*/
field: string;
/**
* Dictates the way the FilterIn component is rendered depending on the situation in which it's used
*/
type?: ComponentType;
/**
* Display component for when the FilterIn component is used within a DataGrid
*/
as?: typeof EuiButtonEmpty | typeof EuiButtonIcon | typeof EuiContextMenuItem;
/**
* Used for unit and e2e tests.
*/
['data-test-subj']?: string;
}
/**
* Retrieves the indicator's field and value, then creates a new {@link Filter} and adds it to the {@link FilterManager}.
*
* The component has 3 renders depending on where it's used: within a EuiContextMenu, a EuiDataGrid or not.
*
* @returns filter in button
*/
export const FilterIn: VFC<FilterInProps> = ({ data, field, type, as: Component, ...props }) => {
const styles = useStyles();
const { filterManager } = useIndicatorsFiltersContext();
const { key, value } =
typeof data === 'string' ? { key: field, value: data } : getIndicatorFieldAndValue(data, field);
const filterIn = useCallback((): void => {
const existingFilters = filterManager.getFilters();
const newFilters: Filter[] = updateFiltersArray(existingFilters, key, value, FilterInConst);
filterManager.setFilters(newFilters);
}, [filterManager, key, value]);
if (!value || value === EMPTY_VALUE || !key) {
return <></>;
}
if (type === ComponentType.EuiDataGrid) {
return (
<div {...props} css={styles.button}>
{/* @ts-ignore*/}
<Component aria-label={ICON_TITLE} iconType={ICON_TYPE} onClick={filterIn} />
</div>
);
}
if (type === ComponentType.ContextMenu) {
return (
<EuiContextMenuItem
key="addToTimeline"
icon="plusInCircle"
size="s"
onClick={filterIn}
{...props}
>
Filter In
</EuiContextMenuItem>
);
}
return (
<EuiButtonIcon
aria-label={ICON_TITLE}
iconType={ICON_TYPE}
iconSize="s"
size="xs"
color="primary"
onClick={filterIn}
{...props}
/>
);
};

View file

@ -1,111 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, VFC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import { Filter } from '@kbn/es-query';
import { useIndicatorsFiltersContext } from '../../../indicators/hooks/use_indicators_filters_context';
import { getIndicatorFieldAndValue } from '../../../indicators/lib/field_value';
import { FilterIn, FilterOut, updateFiltersArray } from '../../lib/filter';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { Indicator } from '../../../../../common/types/indicator';
import { useStyles } from './styles';
export const IN_ICON_TEST_ID = 'tiFilterInIcon';
export const OUT_ICON_TEST_ID = 'tiFilterOutIcon';
export const IN_COMPONENT_TEST_ID = 'tiFilterInComponent';
export const OUT_COMPONENT_TEST_ID = 'tiFilterOutComponent';
const IN_ICON_TYPE = 'plusInCircle';
const IN_ICON_TITLE = i18n.translate('xpack.threatIntelligence.queryBar.filterInButton', {
defaultMessage: 'Filter In',
});
const OUT_ICON_TYPE = 'minusInCircle';
const OUT_ICON_TITLE = i18n.translate('xpack.threatIntelligence.queryBar.filterOutButton', {
defaultMessage: 'Filter Out',
});
export interface FilterInOutProps {
/**
* Value used to filter in/out in the KQL bar. Used in combination with field if is type of {@link Indicator}.
*/
data: Indicator | string;
/**
* Value used to filter in /out in the KQL bar.
*/
field: string;
/**
* Display component for when the FilterIn component is used within a DataGrid
*/
as?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
}
/**
* Retrieves the indicator's field and value, then creates a new {@link Filter} and adds it to the {@link FilterManager}.
*/
export const FilterInOut: VFC<FilterInOutProps> = ({ data, field, as: Component }) => {
const styles = useStyles();
const { filterManager } = useIndicatorsFiltersContext();
const { key, value } =
typeof data === 'string' ? { key: field, value: data } : getIndicatorFieldAndValue(data, field);
const filterIn = useCallback((): void => {
const existingFilters = filterManager.getFilters();
const newFilters: Filter[] = updateFiltersArray(existingFilters, key, value, FilterIn);
filterManager.setFilters(newFilters);
}, [filterManager, key, value]);
const filterOut = useCallback(() => {
const existingFilters: Filter[] = filterManager.getFilters();
const newFilters: Filter[] = updateFiltersArray(existingFilters, key, value, FilterOut);
filterManager.setFilters(newFilters);
}, [filterManager, key, value]);
if (!value || value === EMPTY_VALUE || !key) {
return <></>;
}
return Component ? (
<>
<div data-test-subj={IN_COMPONENT_TEST_ID} css={styles.button}>
<Component aria-label={IN_ICON_TITLE} iconType={IN_ICON_TYPE} onClick={filterIn} />
</div>
<div data-test-subj={OUT_COMPONENT_TEST_ID} css={styles.button}>
<Component
data-test-subj={IN_ICON_TEST_ID}
aria-label={OUT_ICON_TITLE}
iconType={OUT_ICON_TYPE}
onClick={filterOut}
/>
</div>
</>
) : (
<>
<EuiButtonIcon
data-test-subj={IN_ICON_TEST_ID}
aria-label={IN_ICON_TITLE}
iconType={IN_ICON_TYPE}
iconSize="s"
size="xs"
color="primary"
onClick={filterIn}
/>
<EuiButtonIcon
data-test-subj={OUT_ICON_TEST_ID}
aria-label={OUT_ICON_TITLE}
iconType={OUT_ICON_TYPE}
iconSize="s"
size="xs"
color="primary"
onClick={filterOut}
/>
</>
);
};

View file

@ -0,0 +1,406 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FilterOut /> should render an empty component (wrong data input) 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div />
</body>,
"container": <div />,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<FilterOut /> should render an empty component (wrong field input) 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div />
</body>,
"container": <div />,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<FilterOut /> should render one Component (for EuiDataGrid use) 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<div
css="[object Object]"
>
<button
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</div>
</div>
</body>,
"container": <div>
<div
css="[object Object]"
>
<button
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="plusInCircle"
/>
</button>
</div>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<FilterOut /> should render one EuiButtonIcon 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<button
aria-label="Filter Out"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="abc"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="minusInCircle"
/>
</button>
</div>
</body>,
"container": <div>
<button
aria-label="Filter Out"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="abc"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="minusInCircle"
/>
</button>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`<FilterOut /> should render one EuiContextMenuItem (for EuiContextMenu use) 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<button
class="euiContextMenuItem euiContextMenuItem--small"
type="button"
>
<span
class="euiContextMenu__itemLayout"
>
<span
class="euiContextMenu__icon"
color="inherit"
data-euiicon-type="minusInCircle"
/>
<span
class="euiContextMenuItem__text"
>
Filter Out
</span>
</span>
</button>
</div>
</body>,
"container": <div>
<button
class="euiContextMenuItem euiContextMenuItem--small"
type="button"
>
<span
class="euiContextMenu__itemLayout"
>
<span
class="euiContextMenu__icon"
color="inherit"
data-euiicon-type="minusInCircle"
/>
<span
class="euiContextMenuItem__text"
>
Filter Out
</span>
</span>
</button>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { Story } from '@storybook/react';
import { mockIndicatorsFiltersContext } from '../../../../common/mocks/mock_indicators_filters_context';
import { generateMockIndicator, Indicator } from '../../../../../common/types/indicator';
import { IndicatorsFiltersContext } from '../../../indicators/context';
import { FilterOut } from '.';
export default {
component: FilterOut,
title: 'FilterOut',
};
export const Default: Story<void> = () => {
const mockIndicator: Indicator = generateMockIndicator();
const mockField: string = 'threat.feed.name';
return (
<IndicatorsFiltersContext.Provider value={mockIndicatorsFiltersContext}>
<FilterOut data={mockIndicator} field={mockField} />
</IndicatorsFiltersContext.Provider>
);
};

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import { render } from '@testing-library/react';
import { EuiButtonIcon } from '@elastic/eui';
import { generateMockIndicator, Indicator } from '../../../../../common/types/indicator';
import { useIndicatorsFiltersContext } from '../../../indicators/hooks/use_indicators_filters_context';
import { mockIndicatorsFiltersContext } from '../../../../common/mocks/mock_indicators_filters_context';
import { FilterOut } from '.';
import { ComponentType } from '../../../../../common/types/component_type';
jest.mock('../../../indicators/hooks/use_indicators_filters_context');
const mockIndicator: Indicator = generateMockIndicator();
const mockField: string = 'threat.feed.name';
const mockTestId: string = 'abc';
describe('<FilterOut />', () => {
beforeEach(() => {
(
useIndicatorsFiltersContext as jest.MockedFunction<typeof useIndicatorsFiltersContext>
).mockReturnValue(mockIndicatorsFiltersContext);
});
it('should render one EuiButtonIcon', () => {
const component = render(
<FilterOut data={mockIndicator} field={mockField} data-test-subj={mockTestId} />
);
expect(component.getByTestId(mockTestId)).toBeInTheDocument();
expect(component).toMatchSnapshot();
});
it('should render one Component (for EuiDataGrid use)', () => {
const mockType: ComponentType = ComponentType.EuiDataGrid;
const mockComponent: FunctionComponent = () => <EuiButtonIcon iconType="plusInCircle" />;
const component = render(
<FilterOut data={mockIndicator} field={mockField} type={mockType} as={mockComponent} />
);
expect(component).toMatchSnapshot();
});
it('should render one EuiContextMenuItem (for EuiContextMenu use)', () => {
const mockType: ComponentType = ComponentType.ContextMenu;
const component = render(<FilterOut data={mockIndicator} field={mockField} type={mockType} />);
expect(component).toMatchSnapshot();
});
it('should render an empty component (wrong data input)', () => {
const component = render(<FilterOut data={''} field={mockField} />);
expect(component).toMatchSnapshot();
});
it('should render an empty component (wrong field input)', () => {
const component = render(<FilterOut data={mockIndicator} field={''} />);
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, VFC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty, EuiButtonIcon, EuiContextMenuItem } from '@elastic/eui';
import { Filter } from '@kbn/es-query';
import { ComponentType } from '../../../../../common/types/component_type';
import { useIndicatorsFiltersContext } from '../../../indicators/hooks/use_indicators_filters_context';
import { getIndicatorFieldAndValue } from '../../../indicators/lib/field_value';
import { FilterOut as FilterOutConst, updateFiltersArray } from '../../lib/filter';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { Indicator } from '../../../../../common/types/indicator';
import { useStyles } from './styles';
const ICON_TYPE = 'minusInCircle';
const ICON_TITLE = i18n.translate('xpack.threatIntelligence.queryBar.filterOutButton', {
defaultMessage: 'Filter Out',
});
export interface FilterOutProps {
/**
* Value used to filter in/out in the KQL bar. Used in combination with field if is type of {@link Indicator}.
*/
data: Indicator | string;
/**
* Value used to filter in /out in the KQL bar.
*/
field: string;
/**
* Dictates the way the FilterOut component is rendered depending on the situation in which it's used
*/
type?: ComponentType;
/**
* Display component for when the FilterIn component is used within a DataGrid
*/
as?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
/**
* Used for unit and e2e tests.
*/
['data-test-subj']?: string;
}
/**
* Retrieves the indicator's field and value, then creates a new {@link Filter} and adds it to the {@link FilterManager}.
*
* The component has 3 renders depending on where it's used: within a EuiContextMenu, a EuiDataGrid or not.
*
* @returns filter out button
*/
export const FilterOut: VFC<FilterOutProps> = ({ data, field, type, as: Component, ...props }) => {
const styles = useStyles();
const { filterManager } = useIndicatorsFiltersContext();
const { key, value } =
typeof data === 'string' ? { key: field, value: data } : getIndicatorFieldAndValue(data, field);
const filterOut = useCallback(() => {
const existingFilters: Filter[] = filterManager.getFilters();
const newFilters: Filter[] = updateFiltersArray(existingFilters, key, value, FilterOutConst);
filterManager.setFilters(newFilters);
}, [filterManager, key, value]);
if (!value || value === EMPTY_VALUE || !key) {
return <></>;
}
if (type === ComponentType.EuiDataGrid) {
return (
<div {...props} css={styles.button}>
{/* @ts-ignore*/}
<Component aria-label={ICON_TITLE} iconType={ICON_TYPE} onClick={filterOut} />
</div>
);
}
if (type === ComponentType.ContextMenu) {
return (
<EuiContextMenuItem
key="filterOut"
icon="minusInCircle"
size="s"
onClick={filterOut}
{...props}
>
Filter Out
</EuiContextMenuItem>
);
}
return (
<EuiButtonIcon
aria-label={ICON_TITLE}
iconType={ICON_TYPE}
iconSize="s"
size="xs"
color="primary"
onClick={filterOut}
{...props}
/>
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './filter_out';

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CSSObject } from '@emotion/react';
export const useStyles = () => {
const button: CSSObject = {
display: 'inline-flex',
};
return {
button,
};
};

View file

@ -5,10 +5,12 @@
* 2.0.
*/
import React, { VFC } from 'react';
import React, { useRef, VFC } from 'react';
import { DataProvider, QueryOperator } from '@kbn/timelines-plugin/common';
import { AddToTimelineButtonProps } from '@kbn/timelines-plugin/public';
import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui/src/components/button';
import { EuiContextMenuItem } from '@elastic/eui';
import { ComponentType } from '../../../../../common/types/component_type';
import { getIndicatorFieldAndValue } from '../../../indicators/lib/field_value';
import { EMPTY_VALUE } from '../../../../../common/constants';
import { useKibana } from '../../../../hooks/use_kibana';
@ -24,14 +26,18 @@ export interface AddToTimelineProps {
* Value passed to the timeline.
*/
field: string;
/**
* Dictates the way the FilterIn component is rendered depending on the situation in which it's used
*/
type?: ComponentType;
/**
* Only used with `EuiDataGrid` (see {@link AddToTimelineButtonProps}).
*/
as?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
/**
* Used as `data-test-subj` value for e2e tests.
* Used for unit and e2e tests.
*/
testId?: string;
['data-test-subj']?: string;
}
/**
@ -40,11 +46,15 @@ export interface AddToTimelineProps {
* Leverages the built-in functionality retrieves from the timeLineService (see ThreatIntelligenceSecuritySolutionContext in x-pack/plugins/threat_intelligence/public/types.ts)
* Clicking on the button will add a key-value pair to an Untitled timeline.
*
* The component has 2 renders depending on where it's used: within a EuiContextMenu or not.
*
* @returns add to timeline button or an empty component.
*/
export const AddToTimeline: VFC<AddToTimelineProps> = ({ data, field, as, testId }) => {
export const AddToTimeline: VFC<AddToTimelineProps> = ({ data, field, type, as, ...props }) => {
const styles = useStyles();
const contextMenuRef = useRef<HTMLButtonElement>(null);
const addToTimelineButton =
useKibana().services.timelines.getHoverActions().getAddToTimelineButton;
@ -78,10 +88,33 @@ export const AddToTimeline: VFC<AddToTimelineProps> = ({ data, field, as, testId
field: key,
ownFocus: false,
};
// Use case is for the barchart legend (for example).
// We can't use the addToTimelineButton directly because the UI doesn't work in a EuiContextMenu.
// We hide it and use the defaultFocusedButtonRef props to programmatically click it.
if (type === ComponentType.ContextMenu) {
addToTimelineProps.defaultFocusedButtonRef = contextMenuRef;
return (
<>
<div css={styles.displayNone}>{addToTimelineButton(addToTimelineProps)}</div>
<EuiContextMenuItem
key="addToTimeline"
icon="timeline"
size="s"
onClick={() => contextMenuRef.current?.click()}
{...props}
>
Add to Timeline
</EuiContextMenuItem>
</>
);
}
if (as) addToTimelineProps.Component = as;
return (
<div data-test-subj={testId} css={styles.button}>
<div {...props} css={styles.inlineFlex}>
{addToTimelineButton(addToTimelineProps)}
</div>
);

View file

@ -8,11 +8,16 @@
import { CSSObject } from '@emotion/react';
export const useStyles = () => {
const button: CSSObject = {
const inlineFlex: CSSObject = {
display: 'inline-flex',
};
const displayNone: CSSObject = {
display: 'none',
};
return {
button,
inlineFlex,
displayNone,
};
};