From 43b615a026bab02fef747aaf1b42922859cabcbc Mon Sep 17 00:00:00 2001 From: Umberto Pepato Date: Thu, 17 Apr 2025 16:18:24 +0200 Subject: [PATCH] [ResponseOps][Alerts] Implement alerts filters form (#214982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements the alerts filters form that will be used to pre-filter the alerts table embeddable. image > [!NOTE] > I'm using the terminology "form" to distinguish this from the alert filter _controls_ or other type of more KQL-bar-like filters. Other alternatives that came to mind were `alerts-boolean-filters-...` or `alerts-filters-builder`.
## Implementation details ### Filters expression state I opted for a tree state representation of the form's boolean expression to accommodate potential future requirements such as more complex boolean expressions (negation, parenthesized subexpressions to manually control operators precedence): ```ts { operator: 'or', operands: [ { operator: 'or', operands: [ { type: 'ruleTags', value: ['tag-1'] }, { type: 'ruleTags', value: ['tag-2'] }, { operator: 'and', operands: [{ type: 'ruleTypes', value: ['type-1'] }, { type: 'ruleTypes', value: ['type-2'] }], }, ], }, { type: 'ruleTags', value: ['tag-3'] }, ], } ``` This state is saved in the embeddable panel state and represents the editor form. The embeddable alerts table wrapper component will then transform this to an actual ES query. To simplify interactions inside the form, an intermediate equivalent flattened state is used: ```ts [ { filter: { type: 'ruleTags', value: ['tag-1'] } }, { operator: 'or' }, { filter: { type: 'ruleTags', value: ['tag-2'] } }, { operator: 'or' }, { filter: { type: 'ruleTypes', value: ['type-1'] }}, { operator: 'and' }, { filter: { type: 'ruleTypes', value: ['type-2'] } }, { operator: 'or' }, { filter: { type: 'ruleTags', value: ['tag-3'] } }, ] ``` ### Filters model Each filter is described by an `AlertsFilterMetadata` object, where `T` is the type of the filter value: ```tsx export const filterMetadata: AlertsFilterMetadata = { id: 'ruleTags', displayName: RULE_TAGS_FILTER_LABEL, component: AlertsFilterByRuleTags, // Filter-specific empty check isEmpty: (value?: string[]) => !value?.length, // Conversion to ES query DSL toEsQuery: (value: string[]) => { return { terms: { [ALERT_RULE_TAGS]: value, }, }; }, }; ```
## Verification steps 1. Run Kibana with examples (`yarn start --run-examples`) 2. Create rules in different solutions with tags 3. Navigate to `/app/triggersActionsUiExample/alerts_filters_form` 4. Check that the solution selector options are coherent with the rule types the user can access 5. Select a solution 6. Build filters expressions, checking that the rule tags and rule types are coherent with the solution selection and the rules created previously 7. Repeat steps 3-6 with different roles: 7.1. having access to rule types from just one solution (in this case the solution selector shouldn't appear at all), 7.2. having access just to Observability and Stack but not Security (in this case the solution selector shouldn't appear at all), 8. Repeat steps 3-6 in the three serverless project types: ```shell $ yarn es serverless —ssl --projectType $ yarn serverless- --ssl --run-examples ``` (If the authentication fails when switching between project types, use a clean session) 8.1. ES project types should have access only to Stack rules (no selector) 8.2. Observability project types should have access only to Observability and Stack rules (no selector) 8.3. Security project types should have access only to Security and Stack rules (selector shows Stack instead of Observability) ## References Depends on #214187 Closes #213061 ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Christos Nasikas (cherry picked from commit c44efc52f62f2bb80c8cb8c288cfb40fc3164344) # Conflicts: # x-pack/examples/triggers_actions_ui_example/public/application.tsx --- .github/CODEOWNERS | 1 + package.json | 1 + .../alerts-filters-form/README.md | 3 + .../alerts_filter_by_rule_tags.test.tsx | 123 +++++++++ .../components/alerts_filter_by_rule_tags.tsx | 95 +++++++ .../alerts_filter_by_rule_types.test.tsx | 136 ++++++++++ .../alerts_filter_by_rule_types.tsx | 95 +++++++ .../components/alerts_filters_form.test.tsx | 167 ++++++++++++ .../components/alerts_filters_form.tsx | 252 ++++++++++++++++++ .../alerts_filters_form_item.test.tsx | 98 +++++++ .../components/alerts_filters_form_item.tsx | 80 ++++++ .../alerts_solution_selector.test.tsx | 107 ++++++++ .../components/alerts_solution_selector.tsx | 121 +++++++++ .../alerts-filters-form/constants.ts | 15 ++ .../contexts/alerts_filters_form_context.tsx | 32 +++ .../alerts-filters-form/filters.ts | 16 ++ .../alerts-filters-form/jest.config.js | 17 ++ .../alerts-filters-form/kibana.jsonc | 7 + .../alerts-filters-form/package.json | 6 + .../alerts-filters-form/setup_tests.ts | 11 + .../alerts-filters-form/translations.ts | 108 ++++++++ .../alerts-filters-form/tsconfig.json | 29 ++ .../response-ops/alerts-filters-form/types.ts | 57 ++++ .../alerts-filters-form/utils.test.ts | 56 ++++ .../response-ops/alerts-filters-form/utils.ts | 32 +++ .../hooks/use_get_rule_tags_query.ts | 39 +-- tsconfig.base.json | 2 + .../public/application.tsx | 15 ++ .../alerts_filters_form_sandbox.tsx | 91 +++++++ .../public/components/sidebar.tsx | 9 +- .../triggers_actions_ui_example/tsconfig.json | 5 + yarn.lock | 4 + 32 files changed, 1813 insertions(+), 17 deletions(-) create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/README.md create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_tags.test.tsx create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_tags.tsx create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_types.test.tsx create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filter_by_rule_types.tsx create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form.test.tsx create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form.tsx create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form_item.test.tsx create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_filters_form_item.tsx create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_solution_selector.test.tsx create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/components/alerts_solution_selector.tsx create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/constants.ts create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/contexts/alerts_filters_form_context.tsx create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/filters.ts create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/jest.config.js create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/kibana.jsonc create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/package.json create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/setup_tests.ts create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/translations.ts create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/tsconfig.json create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/types.ts create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/utils.test.ts create mode 100644 src/platform/packages/shared/response-ops/alerts-filters-form/utils.ts create mode 100644 x-pack/examples/triggers_actions_ui_example/public/components/alerts_filters_form_sandbox.tsx 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 ""