diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6c591d2145483..67d1a17b69ca6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -760,6 +760,7 @@ examples/resizable_layout_examples @elastic/kibana-data-discovery x-pack/test/plugin_functional/plugins/resolver_test @elastic/security-solution src/platform/packages/shared/response-ops/alerts-apis @elastic/response-ops src/platform/packages/shared/response-ops/alerts-fields-browser @elastic/response-ops +src/platform/packages/shared/response-ops/alerts-filters-form @elastic/response-ops src/platform/packages/shared/response-ops/alerts-table @elastic/response-ops src/platform/packages/shared/response-ops/rule_form @elastic/response-ops src/platform/packages/shared/response-ops/rule_params @elastic/response-ops diff --git a/package.json b/package.json index bbdcb764a3548..470c10ce3eb1d 100644 --- a/package.json +++ b/package.json @@ -770,6 +770,7 @@ "@kbn/resolver-test-plugin": "link:x-pack/test/plugin_functional/plugins/resolver_test", "@kbn/response-ops-alerts-apis": "link:src/platform/packages/shared/response-ops/alerts-apis", "@kbn/response-ops-alerts-fields-browser": "link:src/platform/packages/shared/response-ops/alerts-fields-browser", + "@kbn/response-ops-alerts-filters-form": "link:src/platform/packages/shared/response-ops/alerts-filters-form", "@kbn/response-ops-alerts-table": "link:src/platform/packages/shared/response-ops/alerts-table", "@kbn/response-ops-rule-form": "link:src/platform/packages/shared/response-ops/rule_form", "@kbn/response-ops-rule-params": "link:src/platform/packages/shared/response-ops/rule_params", diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/README.md b/src/platform/packages/shared/response-ops/alerts-filters-form/README.md new file mode 100644 index 0000000000000..7f3bb64d5a81d --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/README.md @@ -0,0 +1,3 @@ +# @kbn/response-ops-alerts-filters-form + +A form to create and edit boolean filter expressions for alert document search queries. diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_tags.test.tsx b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_tags.test.tsx new file mode 100644 index 0000000000000..086292d9dd6ac --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_tags.test.tsx @@ -0,0 +1,123 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; +import { useGetRuleTagsQuery } from '@kbn/response-ops-rules-apis/hooks/use_get_rule_tags_query'; +import { AlertsFiltersFormContextProvider } from '../contexts/alerts_filters_form_context'; +import { AlertsFilterByRuleTags, filterMetadata } from './alerts_filter_by_rule_tags'; + +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); +jest.mock('@kbn/response-ops-rules-apis/hooks/use_get_rule_tags_query'); +const mockUseGetRuleTagsQuery = jest.mocked(useGetRuleTagsQuery); + +const ruleTagsBaseQueryResult = { + hasNextPage: false, + fetchNextPage: jest.fn(), + refetch: jest.fn(), +}; + +describe('AlertsFilterByRuleTags', () => { + it('should show all available tags as options', async () => { + mockUseGetRuleTagsQuery.mockReturnValue({ + tags: ['tag1', 'tag2'], + isLoading: false, + isError: false, + ...ruleTagsBaseQueryResult, + }); + render( + + + + ); + await userEvent.click(screen.getByRole('combobox')); + expect(screen.getByText('tag1')).toBeInTheDocument(); + expect(screen.getByText('tag2')).toBeInTheDocument(); + }); + + it('should show the selected tag in the combobox', async () => { + mockUseGetRuleTagsQuery.mockReturnValue({ + tags: ['tag1', 'tag2'], + isLoading: false, + isError: false, + ...ruleTagsBaseQueryResult, + }); + render( + + + + ); + const comboboxPills = screen.getAllByTestId('euiComboBoxPill'); + expect(comboboxPills).toHaveLength(1); + expect(comboboxPills[0]).toHaveTextContent('tag1'); + }); + + it('should set the combobox in loading mode while loading the available tags', async () => { + mockUseGetRuleTagsQuery.mockReturnValue({ + tags: [], + isLoading: true, + isError: false, + ...ruleTagsBaseQueryResult, + }); + render( + + + + ); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('should disable the combobox when the tags query fails', async () => { + mockUseGetRuleTagsQuery.mockReturnValue({ + tags: [], + isLoading: false, + isError: true, + ...ruleTagsBaseQueryResult, + }); + render( + + + + ); + const comboboxInput = screen.getByTestId('comboBoxSearchInput'); + expect(comboboxInput).toHaveAttribute('aria-invalid', 'true'); + expect(comboboxInput).toBeDisabled(); + }); + + describe('filterMetadata', () => { + it('should have the correct type id and component', () => { + expect(filterMetadata.id).toEqual('ruleTags'); + expect(filterMetadata.component).toEqual(AlertsFilterByRuleTags); + }); + + describe('isEmpty', () => { + it.each([undefined, null, []])('should return false for %s', (value) => { + expect( + filterMetadata.isEmpty(value as Parameters[0]) + ).toEqual(true); + }); + + it('should return true for non-empty values', () => { + expect(filterMetadata.isEmpty(['test-tag'])).toEqual(false); + }); + }); + }); +}); diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_tags.tsx b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_tags.tsx new file mode 100644 index 0000000000000..07d0d7266a8b9 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_tags.tsx @@ -0,0 +1,95 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; +import { useGetRuleTagsQuery } from '@kbn/response-ops-rules-apis/hooks/use_get_rule_tags_query'; +import React, { useCallback, useMemo } from 'react'; +import { EuiComboBoxProps } from '@elastic/eui/src/components/combo_box/combo_box'; +import { + RULE_TAGS_FILTER_LABEL, + RULE_TAGS_FILTER_NO_OPTIONS_PLACEHOLDER, + RULE_TAGS_FILTER_PLACEHOLDER, + RULE_TAGS_LOAD_ERROR_MESSAGE, +} from '../translations'; +import { useAlertsFiltersFormContext } from '../contexts/alerts_filters_form_context'; +import { AlertsFilterComponentType, AlertsFilterMetadata } from '../types'; + +export const AlertsFilterByRuleTags: AlertsFilterComponentType = ({ + value, + onChange, + isDisabled = false, +}) => { + const { + ruleTypeIds, + services: { + http, + notifications: { toasts }, + }, + } = useAlertsFiltersFormContext(); + + const { tags, isLoading, isError } = useGetRuleTagsQuery({ + enabled: true, + perPage: 10000, + // Only search tags from allowed rule type ids + ruleTypeIds, + http, + toasts, + }); + + const options = useMemo>>( + () => + tags.map((tag) => ({ + label: tag, + })), + [tags] + ); + + const selectedOptions = useMemo( + () => options.filter(({ label }) => value?.includes(label)), + [options, value] + ); + + const onSelectedOptionsChange = useCallback['onChange']>>( + (newOptions) => { + onChange?.(newOptions.map(({ label }) => label)); + }, + [onChange] + ); + + return ( + + + + ); +}; + +export const filterMetadata = { + id: 'ruleTags', + displayName: RULE_TAGS_FILTER_LABEL, + component: AlertsFilterByRuleTags, + isEmpty: (value?: string[]) => !Boolean(value?.length), +} as const satisfies AlertsFilterMetadata; diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_types.test.tsx b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_types.test.tsx new file mode 100644 index 0000000000000..3d9162bfccef5 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_types.test.tsx @@ -0,0 +1,136 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; +import { useGetInternalRuleTypesQuery } from '@kbn/response-ops-rules-apis/hooks/use_get_internal_rule_types_query'; +import { AlertsFiltersFormContextProvider } from '../contexts/alerts_filters_form_context'; +import { AlertsFilterByRuleTypes } from './alerts_filter_by_rule_types'; +import { InternalRuleType } from '@kbn/response-ops-rules-apis/apis/get_internal_rule_types'; +import { filterMetadata } from './alerts_filter_by_rule_types'; + +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); +jest.mock('@kbn/response-ops-rules-apis/hooks/use_get_internal_rule_types_query'); +const mockUseGetInternalRuleTypesQuery = useGetInternalRuleTypesQuery as jest.Mock; + +const ruleTypeIds = ['.es-query', '.index-threshold']; + +describe('AlertsFilterByRuleTypes', () => { + it('should show all available types as options', async () => { + mockUseGetInternalRuleTypesQuery.mockReturnValue({ + data: [ + { id: '.es-query', name: 'Elasticsearch Query' }, + { id: '.index-threshold', name: 'Index threshold' }, + ] as InternalRuleType[], + isLoading: false, + isError: false, + }); + render( + + + + ); + await userEvent.click(screen.getByRole('combobox')); + expect(screen.getByText('Elasticsearch Query')).toBeInTheDocument(); + expect(screen.getByText('Index threshold')).toBeInTheDocument(); + }); + + it('should show the selected type in the combobox', async () => { + mockUseGetInternalRuleTypesQuery.mockReturnValue({ + data: [ + { id: '.es-query', name: 'Elasticsearch Query' }, + { id: '.index-threshold', name: 'Index threshold' }, + ] as InternalRuleType[], + isLoading: false, + isError: false, + }); + render( + + + + ); + const comboboxPills = screen.getAllByTestId('euiComboBoxPill'); + expect(comboboxPills).toHaveLength(1); + expect(comboboxPills[0]).toHaveTextContent('Elasticsearch Query'); + }); + + it('should filter available types according to the provided ruleTypeIds', async () => { + mockUseGetInternalRuleTypesQuery.mockReturnValue({ + data: [ + { id: '.es-query', name: 'Elasticsearch Query' }, + { id: '.index-threshold', name: 'Index threshold' }, + ] as InternalRuleType[], + isLoading: false, + isError: false, + }); + render( + + + + ); + await userEvent.click(screen.getByRole('combobox')); + expect(screen.getByText('Elasticsearch Query')).toBeInTheDocument(); + expect(screen.queryByText('Index threshold')).not.toBeInTheDocument(); + }); + + it('should set the combobox in loading mode while loading the available types', async () => { + mockUseGetInternalRuleTypesQuery.mockReturnValue({ + data: [], + isLoading: true, + isError: false, + }); + render( + + + + ); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('should disable the combobox when the types query fails', async () => { + mockUseGetInternalRuleTypesQuery.mockReturnValue({ + types: [], + isLoading: false, + isError: true, + }); + render( + + + + ); + const comboboxInput = screen.getByTestId('comboBoxSearchInput'); + expect(comboboxInput).toHaveAttribute('aria-invalid', 'true'); + expect(comboboxInput).toHaveAttribute('disabled'); + }); + + describe('filterMetadata', () => { + it('should have the correct type id and component', () => { + expect(filterMetadata.id).toEqual('ruleTypes'); + expect(filterMetadata.component).toEqual(AlertsFilterByRuleTypes); + }); + + describe('isEmpty', () => { + it.each([undefined, null, []])('should return false for %s', (value) => { + expect( + filterMetadata.isEmpty(value as Parameters[0]) + ).toEqual(true); + }); + + it('should return true for non-empty values', () => { + expect(filterMetadata.isEmpty(['test-type'])).toEqual(false); + }); + }); + }); +}); diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_types.tsx b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_types.tsx new file mode 100644 index 0000000000000..0eac1461f0969 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_types.tsx @@ -0,0 +1,95 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useGetInternalRuleTypesQuery } from '@kbn/response-ops-rules-apis/hooks/use_get_internal_rule_types_query'; +import { EuiComboBoxProps } from '@elastic/eui/src/components/combo_box/combo_box'; +import { SetRequired } from 'type-fest'; +import { AlertsFilterComponentType, AlertsFilterMetadata } from '../types'; +import { useAlertsFiltersFormContext } from '../contexts/alerts_filters_form_context'; +import { + RULE_TYPES_FILTER_LABEL, + RULE_TYPES_FILTER_NO_OPTIONS_PLACEHOLDER, + RULE_TYPES_FILTER_PLACEHOLDER, + RULE_TYPES_LOAD_ERROR_MESSAGE, +} from '../translations'; + +export const AlertsFilterByRuleTypes: AlertsFilterComponentType = ({ + value, + onChange, + isDisabled = false, +}) => { + const { + ruleTypeIds: allowedRuleTypeIds, + services: { http }, + } = useAlertsFiltersFormContext(); + + const { + data: ruleTypes, + isLoading, + isError, + } = useGetInternalRuleTypesQuery({ + http, + }); + + const options = useMemo, 'value'>>>( + () => + ruleTypes + ?.filter((ruleType) => allowedRuleTypeIds.includes(ruleType.id)) + .map((ruleType) => ({ + value: ruleType.id, + label: ruleType.name, + })) ?? [], + [allowedRuleTypeIds, ruleTypes] + ); + + const selectedOptions = useMemo( + () => options.filter((option) => value?.includes(option.value)), + [options, value] + ); + + const onSelectedOptionsChange = useCallback['onChange']>>( + (newOptions) => { + onChange?.(newOptions.map((option) => option.value!)); + }, + [onChange] + ); + + return ( + + + + ); +}; + +export const filterMetadata = { + id: 'ruleTypes', + displayName: RULE_TYPES_FILTER_LABEL, + component: AlertsFilterByRuleTypes, + isEmpty: (value?: string[]) => !Boolean(value?.length), +} as const satisfies AlertsFilterMetadata; diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form.test.tsx b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form.test.tsx new file mode 100644 index 0000000000000..7436592225cb6 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form.test.tsx @@ -0,0 +1,167 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { AlertsFiltersForm, AlertsFiltersFormProps } from './alerts_filters_form'; +import { AlertsFilter, AlertsFiltersExpression } from '../types'; +import { + ADD_OR_OPERATION_BUTTON_SUBJ, + DELETE_OPERAND_BUTTON_SUBJ, + FORM_ITEM_SUBJ, +} from '../constants'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; +import { + FORM_ITEM_FILTER_BY_LABEL, + FORM_ITEM_FILTER_BY_PLACEHOLDER, + RULE_TAGS_FILTER_LABEL, + RULE_TYPES_FILTER_LABEL, +} from '../translations'; +import { alertsFiltersMetadata } from '../filters'; + +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); + +const TAG_1 = 'tag1'; +const TAG_2 = 'tag2'; +const TAG_3 = 'tag3'; + +jest.mock('@kbn/response-ops-rules-apis/hooks/use_get_rule_tags_query'); +const { useGetRuleTagsQuery: mockUseGetRuleTagsQuery } = jest.requireMock( + '@kbn/response-ops-rules-apis/hooks/use_get_rule_tags_query' +); +mockUseGetRuleTagsQuery.mockReturnValue({ + tags: [TAG_1, TAG_2, TAG_3], + isLoading: false, + isError: false, + hasNextPage: false, + fetchNextPage: jest.fn(), + refetch: jest.fn(), +}); + +jest.mock('@kbn/response-ops-rules-apis/hooks/use_get_internal_rule_types_query'); +const { useGetInternalRuleTypesQuery: mockUseGetInternalRuleTypesQuery } = jest.requireMock( + '@kbn/response-ops-rules-apis/hooks/use_get_internal_rule_types_query' +); +mockUseGetInternalRuleTypesQuery.mockReturnValue({ + data: [{ id: 'testType', name: 'Test Type', solution: 'stack' }], + isLoading: false, + isError: false, +}); + +const testExpression: AlertsFiltersExpression = [ + { filter: { type: alertsFiltersMetadata.ruleTags.id, value: [TAG_1] } }, + { operator: 'and' }, + { filter: { type: alertsFiltersMetadata.ruleTags.id, value: [TAG_2] } }, + { operator: 'or' }, + { filter: { type: alertsFiltersMetadata.ruleTags.id, value: [TAG_3] } }, +]; + +const mockOnChange = jest.fn(); + +const TestComponent = (overrides: Partial) => { + const [value, setValue] = useState(testExpression); + + mockOnChange.mockImplementation(setValue); + + return ( + + + + ); +}; + +describe('AlertsFiltersForm', () => { + it('should render boolean expressions', () => { + render(); + + [TAG_1, TAG_2, TAG_3].forEach((filter) => { + expect(screen.getByText(filter)).toBeInTheDocument(); + }); + }); + + it('should delete the correct operand when clicking on the trash icon', async () => { + render(); + + [TAG_1, TAG_2, TAG_3].forEach((filter) => { + expect(screen.getByText(filter)).toBeInTheDocument(); + }); + + await userEvent.click(screen.getAllByTestId(DELETE_OPERAND_BUTTON_SUBJ)[0]); + + [TAG_1, TAG_3].forEach((filter) => { + expect(screen.getByText(filter)).toBeInTheDocument(); + }); + expect(screen.queryByText(TAG_2)).not.toBeInTheDocument(); + expect(mockOnChange).toHaveBeenCalledWith([ + ...testExpression.slice(0, 1), + ...testExpression.slice(3), + ]); + }); + + it('should correctly add a new operand', async () => { + render(); + + expect(screen.getAllByTestId(FORM_ITEM_SUBJ)).toHaveLength(3); + + await userEvent.click(screen.getByTestId(ADD_OR_OPERATION_BUTTON_SUBJ)); + + const formItems = screen.getAllByTestId(FORM_ITEM_SUBJ); + expect(formItems).toHaveLength(4); + // New operands should be empty + expect(formItems[3]).toHaveTextContent(FORM_ITEM_FILTER_BY_PLACEHOLDER); + expect(mockOnChange).toHaveBeenCalledWith([ + ...testExpression, + { operator: 'or' }, + { filter: {} }, + ]); + }); + + it('should correctly change filter types', async () => { + render(); + + const filterTypeSelectors = screen.getAllByRole('button', { + name: `${RULE_TAGS_FILTER_LABEL} , ${FORM_ITEM_FILTER_BY_LABEL}`, + }); + await userEvent.click(filterTypeSelectors[0]); + await userEvent.click(screen.getByRole('option', { name: RULE_TYPES_FILTER_LABEL })); + expect(mockOnChange).toHaveBeenCalledWith([ + { filter: { type: alertsFiltersMetadata.ruleTypes.id } }, + ...testExpression.slice(1), + ]); + }); + + it('should correctly change filter values', async () => { + render(); + + const filterValueDropdownToggles = screen.getAllByRole('button', { + name: 'Open list of options', + }); + await userEvent.click(filterValueDropdownToggles[0]); + await userEvent.click(screen.getByRole('option', { name: TAG_2 })); + expect(mockOnChange).toHaveBeenCalledWith([ + { + filter: { + ...(testExpression[0] as { filter: AlertsFilter }).filter, + value: [TAG_1, TAG_2], + }, + }, + ...testExpression.slice(1), + ]); + }); +}); diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form.tsx b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form.tsx new file mode 100644 index 0000000000000..135a16aee0cb6 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form.tsx @@ -0,0 +1,252 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useCallback, useMemo } from 'react'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiText, + useEuiTheme, + EuiPanel, + EuiHorizontalRule, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { HttpStart } from '@kbn/core-http-browser'; +import type { NotificationsStart } from '@kbn/core-notifications-browser'; +import { AlertsFiltersFormContextProvider } from '../contexts/alerts_filters_form_context'; +import { + AlertsFiltersExpression, + AlertsFiltersFormItemType, + AlertsFilter, + AlertsFiltersExpressionOperator, +} from '../types'; +import { + ADD_OPERATION_LABEL, + AND_OPERATOR, + DELETE_OPERAND_LABEL, + OR_OPERATOR, +} from '../translations'; +import { AlertsFiltersFormItem } from './alerts_filters_form_item'; +import { isFilter } from '../utils'; +import { + ADD_AND_OPERATION_BUTTON_SUBJ, + ADD_OR_OPERATION_BUTTON_SUBJ, + DELETE_OPERAND_BUTTON_SUBJ, +} from '../constants'; + +export interface AlertsFiltersFormProps { + ruleTypeIds: string[]; + value?: AlertsFiltersExpression; + onChange: (newValue: AlertsFiltersExpression) => void; + isDisabled?: boolean; + services: { + http: HttpStart; + notifications: NotificationsStart; + }; +} + +// This ensures that the form is initialized with an initially empty "Filter by" selector +const DEFAULT_VALUE: AlertsFiltersExpression = [{ filter: {} }]; + +/** + * A form to build boolean expressions of filters for alerts searches + */ +export const AlertsFiltersForm = ({ + ruleTypeIds, + value = DEFAULT_VALUE, + onChange, + isDisabled = false, + services, +}: AlertsFiltersFormProps) => { + const [firstItem, ...otherItems] = value as [ + { + filter: AlertsFilter; + }, + ...AlertsFiltersExpression + ]; + + const addOperand = useCallback( + (operator: AlertsFiltersExpressionOperator) => { + onChange([ + ...value, + { + operator, + }, + { filter: {} }, + ]); + }, + [onChange, value] + ); + + const deleteOperand = useCallback( + (atIndex: number) => { + // Remove two items: the operator and the following filter + const newValue = [...value]; + newValue.splice(atIndex, 2); + onChange(newValue); + }, + [onChange, value] + ); + + const onFormItemTypeChange = useCallback( + (atIndex: number, newType: AlertsFiltersFormItemType) => { + const newValue = [...value]; + const expressionItem = value[atIndex]; + if (isFilter(expressionItem)) { + newValue[atIndex] = { + filter: { + type: newType, + }, + }; + onChange(newValue); + } + }, + [onChange, value] + ); + + const onFormItemValueChange = useCallback( + (atIndex: number, newItemValue: unknown) => { + const newValue = [...value]; + const expressionItem = newValue[atIndex]; + if (isFilter(expressionItem)) { + newValue[atIndex] = { + filter: { + ...expressionItem.filter, + value: newItemValue, + }, + }; + onChange(newValue); + } + }, + [onChange, value] + ); + + const contextValue = useMemo( + () => ({ + ruleTypeIds, + services, + }), + [ruleTypeIds, services] + ); + + return ( + + + + onFormItemTypeChange(0, newType)} + value={firstItem.filter.value} + onValueChange={(newValue) => onFormItemValueChange(0, newValue)} + isDisabled={isDisabled} + /> + + {Boolean(otherItems?.length) && ( + + + {otherItems.map((item, offsetIndex) => { + // offsetIndex starts from the second item + const index = offsetIndex + 1; + return ( + + {isFilter(item) ? ( + onFormItemTypeChange(index, newType)} + value={item.filter.value} + onValueChange={(newValue) => onFormItemValueChange(index, newValue)} + isDisabled={isDisabled} + /> + ) : ( + deleteOperand(index)} /> + )} + + ); + })} + + + )} + + + + + + + addOperand('or')} + isDisabled={isDisabled} + data-test-subj={ADD_OR_OPERATION_BUTTON_SUBJ} + > + {OR_OPERATOR} + + + + addOperand('and')} + isDisabled={isDisabled} + data-test-subj={ADD_AND_OPERATION_BUTTON_SUBJ} + > + {AND_OPERATOR} + + + + + + + + + + ); +}; + +interface OperatorProps { + operator: 'and' | 'or'; + onDelete: () => void; +} + +const Operator = ({ operator, onDelete }: OperatorProps) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + {operator.toUpperCase()} + + + + + + + ); +}; diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form_item.test.tsx b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form_item.test.tsx new file mode 100644 index 0000000000000..c5f6dd12ec95d --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form_item.test.tsx @@ -0,0 +1,98 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { alertsFiltersMetadata } from '../filters'; +import { AlertsFiltersFormItem, AlertsFiltersFormItemProps } from './alerts_filters_form_item'; + +jest.mock('../filters', () => { + const original: { alertsFiltersMetadata: typeof alertsFiltersMetadata } = + jest.requireActual('../filters'); + return { + alertsFiltersMetadata: Object.fromEntries( + Object.entries(original.alertsFiltersMetadata).map(([key, value]) => [ + key, + { + ...value, + component: jest + .fn() + .mockImplementation((props) => ( +
{props.value}
+ )), + }, + ]) + ), + }; +}); + +const mockOnTypeChange = jest.fn(); +const mockOnValueChange = jest.fn(); + +const TestComponent = (overrides: Partial>) => { + const [type, setType] = useState(overrides?.type); + const [value, setValue] = useState(overrides?.value); + + mockOnTypeChange.mockImplementation(setType); + mockOnValueChange.mockImplementation(setValue); + + return ( + + + + ); +}; + +describe('AlertsFiltersFormItem', () => { + it('should show all available filter types as options', async () => { + render(); + + await userEvent.click(screen.getByRole('button')); + Object.values(alertsFiltersMetadata).forEach((filterMeta) => { + expect(screen.getByText(filterMeta.displayName)).toBeInTheDocument(); + }); + }); + + it('should render the correct filter component for the selected type', () => { + render(); + + expect(screen.getByTestId('ruleTagsFilter')).toBeInTheDocument(); + }); + + it('should forward the correct props to the selected filter component', () => { + render(); + + expect(screen.getByText('tag1')).toBeInTheDocument(); + expect(alertsFiltersMetadata.ruleTags.component).toHaveBeenCalledWith( + { + value: ['tag1'], + onChange: mockOnValueChange, + isDisabled: false, + }, + {} + ); + }); + + it('should call onTypeChange when the type is changed', async () => { + render(); + + await userEvent.click(screen.getByRole('button')); + await userEvent.click(screen.getByText(alertsFiltersMetadata.ruleTags.displayName)); + + expect(mockOnTypeChange).toHaveBeenCalledWith(alertsFiltersMetadata.ruleTags.id); + }); +}); diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form_item.tsx b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form_item.tsx new file mode 100644 index 0000000000000..7f3ee77375982 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form_item.tsx @@ -0,0 +1,80 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiFormRow, EuiSuperSelect, EuiText } from '@elastic/eui'; +import type { EuiSuperSelectOption } from '@elastic/eui/src/components/form/super_select/super_select_item'; +import { FORM_ITEM_SUBJ } from '../constants'; +import { alertsFiltersMetadata } from '../filters'; +import { AlertsFilterComponentType, AlertsFiltersFormItemType } from '../types'; +import { + FORM_ITEM_FILTER_BY_LABEL, + FORM_ITEM_FILTER_BY_PLACEHOLDER, + FORM_ITEM_OPTIONAL_CAPTION, +} from '../translations'; + +export interface AlertsFiltersFormItemProps { + type?: AlertsFiltersFormItemType; + onTypeChange: (newFilterType: AlertsFiltersFormItemType) => void; + value?: T; + onValueChange: (newFilterValue: T) => void; + isDisabled?: boolean; +} + +const options: Array> = Object.values( + alertsFiltersMetadata +).map((filterMeta) => ({ + value: filterMeta.id, + dropdownDisplay: filterMeta.displayName, + inputDisplay: filterMeta.displayName, +})); + +export const AlertsFiltersFormItem = ({ + type, + onTypeChange, + value, + onValueChange, + isDisabled = false, +}: AlertsFiltersFormItemProps) => { + const FilterComponent = type + ? (alertsFiltersMetadata[type].component as AlertsFilterComponentType) + : null; + + return ( + <> + + {FORM_ITEM_OPTIONAL_CAPTION} + + } + fullWidth + isDisabled={isDisabled} + data-test-subj={FORM_ITEM_SUBJ} + > + + + {FilterComponent && ( + + )} + + ); +}; diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_solution_selector.test.tsx b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_solution_selector.test.tsx new file mode 100644 index 0000000000000..ef5cfc996b871 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_solution_selector.test.tsx @@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { useGetInternalRuleTypesQuery } from '@kbn/response-ops-rules-apis/hooks/use_get_internal_rule_types_query'; +import { InternalRuleType } from '@kbn/response-ops-rules-apis/apis/get_internal_rule_types'; +import { AlertsSolutionSelector } from './alerts_solution_selector'; +import { SOLUTION_SELECTOR_SUBJ } from '../constants'; +import userEvent from '@testing-library/user-event'; + +const http = httpServiceMock.createStartContract(); +jest.mock('@kbn/response-ops-rules-apis/hooks/use_get_internal_rule_types_query'); +const mockUseGetInternalRuleTypesQuery = useGetInternalRuleTypesQuery as jest.Mock; + +describe('AlertsSolutionSelector', () => { + it('should not render when only no solution is available', () => { + mockUseGetInternalRuleTypesQuery.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + }); + const mockOnSolutionChange = jest.fn(); + render( + + ); + expect(screen.queryByTestId(SOLUTION_SELECTOR_SUBJ)).not.toBeInTheDocument(); + expect(mockOnSolutionChange).not.toHaveBeenCalled(); + }); + + it.each(['stack', 'security', 'observability'])( + 'should not render when only one solution (%s) is available and auto-select it', + (solution) => { + mockUseGetInternalRuleTypesQuery.mockReturnValue({ + data: [{ id: '.test-rule-type', name: 'Test rule type', solution }], + isLoading: false, + isError: false, + }); + const mockOnSolutionChange = jest.fn(); + render( + + ); + expect(screen.queryByTestId(SOLUTION_SELECTOR_SUBJ)).not.toBeInTheDocument(); + expect(mockOnSolutionChange).toHaveBeenCalledWith(solution); + } + ); + + it('should not render when only stack and observability are available and auto-select observability', () => { + mockUseGetInternalRuleTypesQuery.mockReturnValue({ + data: [ + { id: '.es-query', name: 'Elasticsearch Query', solution: 'stack' }, + { id: '.custom-threshold', name: 'Custom threshold', solution: 'observability' }, + ] as InternalRuleType[], + isLoading: false, + isError: false, + }); + const mockOnSolutionChange = jest.fn(); + render( + + ); + expect(screen.queryByTestId(SOLUTION_SELECTOR_SUBJ)).not.toBeInTheDocument(); + expect(mockOnSolutionChange).toHaveBeenCalledWith('observability'); + }); + + it('should render when security and observability/stack are available', async () => { + mockUseGetInternalRuleTypesQuery.mockReturnValue({ + data: [ + { id: '.es-query', name: 'Elasticsearch Query', solution: 'stack' }, + { id: '.custom-threshold', name: 'Custom threshold', solution: 'observability' }, + { id: 'siem.esqlRule', name: 'Security ESQL Rule', solution: 'security' }, + ] as InternalRuleType[], + isLoading: false, + isError: false, + }); + render( + + ); + expect(screen.queryByTestId(SOLUTION_SELECTOR_SUBJ)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button')); + expect(screen.getAllByRole('option')).toHaveLength(2); + expect(screen.getByText('Observability')).toBeInTheDocument(); + expect(screen.getByText('Security')).toBeInTheDocument(); + }); +}); diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_solution_selector.tsx b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_solution_selector.tsx new file mode 100644 index 0000000000000..cb0d3fbc3d027 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_solution_selector.tsx @@ -0,0 +1,121 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import React, { useMemo } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiSuperSelect, + EuiSuperSelectOption, +} from '@elastic/eui'; +import { capitalize } from 'lodash'; +import type { HttpStart } from '@kbn/core-http-browser'; +import { useGetInternalRuleTypesQuery } from '@kbn/response-ops-rules-apis/hooks/use_get_internal_rule_types_query'; +import { RuleTypeSolution } from '@kbn/alerting-types'; +import { InternalRuleType } from '@kbn/response-ops-rules-apis/apis/get_internal_rule_types'; +import { SOLUTION_SELECTOR_SUBJ, SUPPORTED_SOLUTIONS } from '../constants'; +import { + RULE_TYPES_LOAD_ERROR_MESSAGE, + SOLUTION_SELECTOR_LABEL, + SOLUTION_SELECTOR_PLACEHOLDER, +} from '../translations'; + +export interface AlertsSolutionSelectorProps { + solution?: RuleTypeSolution; + onSolutionChange: (newSolution: RuleTypeSolution) => void; + services: { + http: HttpStart; + }; +} + +const featuresIcons: Record = { + stack: 'managementApp', + security: 'logoSecurity', + observability: 'logoObservability', +}; + +const getAvailableSolutions = (ruleTypes: InternalRuleType[]) => { + const solutions = new Set(); + + for (const ruleType of ruleTypes) { + // We want to filter out solutions we do not support in case someone + // abuses the solution rule type attribute + if (SUPPORTED_SOLUTIONS.includes(ruleType.solution)) { + solutions.add(ruleType.solution); + } + } + + if (solutions.has('stack') && solutions.has('observability')) { + solutions.delete('stack'); + } + + return solutions; +}; + +/** + * A solution selector for segregated rule types authorization + * When only one solution is available, it will be selected by default + * and the picker will be hidden. + * When Observability/Stack and Security rule types are available + * the selector will be shown, hiding Stack under Observability. + * Stack is shown only when it's the unique alternative to Security + * (i.e. in Security serverless projects). + */ +export const AlertsSolutionSelector = ({ + solution, + onSolutionChange, + services: { http }, +}: AlertsSolutionSelectorProps) => { + const { data: ruleTypes, isLoading, isError } = useGetInternalRuleTypesQuery({ http }); + const availableSolutions = useMemo(() => getAvailableSolutions(ruleTypes ?? []), [ruleTypes]); + const options = useMemo>>(() => { + return Array.from(availableSolutions.values()).map((sol) => ({ + value: sol, + inputDisplay: ( + + + + + {capitalize(sol)} + + ), + })); + }, [availableSolutions]); + + if (options.length < 2) { + if (options.length === 1) { + // Select the only available solution and + // don't show the selector + onSolutionChange(options[0].value); + } + return null; + } + + return ( + + onSolutionChange(newSol)} + fullWidth + /> + + ); +}; diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/constants.ts b/src/platform/packages/shared/response-ops/alerts-filters-form/constants.ts new file mode 100644 index 0000000000000..5c7b859e1f951 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/constants.ts @@ -0,0 +1,15 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export const DELETE_OPERAND_BUTTON_SUBJ = 'deleteOperandButton'; +export const ADD_OR_OPERATION_BUTTON_SUBJ = 'addOrOperationButton'; +export const ADD_AND_OPERATION_BUTTON_SUBJ = 'addAndOperationButton'; +export const SOLUTION_SELECTOR_SUBJ = 'solutionSelector'; +export const FORM_ITEM_SUBJ = 'formItem'; +export const SUPPORTED_SOLUTIONS = ['stack', 'security', 'observability'] as const; diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/contexts/alerts_filters_form_context.tsx b/src/platform/packages/shared/response-ops/alerts-filters-form/contexts/alerts_filters_form_context.tsx new file mode 100644 index 0000000000000..9854e418676f1 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/contexts/alerts_filters_form_context.tsx @@ -0,0 +1,32 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { createContext, PropsWithChildren, useContext } from 'react'; +import { AlertsFiltersFormContextValue } from '../types'; + +export const AlertsFiltersFormContext = createContext( + undefined +); + +export const AlertsFiltersFormContextProvider = ({ + children, + value, +}: PropsWithChildren<{ value: AlertsFiltersFormContextValue }>) => { + return ( + {children} + ); +}; + +export const useAlertsFiltersFormContext = () => { + const context = useContext(AlertsFiltersFormContext); + if (!context) { + throw new Error('Missing AlertsFiltersFormContext'); + } + return context; +}; diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/filters.ts b/src/platform/packages/shared/response-ops/alerts-filters-form/filters.ts new file mode 100644 index 0000000000000..66e64c84e1501 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/filters.ts @@ -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 + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { filterMetadata as ruleTagsFilterMetadata } from './components/alerts_filter_by_rule_tags'; +import { filterMetadata as ruleTypesFilterMetadata } from './components/alerts_filter_by_rule_types'; + +export const alertsFiltersMetadata = { + [ruleTagsFilterMetadata.id]: ruleTagsFilterMetadata, + [ruleTypesFilterMetadata.id]: ruleTypesFilterMetadata, +} as const; diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/jest.config.js b/src/platform/packages/shared/response-ops/alerts-filters-form/jest.config.js new file mode 100644 index 0000000000000..33affa5c3bcd1 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/jest.config.js @@ -0,0 +1,17 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../..', + roots: ['/src/platform/packages/shared/response-ops/alerts-filters-form'], + setupFilesAfterEnv: [ + '/src/platform/packages/shared/response-ops/alerts-filters-form/setup_tests.ts', + ], +}; diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/kibana.jsonc b/src/platform/packages/shared/response-ops/alerts-filters-form/kibana.jsonc new file mode 100644 index 0000000000000..09afc7c089d62 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-browser", + "id": "@kbn/response-ops-alerts-filters-form", + "owner": "@elastic/response-ops", + "group": "platform", + "visibility": "shared" +} diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/package.json b/src/platform/packages/shared/response-ops/alerts-filters-form/package.json new file mode 100644 index 0000000000000..36b2d9a9529c1 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/response-ops-alerts-filters-form", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/setup_tests.ts b/src/platform/packages/shared/response-ops/alerts-filters-form/setup_tests.ts new file mode 100644 index 0000000000000..5ebc6d3dac1ca --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/setup_tests.ts @@ -0,0 +1,11 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import '@testing-library/jest-dom'; diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/translations.ts b/src/platform/packages/shared/response-ops/alerts-filters-form/translations.ts new file mode 100644 index 0000000000000..e10c334afc911 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/translations.ts @@ -0,0 +1,108 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { i18n } from '@kbn/i18n'; + +export const RULE_TAGS_FILTER_LABEL = i18n.translate('alertsFiltersForm.ruleTags.label', { + defaultMessage: 'Rule tags', +}); + +export const RULE_TAGS_FILTER_PLACEHOLDER = i18n.translate( + 'alertsFiltersForm.ruleTags.placeholder', + { + defaultMessage: 'Select rule tags', + } +); + +export const RULE_TAGS_FILTER_NO_OPTIONS_PLACEHOLDER = i18n.translate( + 'alertsFiltersForm.ruleTags.noOptionsPlaceholder', + { + defaultMessage: 'No tags available', + } +); + +export const RULE_TAGS_LOAD_ERROR_MESSAGE = i18n.translate( + 'alertsFiltersForm.ruleTags.errorMessage', + { + defaultMessage: 'Cannot load available rule tags', + } +); + +export const RULE_TYPES_FILTER_LABEL = i18n.translate('alertsFiltersForm.ruleTypes.label', { + defaultMessage: 'Rule types', +}); + +export const RULE_TYPES_FILTER_PLACEHOLDER = i18n.translate( + 'alertsFiltersForm.ruleTypes.placeholder', + { + defaultMessage: 'Select rule types', + } +); + +export const RULE_TYPES_FILTER_NO_OPTIONS_PLACEHOLDER = i18n.translate( + 'alertsFiltersForm.ruleTypes.noOptionsPlaceHolder', + { + defaultMessage: 'No rule types available', + } +); + +export const RULE_TYPES_LOAD_ERROR_MESSAGE = i18n.translate( + 'alertsFiltersForm.ruleTypes.errorMessage', + { + defaultMessage: 'Cannot load available rule types', + } +); + +export const DELETE_OPERAND_LABEL = i18n.translate('alertsFiltersForm.deleteOperand', { + defaultMessage: 'Delete operand', +}); + +export const FORM_ITEM_OPTIONAL_CAPTION = i18n.translate( + 'alertsFiltersForm.formItem.optionalCaption', + { + defaultMessage: 'Optional', + } +); + +export const FORM_ITEM_FILTER_BY_LABEL = i18n.translate( + 'alertsFiltersForm.formItem.filterByLabel', + { + defaultMessage: 'Filter by', + } +); + +export const FORM_ITEM_FILTER_BY_PLACEHOLDER = i18n.translate( + 'alertsFiltersForm.formItem.filterByPlaceholder', + { + defaultMessage: 'Select filter type', + } +); + +export const ADD_OPERATION_LABEL = i18n.translate('alertsFiltersForm.addOperationLabel', { + defaultMessage: 'Add boolean operation', +}); + +export const OR_OPERATOR = i18n.translate('alertsFiltersForm.orOperator', { + defaultMessage: 'OR', +}); + +export const AND_OPERATOR = i18n.translate('alertsFiltersForm.andOperator', { + defaultMessage: 'AND', +}); + +export const SOLUTION_SELECTOR_LABEL = i18n.translate('alertsFiltersForm.solutionSelectorLabel', { + defaultMessage: 'Solution', +}); + +export const SOLUTION_SELECTOR_PLACEHOLDER = i18n.translate( + 'alertsFiltersForm.solutionSelectorPlaceholder', + { + defaultMessage: 'Select solution', + } +); diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/tsconfig.json b/src/platform/packages/shared/response-ops/alerts-filters-form/tsconfig.json new file mode 100644 index 0000000000000..ba06446224d0c --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "@emotion/react/types/css-prop", + "@kbn/ambient-ui-types" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/response-ops-rules-apis", + "@kbn/i18n", + "@kbn/i18n-react", + "@kbn/core-http-browser-mocks", + "@kbn/core-notifications-browser-mocks", + "@kbn/core-http-browser", + "@kbn/core-notifications-browser", + "@kbn/alerting-types", + ] +} diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/types.ts b/src/platform/packages/shared/response-ops/alerts-filters-form/types.ts new file mode 100644 index 0000000000000..478fdcba8030e --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/types.ts @@ -0,0 +1,57 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { HttpStart } from '@kbn/core-http-browser'; +import type { NotificationsStart } from '@kbn/core-notifications-browser'; +import type { ComponentType } from 'react'; +import type { alertsFiltersMetadata } from './filters'; + +export type AlertsFilterComponentType = ComponentType<{ + value?: T; + onChange: (newValue: T) => void; + isDisabled?: boolean; +}>; + +export interface AlertsFilter { + type?: AlertsFiltersFormItemType; + value?: unknown; +} + +export interface AlertsFilterMetadata { + id: string; + displayName: string; + component: AlertsFilterComponentType; + isEmpty: (value?: T) => boolean; +} + +export interface AlertsFiltersFormContextValue { + /** + * Pre-selected rule type ids for authorization + */ + ruleTypeIds: string[]; + + services: { + http: HttpStart; + notifications: NotificationsStart; + }; +} + +export type AlertsFiltersFormItemType = keyof typeof alertsFiltersMetadata; + +export type AlertsFiltersExpressionOperator = 'and' | 'or'; + +export type AlertsFiltersExpressionItem = + | { + operator: AlertsFiltersExpressionOperator; + } + | { + filter: AlertsFilter; + }; + +export type AlertsFiltersExpression = AlertsFiltersExpressionItem[]; diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/utils.test.ts b/src/platform/packages/shared/response-ops/alerts-filters-form/utils.test.ts new file mode 100644 index 0000000000000..962b2c839c12a --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/utils.test.ts @@ -0,0 +1,56 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { getRuleTypeIdsForSolution, isFilter } from './utils'; +import type { InternalRuleType } from '@kbn/response-ops-rules-apis/apis/get_internal_rule_types'; +import type { RuleTypeSolution } from '@kbn/alerting-types'; + +const ruleTypes = [ + { id: 'stack-rule-type', solution: 'stack' }, + { id: 'observability-rule-type', solution: 'observability' }, + { id: 'security-rule-type', solution: 'security' }, +] as InternalRuleType[]; + +describe('getRuleTypeIdsForSolution', () => { + it.each(['stack', 'observability', 'security'] as RuleTypeSolution[])( + 'should include %s rule type ids in the returned array', + (solution) => { + const solutionRuleTypeIds = ruleTypes + .filter((ruleType) => ruleType.solution === solution) + .map((ruleType) => ruleType.id); + const ruleTypeIds = getRuleTypeIdsForSolution(ruleTypes, solution); + for (const ruleTypeId of solutionRuleTypeIds) { + expect(ruleTypeIds).toContain(ruleTypeId); + } + } + ); + + it('should group stack rule type ids under observability', () => { + expect(getRuleTypeIdsForSolution(ruleTypes, 'observability')).toEqual([ + 'stack-rule-type', + 'observability-rule-type', + ]); + }); + + it('should always return security rule type ids in isolation', () => { + expect(getRuleTypeIdsForSolution(ruleTypes, 'security')).toEqual(['security-rule-type']); + expect(getRuleTypeIdsForSolution(ruleTypes, 'security')).toEqual(['security-rule-type']); + }); +}); + +describe('isFilter', () => { + it('should return true for items with filter property', () => { + expect(isFilter({ filter: {} })).toBeTruthy(); + }); + + it.each([null, undefined])('should return false for %s items', (filter) => { + // @ts-expect-error: Testing empty values + expect(isFilter(filter)).toBeFalsy(); + }); +}); diff --git a/src/platform/packages/shared/response-ops/alerts-filters-form/utils.ts b/src/platform/packages/shared/response-ops/alerts-filters-form/utils.ts new file mode 100644 index 0000000000000..70812f46d72d0 --- /dev/null +++ b/src/platform/packages/shared/response-ops/alerts-filters-form/utils.ts @@ -0,0 +1,32 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { RuleTypeSolution } from '@kbn/alerting-types'; +import type { InternalRuleType } from '@kbn/response-ops-rules-apis/apis/get_internal_rule_types'; +import type { AlertsFilter, AlertsFiltersExpressionItem } from './types'; + +/** + * Filters rule types by solution and returns their ids. + * Stack rules are included under Observability. + */ +export const getRuleTypeIdsForSolution = ( + ruleTypes: InternalRuleType[], + solution: RuleTypeSolution +) => { + return ruleTypes + .filter( + (ruleType) => + ruleType.solution === solution || + (solution === 'observability' && ruleType.solution === 'stack') + ) + .map((ruleType) => ruleType.id); +}; + +export const isFilter = (item?: AlertsFiltersExpressionItem): item is { filter: AlertsFilter } => + item != null && 'filter' in item; diff --git a/src/platform/packages/shared/response-ops/rules-apis/hooks/use_get_rule_tags_query.ts b/src/platform/packages/shared/response-ops/rules-apis/hooks/use_get_rule_tags_query.ts index c751c52be4c53..3ef7ace468293 100644 --- a/src/platform/packages/shared/response-ops/rules-apis/hooks/use_get_rule_tags_query.ts +++ b/src/platform/packages/shared/response-ops/rules-apis/hooks/use_get_rule_tags_query.ts @@ -69,21 +69,29 @@ export function useGetRuleTagsQuery({ }; }; - const { refetch, data, fetchNextPage, isLoading, isFetching, hasNextPage, isFetchingNextPage } = - useInfiniteQuery({ - queryKey: getKey({ - ruleTypeIds, - search, - perPage, - page, - refresh, - }), - queryFn, - onError: onErrorFn, - enabled, - getNextPageParam, - refetchOnWindowFocus: false, - }); + const { + refetch, + data, + fetchNextPage, + isLoading, + isFetching, + hasNextPage, + isFetchingNextPage, + isError, + } = useInfiniteQuery({ + queryKey: getKey({ + ruleTypeIds, + search, + perPage, + page, + refresh, + }), + queryFn, + onError: onErrorFn, + enabled, + getNextPageParam, + refetchOnWindowFocus: false, + }); const tags = useMemo(() => { return ( @@ -99,5 +107,6 @@ export function useGetRuleTagsQuery({ refetch, isLoading: isLoading || isFetching || isFetchingNextPage, fetchNextPage, + isError, }; } diff --git a/tsconfig.base.json b/tsconfig.base.json index 30383ccc87050..8fde9ee3c8bd1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1514,6 +1514,8 @@ "@kbn/response-ops-alerts-apis/*": ["src/platform/packages/shared/response-ops/alerts-apis/*"], "@kbn/response-ops-alerts-fields-browser": ["src/platform/packages/shared/response-ops/alerts-fields-browser"], "@kbn/response-ops-alerts-fields-browser/*": ["src/platform/packages/shared/response-ops/alerts-fields-browser/*"], + "@kbn/response-ops-alerts-filters-form": ["src/platform/packages/shared/response-ops/alerts-filters-form"], + "@kbn/response-ops-alerts-filters-form/*": ["src/platform/packages/shared/response-ops/alerts-filters-form/*"], "@kbn/response-ops-alerts-table": ["src/platform/packages/shared/response-ops/alerts-table"], "@kbn/response-ops-alerts-table/*": ["src/platform/packages/shared/response-ops/alerts-table/*"], "@kbn/response-ops-rule-form": ["src/platform/packages/shared/response-ops/rule_form"], diff --git a/x-pack/examples/triggers_actions_ui_example/public/application.tsx b/x-pack/examples/triggers_actions_ui_example/public/application.tsx index 65cb06157afa6..b2bdbcc983af7 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/application.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/application.tsx @@ -25,6 +25,7 @@ import { CREATE_RULE_ROUTE, EDIT_RULE_ROUTE, RuleForm } from '@kbn/response-ops- import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; +import { AlertsFiltersFormSandbox } from './components/alerts_filters_form_sandbox'; import { TriggersActionsUiExamplePublicStartDeps } from './plugin'; import { Page } from './components/page'; @@ -248,6 +249,20 @@ const TriggersActionsUiExampleApp = ({ )} /> + ( + + + + )} + /> diff --git a/x-pack/examples/triggers_actions_ui_example/public/components/alerts_filters_form_sandbox.tsx b/x-pack/examples/triggers_actions_ui_example/public/components/alerts_filters_form_sandbox.tsx new file mode 100644 index 0000000000000..7545453641a00 --- /dev/null +++ b/x-pack/examples/triggers_actions_ui_example/public/components/alerts_filters_form_sandbox.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useState } from 'react'; +import { HttpStart } from '@kbn/core-http-browser'; +import { NotificationsStart } from '@kbn/core-notifications-browser'; +import { AlertsFiltersForm } from '@kbn/response-ops-alerts-filters-form/components/alerts_filters_form'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel, EuiText } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { AlertsFiltersExpression } from '@kbn/response-ops-alerts-filters-form/types'; +import { useGetInternalRuleTypesQuery } from '@kbn/response-ops-rules-apis/hooks/use_get_internal_rule_types_query'; +import { RuleTypeSolution } from '@kbn/alerting-types'; +import { AlertsSolutionSelector } from '@kbn/response-ops-alerts-filters-form/components/alerts_solution_selector'; +import { getRuleTypeIdsForSolution } from '@kbn/response-ops-alerts-filters-form/utils'; + +export const AlertsFiltersFormSandbox = ({ + services: { http, notifications }, +}: { + services: { + http: HttpStart; + notifications: NotificationsStart; + }; +}) => { + const [solution, setSolution] = useState(); + const [filters, setFilters] = useState(); + const { data: ruleTypes, isLoading: isLoadingRuleTypes } = useGetInternalRuleTypesQuery({ http }); + const ruleTypeIds = useMemo( + () => (!ruleTypes || !solution ? [] : getRuleTypeIdsForSolution(ruleTypes, solution)), + [ruleTypes, solution] + ); + const services = useMemo( + () => ({ + http, + notifications, + }), + [http, notifications] + ); + + return ( + + + + { + if (solution != null && newSolution !== solution) { + setFilters(undefined); + } + setSolution(newSolution); + }} + /> + + + {isLoadingRuleTypes ? ( + + ) : ( + + + +

+ +

+
+
+ + + +
+ )} +
+
+
+ ); +}; diff --git a/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx b/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx index 1d73a88d8ee2f..145cbe2cf2d12 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx @@ -64,15 +64,20 @@ export const Sidebar = ({ history }: { history: ScopedHistory }) => { onClick: () => history.push(`/rule_status_filter`), }, { - id: 'alerts table', + id: 'alerts_table', name: 'Alert Table', onClick: () => history.push('/alerts_table'), }, { - id: 'rules settings link', + id: 'rules_settings_link', name: 'Rules Settings Link', onClick: () => history.push('/rules_settings_link'), }, + { + id: 'alerts_filters_form', + name: 'Alerts filters form', + onClick: () => history.push('/alerts_filters_form'), + }, ], }, { diff --git a/x-pack/examples/triggers_actions_ui_example/tsconfig.json b/x-pack/examples/triggers_actions_ui_example/tsconfig.json index 520f827facc61..f02680c32be5a 100644 --- a/x-pack/examples/triggers_actions_ui_example/tsconfig.json +++ b/x-pack/examples/triggers_actions_ui_example/tsconfig.json @@ -35,5 +35,10 @@ "@kbn/licensing-plugin", "@kbn/response-ops-rule-form", "@kbn/fields-metadata-plugin", + "@kbn/response-ops-alerts-filters-form", + "@kbn/core-http-browser", + "@kbn/core-notifications-browser", + "@kbn/response-ops-rules-apis", + "@kbn/alerting-types", ] } diff --git a/yarn.lock b/yarn.lock index 62a7c9f9fb464..5cf804a7a6fc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6662,6 +6662,10 @@ version "0.0.0" uid "" +"@kbn/response-ops-alerts-filters-form@link:src/platform/packages/shared/response-ops/alerts-filters-form": + version "0.0.0" + uid "" + "@kbn/response-ops-alerts-table@link:src/platform/packages/shared/response-ops/alerts-table": version "0.0.0" uid ""