Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { set } from '@kbn/safer-lodash-set/fp';
import { getOr } from 'lodash/fp';
import React, { memo, useEffect, useCallback, useMemo } from 'react';
import React, { memo, useCallback, useEffect, useMemo } from 'react';
import type { ConnectedProps } from 'react-redux';
import { connect, useDispatch } from 'react-redux';
import type { Dispatch } from 'redux';
Expand All @@ -16,14 +16,14 @@ import deepEqual from 'fast-deep-equal';

import type { Filter, Query, TimeRange } from '@kbn/es-query';
import type { FilterManager, SavedQuery } from '@kbn/data-plugin/public';
import type { DataViewSpec } from '@kbn/data-views-plugin/public';
import { DataView } from '@kbn/data-views-plugin/public';

import type { OnTimeChangeProps } from '@elastic/eui';
import type { DataViewSpec } from '@kbn/data-views-plugin/public';
import { inputsActions } from '../../store/inputs';
import type { InputsRange } from '../../store/inputs/model';
import type { InputsModelId } from '../../store/inputs/constants';
import type { State, inputsModel } from '../../store';
import type { inputsModel, State } from '../../store';
import { formatDate } from '../super_date_picker';
import {
endSelector,
Expand Down Expand Up @@ -51,6 +51,10 @@ interface SiemSearchBarProps {
dataTestSubj?: string;
hideFilterBar?: boolean;
hideQueryInput?: boolean;
/**
* Allows to hide the query menu button displayed to the left of the query input.
*/
hideQueryMenu?: boolean;
}

export const SearchBarComponent = memo<SiemSearchBarProps & PropsFromRedux>(
Expand All @@ -60,6 +64,7 @@ export const SearchBarComponent = memo<SiemSearchBarProps & PropsFromRedux>(
fromStr,
hideFilterBar = false,
hideQueryInput = false,
hideQueryMenu = false,
id,
isLoading = false,
pollForSignalIndex,
Expand Down Expand Up @@ -337,6 +342,7 @@ export const SearchBarComponent = memo<SiemSearchBarProps & PropsFromRedux>(
showFilterBar={!hideFilterBar}
showDatePicker={true}
showQueryInput={!hideQueryInput}
showQueryMenu={!hideQueryMenu}
allowSavingQueries
dataTestSubj={dataTestSubj}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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 { act, render } from '@testing-library/react';
import { useKibana } from '../../../../common/lib/kibana';
import {
INTEGRATION_BUTTON_TEST_ID,
IntegrationFilterButton,
INTEGRATIONS_LIST_TEST_ID,
} from './integrations_filter_button';
import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';

jest.mock('../../../../common/lib/kibana');

const integrations: EuiSelectableOption[] = [
{
'data-test-subj': 'first',
checked: 'on',
key: 'firstKey',
label: 'firstLabel',
},
{
'data-test-subj': 'second',
key: 'secondKey',
label: 'secondLabel',
},
];

describe('<IntegrationFilterButton />', () => {
it('should render the component', async () => {
(useKibana as jest.Mock).mockReturnValue({
services: { data: { query: { filterManager: jest.fn() } } },
});

await act(async () => {
const { getByTestId } = render(<IntegrationFilterButton integrations={integrations} />);

const button = getByTestId(INTEGRATION_BUTTON_TEST_ID);
expect(button).toBeInTheDocument();
button.click();

await new Promise(process.nextTick);

expect(getByTestId(INTEGRATIONS_LIST_TEST_ID)).toBeInTheDocument();

expect(getByTestId('first')).toHaveTextContent('firstLabel');
expect(getByTestId('second')).toHaveTextContent('secondLabel');
});
});

it('should add a negated filter to filterManager', async () => {
const getFilters = jest.fn().mockReturnValue([]);
const setFilters = jest.fn();
(useKibana as jest.Mock).mockReturnValue({
services: { data: { query: { filterManager: { getFilters, setFilters } } } },
});

await act(async () => {
const { getByTestId } = render(<IntegrationFilterButton integrations={integrations} />);

getByTestId(INTEGRATION_BUTTON_TEST_ID).click();

await new Promise(process.nextTick);

getByTestId('first').click();
expect(setFilters).toHaveBeenCalledWith([
{
meta: {
alias: null,
disabled: false,
index: undefined,
key: 'kibana.alert.rule.name',
negate: true,
params: { query: 'firstKey' },
type: 'phrase',
},
query: { match_phrase: { 'kibana.alert.rule.name': 'firstKey' } },
},
]);
});
});

it('should remove the negated filter from filterManager', async () => {
const getFilters = jest.fn().mockReturnValue([
{
meta: {
alias: null,
disabled: false,
index: undefined,
key: 'kibana.alert.rule.name',
negate: true,
params: { query: 'secondKey' },
type: 'phrase',
},
query: { match_phrase: { 'kibana.alert.rule.name': 'secondKey' } },
},
]);
const setFilters = jest.fn();
(useKibana as jest.Mock).mockReturnValue({
services: { data: { query: { filterManager: { getFilters, setFilters } } } },
});

await act(async () => {
const { getByTestId } = render(<IntegrationFilterButton integrations={integrations} />);

getByTestId(INTEGRATION_BUTTON_TEST_ID).click();

await new Promise(process.nextTick);

// creates a new filter that
getByTestId('second').click();
expect(setFilters).toHaveBeenCalledWith([]);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* 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, { memo, useCallback, useState } from 'react';
import { css } from '@emotion/react';
import {
EuiFilterButton,
EuiFilterGroup,
EuiPopover,
EuiSelectable,
useEuiTheme,
useGeneratedHtmlId,
} from '@elastic/eui';
import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
import type { EuiSelectableOnChangeEvent } from '@elastic/eui/src/components/selectable/selectable';
import type { Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { updateFiltersArray } from '../../../utils/filter';
import { useKibana } from '../../../../common/lib/kibana';

export const INTEGRATION_BUTTON_TEST_ID = 'alert-summary-integration-button';
export const INTEGRATIONS_LIST_TEST_ID = 'alert-summary-integrations-list';

const INTEGRATIONS_BUTTON = i18n.translate(
'xpack.securitySolution.alertSummary.integrations.buttonLabel',
{
defaultMessage: 'Integrations',
}
);

export const FILTER_KEY = 'kibana.alert.rule.name';

export interface IntegrationFilterButtonProps {
/**
* List of integrations the user can select or deselect
*/
integrations: EuiSelectableOption[];
}

/**
* Filter button displayed next to the KQL bar at the top of the alert summary page.
* For the AI for SOC effort, each integration has one rule associated with.
* This means that deselecting an integration is equivalent to filtering out by the rule for that integration.
* The EuiFilterButton works as follow:
* - if an integration is selected, this means that no filters live in filterManager
* - if an integration is deselected, this means that we have a negated filter for that rule in filterManager
*/
export const IntegrationFilterButton = memo(({ integrations }: IntegrationFilterButtonProps) => {
const { euiTheme } = useEuiTheme();

const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []);

const {
data: {
query: { filterManager },
},
} = useKibana().services;

const filterGroupPopoverId = useGeneratedHtmlId({
prefix: 'filterGroupPopover',
});

const [items, setItems] = useState<EuiSelectableOption[]>(integrations);

const onChange = useCallback(
(
options: EuiSelectableOption[],
_: EuiSelectableOnChangeEvent,
changedOption: EuiSelectableOption
) => {
setItems(options);

const ruleName = changedOption.key;
if (ruleName) {
const existingFilters = filterManager.getFilters();
const newFilters: Filter[] = updateFiltersArray(
existingFilters,
FILTER_KEY,
ruleName,
changedOption.checked === 'on'
);
filterManager.setFilters(newFilters);
}
},
[filterManager]
);

const button = (
<EuiFilterButton
badgeColor="accent"
css={css`
background-color: ${euiTheme.colors.backgroundBasePrimary};
`}
data-test-subj={INTEGRATION_BUTTON_TEST_ID}
hasActiveFilters={!!items.find((item) => item.checked === 'on')}
iconType="arrowDown"
isSelected={isPopoverOpen}
numActiveFilters={items.filter((item) => item.checked === 'on').length}
numFilters={items.filter((item) => item.checked !== 'off').length}
onClick={togglePopover}
>
{INTEGRATIONS_BUTTON}
</EuiFilterButton>
);

return (
<EuiFilterGroup>
<EuiPopover
button={button}
closePopover={togglePopover}
id={filterGroupPopoverId}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiSelectable
css={css`
min-width: 200px;
`}
data-test-subj={INTEGRATIONS_LIST_TEST_ID}
options={items}
onChange={onChange}
>
{(list) => list}
</EuiSelectable>
</EuiPopover>
</EuiFilterGroup>
);
});

IntegrationFilterButton.displayName = 'IntegrationFilterButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* 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 { render } from '@testing-library/react';
import type { PackageListItem } from '@kbn/fleet-plugin/common';
import { installationStatuses } from '@kbn/fleet-plugin/common/constants';
import {
INTEGRATION_BUTTON_LOADING_TEST_ID,
SEARCH_BAR_TEST_ID,
SearchBarSection,
} from './search_bar_section';
import type { DataView } from '@kbn/data-views-plugin/common';
import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub';
import { INTEGRATION_BUTTON_TEST_ID } from './integrations_filter_button';
import { useKibana } from '../../../../common/lib/kibana';
import { useIntegrations } from '../../../hooks/alert_summary/use_integrations';

jest.mock('../../../../common/components/search_bar', () => ({
// The module factory of `jest.mock()` is not allowed to reference any out-of-scope variables so we can't use SEARCH_BAR_TEST_ID
SiemSearchBar: () => <div data-test-subj={'alert-summary-search-bar'} />,
}));
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../hooks/alert_summary/use_integrations');

const dataView: DataView = createStubDataView({ spec: {} });
const packages: PackageListItem[] = [
{
id: 'splunk',
name: 'splunk',
status: installationStatuses.Installed,
title: 'Splunk',
version: '',
},
];

describe('<SearchBarSection />', () => {
it('should render all components', () => {
(useIntegrations as jest.Mock).mockReturnValue({
isLoading: false,
integrations: [],
});
(useKibana as jest.Mock).mockReturnValue({
services: { data: { query: { filterManager: jest.fn() } } },
});

const { getByTestId, queryByTestId } = render(
<SearchBarSection dataView={dataView} packages={packages} />
);

expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(INTEGRATION_BUTTON_LOADING_TEST_ID)).not.toBeInTheDocument();
expect(getByTestId(INTEGRATION_BUTTON_TEST_ID)).toBeInTheDocument();
});

it('should render a loading skeleton for the integration button while fetching rules', () => {
(useIntegrations as jest.Mock).mockReturnValue({
isLoading: true,
integrations: [],
});

const { getByTestId, queryByTestId } = render(
<SearchBarSection dataView={dataView} packages={packages} />
);

expect(getByTestId(INTEGRATION_BUTTON_LOADING_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(INTEGRATION_BUTTON_TEST_ID)).not.toBeInTheDocument();
});
});
Loading