mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[ResponseOps][Alerts] Implement alerts filters form (#214982)
## Summary Implements the alerts filters form that will be used to pre-filter the alerts table embeddable. <img width="1004" alt="image" src="https://github.com/user-attachments/assets/b51ce051-40d2-42d0-a9c1-0fba3fd919af" /> > [!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`. <details> <summary> ## Implementation details </summary> ### 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<T>` object, where `T` is the type of the filter value: ```tsx export const filterMetadata: AlertsFilterMetadata<string[]> = { 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, }, }; }, }; ``` </details> ## 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 <es|oblt|security> $ yarn serverless-<es|oblt|security> --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 <xristosnasikas@gmail.com>
This commit is contained in:
parent
c5ff7aa155
commit
c44efc52f6
32 changed files with 1813 additions and 17 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -568,6 +568,7 @@ src/platform/packages/shared/react/kibana_context/theme @elastic/appex-sharedux
|
|||
src/platform/packages/shared/react/kibana_mount @elastic/appex-sharedux
|
||||
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
|
||||
|
|
|
@ -771,6 +771,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",
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/response-ops-alerts-filters-form
|
||||
|
||||
A form to create and edit boolean filter expressions for alert document search queries.
|
|
@ -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(
|
||||
<AlertsFiltersFormContextProvider
|
||||
value={{ ruleTypeIds: ['.es-query'], services: { http, notifications } }}
|
||||
>
|
||||
<AlertsFilterByRuleTags value={[]} onChange={jest.fn()} />
|
||||
</AlertsFiltersFormContextProvider>
|
||||
);
|
||||
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(
|
||||
<AlertsFiltersFormContextProvider
|
||||
value={{ ruleTypeIds: ['.es-query'], services: { http, notifications } }}
|
||||
>
|
||||
<AlertsFilterByRuleTags value={['tag1']} onChange={jest.fn()} />
|
||||
</AlertsFiltersFormContextProvider>
|
||||
);
|
||||
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(
|
||||
<AlertsFiltersFormContextProvider
|
||||
value={{ ruleTypeIds: ['.es-query'], services: { http, notifications } }}
|
||||
>
|
||||
<AlertsFilterByRuleTags value={[]} onChange={jest.fn()} />
|
||||
</AlertsFiltersFormContextProvider>
|
||||
);
|
||||
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(
|
||||
<AlertsFiltersFormContextProvider
|
||||
value={{ ruleTypeIds: ['.es-query'], services: { http, notifications } }}
|
||||
>
|
||||
<AlertsFilterByRuleTags value={[]} onChange={jest.fn()} />
|
||||
</AlertsFiltersFormContextProvider>
|
||||
);
|
||||
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<typeof filterMetadata.isEmpty>[0])
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return true for non-empty values', () => {
|
||||
expect(filterMetadata.isEmpty(['test-tag'])).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<string[]> = ({
|
||||
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<Array<EuiComboBoxOptionOption<string>>>(
|
||||
() =>
|
||||
tags.map((tag) => ({
|
||||
label: tag,
|
||||
})),
|
||||
[tags]
|
||||
);
|
||||
|
||||
const selectedOptions = useMemo(
|
||||
() => options.filter(({ label }) => value?.includes(label)),
|
||||
[options, value]
|
||||
);
|
||||
|
||||
const onSelectedOptionsChange = useCallback<NonNullable<EuiComboBoxProps<string>['onChange']>>(
|
||||
(newOptions) => {
|
||||
onChange?.(newOptions.map(({ label }) => label));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={RULE_TAGS_FILTER_LABEL}
|
||||
isDisabled={isDisabled || isError}
|
||||
isInvalid={isError}
|
||||
error={RULE_TAGS_LOAD_ERROR_MESSAGE}
|
||||
fullWidth
|
||||
>
|
||||
<EuiComboBox
|
||||
isClearable
|
||||
isLoading={isLoading}
|
||||
isDisabled={isDisabled || isError || !options.length}
|
||||
isInvalid={isError}
|
||||
options={options}
|
||||
selectedOptions={selectedOptions}
|
||||
onChange={onSelectedOptionsChange}
|
||||
placeholder={
|
||||
!options.length ? RULE_TAGS_FILTER_NO_OPTIONS_PLACEHOLDER : RULE_TAGS_FILTER_PLACEHOLDER
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const filterMetadata = {
|
||||
id: 'ruleTags',
|
||||
displayName: RULE_TAGS_FILTER_LABEL,
|
||||
component: AlertsFilterByRuleTags,
|
||||
isEmpty: (value?: string[]) => !Boolean(value?.length),
|
||||
} as const satisfies AlertsFilterMetadata<string[]>;
|
|
@ -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(
|
||||
<AlertsFiltersFormContextProvider value={{ ruleTypeIds, services: { http, notifications } }}>
|
||||
<AlertsFilterByRuleTypes value={[]} onChange={jest.fn()} />
|
||||
</AlertsFiltersFormContextProvider>
|
||||
);
|
||||
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(
|
||||
<AlertsFiltersFormContextProvider value={{ ruleTypeIds, services: { http, notifications } }}>
|
||||
<AlertsFilterByRuleTypes value={['.es-query']} onChange={jest.fn()} />
|
||||
</AlertsFiltersFormContextProvider>
|
||||
);
|
||||
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(
|
||||
<AlertsFiltersFormContextProvider
|
||||
value={{ ruleTypeIds: ['.es-query'], services: { http, notifications } }}
|
||||
>
|
||||
<AlertsFilterByRuleTypes value={[]} onChange={jest.fn()} />
|
||||
</AlertsFiltersFormContextProvider>
|
||||
);
|
||||
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(
|
||||
<AlertsFiltersFormContextProvider value={{ ruleTypeIds, services: { http, notifications } }}>
|
||||
<AlertsFilterByRuleTypes value={[]} onChange={jest.fn()} />
|
||||
</AlertsFiltersFormContextProvider>
|
||||
);
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable the combobox when the types query fails', async () => {
|
||||
mockUseGetInternalRuleTypesQuery.mockReturnValue({
|
||||
types: [],
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
render(
|
||||
<AlertsFiltersFormContextProvider value={{ ruleTypeIds, services: { http, notifications } }}>
|
||||
<AlertsFilterByRuleTypes value={[]} onChange={jest.fn()} />
|
||||
</AlertsFiltersFormContextProvider>
|
||||
);
|
||||
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<typeof filterMetadata.isEmpty>[0])
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return true for non-empty values', () => {
|
||||
expect(filterMetadata.isEmpty(['test-type'])).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<string[]> = ({
|
||||
value,
|
||||
onChange,
|
||||
isDisabled = false,
|
||||
}) => {
|
||||
const {
|
||||
ruleTypeIds: allowedRuleTypeIds,
|
||||
services: { http },
|
||||
} = useAlertsFiltersFormContext();
|
||||
|
||||
const {
|
||||
data: ruleTypes,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useGetInternalRuleTypesQuery({
|
||||
http,
|
||||
});
|
||||
|
||||
const options = useMemo<Array<SetRequired<EuiComboBoxOptionOption<string>, '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<NonNullable<EuiComboBoxProps<string>['onChange']>>(
|
||||
(newOptions) => {
|
||||
onChange?.(newOptions.map((option) => option.value!));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={RULE_TYPES_FILTER_LABEL}
|
||||
isDisabled={isDisabled || isError}
|
||||
isInvalid={isError}
|
||||
error={RULE_TYPES_LOAD_ERROR_MESSAGE}
|
||||
fullWidth
|
||||
>
|
||||
<EuiComboBox
|
||||
isClearable
|
||||
isLoading={isLoading}
|
||||
isDisabled={isDisabled || isError || !options.length}
|
||||
isInvalid={isError}
|
||||
options={options}
|
||||
selectedOptions={selectedOptions}
|
||||
onChange={onSelectedOptionsChange}
|
||||
placeholder={
|
||||
!options.length ? RULE_TYPES_FILTER_NO_OPTIONS_PLACEHOLDER : RULE_TYPES_FILTER_PLACEHOLDER
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const filterMetadata = {
|
||||
id: 'ruleTypes',
|
||||
displayName: RULE_TYPES_FILTER_LABEL,
|
||||
component: AlertsFilterByRuleTypes,
|
||||
isEmpty: (value?: string[]) => !Boolean(value?.length),
|
||||
} as const satisfies AlertsFilterMetadata<string[]>;
|
|
@ -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<AlertsFiltersFormProps>) => {
|
||||
const [value, setValue] = useState(testExpression);
|
||||
|
||||
mockOnChange.mockImplementation(setValue);
|
||||
|
||||
return (
|
||||
<IntlProvider locale="en">
|
||||
<AlertsFiltersForm
|
||||
ruleTypeIds={[]}
|
||||
value={value}
|
||||
onChange={mockOnChange}
|
||||
services={{ http, notifications }}
|
||||
{...overrides}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AlertsFiltersForm', () => {
|
||||
it('should render boolean expressions', () => {
|
||||
render(<TestComponent />);
|
||||
|
||||
[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(<TestComponent />);
|
||||
|
||||
[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(<TestComponent />);
|
||||
|
||||
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(<TestComponent />);
|
||||
|
||||
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(<TestComponent />);
|
||||
|
||||
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),
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
<AlertsFiltersFormContextProvider value={contextValue}>
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<AlertsFiltersFormItem
|
||||
type={firstItem.filter.type}
|
||||
onTypeChange={(newType) => onFormItemTypeChange(0, newType)}
|
||||
value={firstItem.filter.value}
|
||||
onValueChange={(newValue) => onFormItemValueChange(0, newValue)}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{Boolean(otherItems?.length) && (
|
||||
<EuiPanel hasShadow={false} color="subdued">
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
{otherItems.map((item, offsetIndex) => {
|
||||
// offsetIndex starts from the second item
|
||||
const index = offsetIndex + 1;
|
||||
return (
|
||||
<EuiFlexItem key={index}>
|
||||
{isFilter(item) ? (
|
||||
<AlertsFiltersFormItem
|
||||
type={item.filter.type}
|
||||
onTypeChange={(newType) => onFormItemTypeChange(index, newType)}
|
||||
value={item.filter.value}
|
||||
onValueChange={(newValue) => onFormItemValueChange(index, newValue)}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
) : (
|
||||
<Operator operator={item.operator} onDelete={() => deleteOperand(index)} />
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
);
|
||||
})}
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="s"
|
||||
role="group"
|
||||
aria-label={ADD_OPERATION_LABEL}
|
||||
>
|
||||
<EuiFlexItem grow>
|
||||
<EuiHorizontalRule margin="s" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
iconType="plusInCircle"
|
||||
size="xs"
|
||||
onClick={() => addOperand('or')}
|
||||
isDisabled={isDisabled}
|
||||
data-test-subj={ADD_OR_OPERATION_BUTTON_SUBJ}
|
||||
>
|
||||
{OR_OPERATOR}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
iconType="plusInCircle"
|
||||
size="xs"
|
||||
onClick={() => addOperand('and')}
|
||||
isDisabled={isDisabled}
|
||||
data-test-subj={ADD_AND_OPERATION_BUTTON_SUBJ}
|
||||
>
|
||||
{AND_OPERATOR}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow>
|
||||
<EuiHorizontalRule margin="s" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</AlertsFiltersFormContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
interface OperatorProps {
|
||||
operator: 'and' | 'or';
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const Operator = ({ operator, onDelete }: OperatorProps) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
justifyContent="spaceBetween"
|
||||
css={css`
|
||||
border-bottom: ${euiTheme.border.thin};
|
||||
`}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s" color="subdued">
|
||||
{operator.toUpperCase()}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
iconType="trash"
|
||||
aria-label={DELETE_OPERAND_LABEL}
|
||||
onClick={onDelete}
|
||||
iconSize="s"
|
||||
color="text"
|
||||
data-test-subj={DELETE_OPERAND_BUTTON_SUBJ}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -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) => (
|
||||
<div data-test-subj={`${key}Filter`}>{props.value}</div>
|
||||
)),
|
||||
},
|
||||
])
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const mockOnTypeChange = jest.fn();
|
||||
const mockOnValueChange = jest.fn();
|
||||
|
||||
const TestComponent = (overrides: Partial<AlertsFiltersFormItemProps<unknown>>) => {
|
||||
const [type, setType] = useState(overrides?.type);
|
||||
const [value, setValue] = useState(overrides?.value);
|
||||
|
||||
mockOnTypeChange.mockImplementation(setType);
|
||||
mockOnValueChange.mockImplementation(setValue);
|
||||
|
||||
return (
|
||||
<IntlProvider locale="en">
|
||||
<AlertsFiltersFormItem
|
||||
type={type}
|
||||
onTypeChange={mockOnTypeChange}
|
||||
value={value}
|
||||
onValueChange={mockOnValueChange}
|
||||
{...overrides}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AlertsFiltersFormItem', () => {
|
||||
it('should show all available filter types as options', async () => {
|
||||
render(<TestComponent />);
|
||||
|
||||
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(<TestComponent type={alertsFiltersMetadata.ruleTags.id} />);
|
||||
|
||||
expect(screen.getByTestId('ruleTagsFilter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should forward the correct props to the selected filter component', () => {
|
||||
render(<TestComponent type={alertsFiltersMetadata.ruleTags.id} value={['tag1']} />);
|
||||
|
||||
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(<TestComponent />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
await userEvent.click(screen.getByText(alertsFiltersMetadata.ruleTags.displayName));
|
||||
|
||||
expect(mockOnTypeChange).toHaveBeenCalledWith(alertsFiltersMetadata.ruleTags.id);
|
||||
});
|
||||
});
|
|
@ -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<T> {
|
||||
type?: AlertsFiltersFormItemType;
|
||||
onTypeChange: (newFilterType: AlertsFiltersFormItemType) => void;
|
||||
value?: T;
|
||||
onValueChange: (newFilterValue: T) => void;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const options: Array<EuiSuperSelectOption<AlertsFiltersFormItemType>> = Object.values(
|
||||
alertsFiltersMetadata
|
||||
).map((filterMeta) => ({
|
||||
value: filterMeta.id,
|
||||
dropdownDisplay: filterMeta.displayName,
|
||||
inputDisplay: filterMeta.displayName,
|
||||
}));
|
||||
|
||||
export const AlertsFiltersFormItem = <T,>({
|
||||
type,
|
||||
onTypeChange,
|
||||
value,
|
||||
onValueChange,
|
||||
isDisabled = false,
|
||||
}: AlertsFiltersFormItemProps<T>) => {
|
||||
const FilterComponent = type
|
||||
? (alertsFiltersMetadata[type].component as AlertsFilterComponentType<T>)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
label={FORM_ITEM_FILTER_BY_LABEL}
|
||||
labelAppend={
|
||||
<EuiText size="xs" color="subdued">
|
||||
{FORM_ITEM_OPTIONAL_CAPTION}
|
||||
</EuiText>
|
||||
}
|
||||
fullWidth
|
||||
isDisabled={isDisabled}
|
||||
data-test-subj={FORM_ITEM_SUBJ}
|
||||
>
|
||||
<EuiSuperSelect
|
||||
options={options}
|
||||
valueOfSelected={type}
|
||||
onChange={onTypeChange}
|
||||
disabled={isDisabled}
|
||||
placeholder={FORM_ITEM_FILTER_BY_PLACEHOLDER}
|
||||
fullWidth
|
||||
popoverProps={{
|
||||
repositionOnScroll: true,
|
||||
ownFocus: true,
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
{FilterComponent && (
|
||||
<FilterComponent value={value} onChange={onValueChange} isDisabled={isDisabled} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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(
|
||||
<AlertsSolutionSelector
|
||||
solution={undefined}
|
||||
onSolutionChange={mockOnSolutionChange}
|
||||
services={{ http }}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<AlertsSolutionSelector
|
||||
solution={undefined}
|
||||
onSolutionChange={mockOnSolutionChange}
|
||||
services={{ http }}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<AlertsSolutionSelector
|
||||
solution={undefined}
|
||||
onSolutionChange={mockOnSolutionChange}
|
||||
services={{ http }}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<AlertsSolutionSelector
|
||||
solution={undefined}
|
||||
onSolutionChange={jest.fn()}
|
||||
services={{ http }}
|
||||
/>
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
|
@ -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<string, string> = {
|
||||
stack: 'managementApp',
|
||||
security: 'logoSecurity',
|
||||
observability: 'logoObservability',
|
||||
};
|
||||
|
||||
const getAvailableSolutions = (ruleTypes: InternalRuleType[]) => {
|
||||
const solutions = new Set<RuleTypeSolution>();
|
||||
|
||||
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<Array<EuiSuperSelectOption<RuleTypeSolution>>>(() => {
|
||||
return Array.from(availableSolutions.values()).map((sol) => ({
|
||||
value: sol,
|
||||
inputDisplay: (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type={featuresIcons[sol]} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>{capitalize(sol)}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
}));
|
||||
}, [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 (
|
||||
<EuiFormRow
|
||||
label={SOLUTION_SELECTOR_LABEL}
|
||||
isInvalid={isError}
|
||||
isDisabled={isError}
|
||||
error={isError ? RULE_TYPES_LOAD_ERROR_MESSAGE : undefined}
|
||||
fullWidth
|
||||
data-test-subj={SOLUTION_SELECTOR_SUBJ}
|
||||
>
|
||||
<EuiSuperSelect
|
||||
isLoading={isLoading}
|
||||
isInvalid={isError}
|
||||
placeholder={SOLUTION_SELECTOR_PLACEHOLDER}
|
||||
options={options}
|
||||
valueOfSelected={solution}
|
||||
onChange={(newSol) => onSolutionChange(newSol)}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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<AlertsFiltersFormContextValue | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export const AlertsFiltersFormContextProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: PropsWithChildren<{ value: AlertsFiltersFormContextValue }>) => {
|
||||
return (
|
||||
<AlertsFiltersFormContext.Provider value={value}>{children}</AlertsFiltersFormContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAlertsFiltersFormContext = () => {
|
||||
const context = useContext(AlertsFiltersFormContext);
|
||||
if (!context) {
|
||||
throw new Error('Missing AlertsFiltersFormContext');
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -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;
|
|
@ -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: ['<rootDir>/src/platform/packages/shared/response-ops/alerts-filters-form'],
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/platform/packages/shared/response-ops/alerts-filters-form/setup_tests.ts',
|
||||
],
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/response-ops-alerts-filters-form",
|
||||
"owner": "@elastic/response-ops",
|
||||
"group": "platform",
|
||||
"visibility": "shared"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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';
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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",
|
||||
]
|
||||
}
|
|
@ -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<T> = ComponentType<{
|
||||
value?: T;
|
||||
onChange: (newValue: T) => void;
|
||||
isDisabled?: boolean;
|
||||
}>;
|
||||
|
||||
export interface AlertsFilter {
|
||||
type?: AlertsFiltersFormItemType;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface AlertsFilterMetadata<T> {
|
||||
id: string;
|
||||
displayName: string;
|
||||
component: AlertsFilterComponentType<T>;
|
||||
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[];
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1512,6 +1512,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"],
|
||||
|
|
|
@ -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';
|
||||
|
@ -258,6 +259,20 @@ const TriggersActionsUiExampleApp = ({
|
|||
</Page>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/alerts_filters_form"
|
||||
render={() => (
|
||||
<Page title="Alerts filters form">
|
||||
<AlertsFiltersFormSandbox
|
||||
services={{
|
||||
http,
|
||||
notifications,
|
||||
}}
|
||||
/>
|
||||
</Page>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</EuiPage>
|
||||
</Router>
|
||||
|
|
|
@ -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<RuleTypeSolution | undefined>();
|
||||
const [filters, setFilters] = useState<AlertsFiltersExpression>();
|
||||
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 (
|
||||
<EuiPanel
|
||||
css={css`
|
||||
max-width: 400px;
|
||||
margin: 40px auto;
|
||||
`}
|
||||
>
|
||||
<EuiFlexGroup direction="column" gutterSize="m">
|
||||
<EuiFlexItem>
|
||||
<AlertsSolutionSelector
|
||||
services={{ http }}
|
||||
solution={solution}
|
||||
onSolutionChange={(newSolution: RuleTypeSolution) => {
|
||||
if (solution != null && newSolution !== solution) {
|
||||
setFilters(undefined);
|
||||
}
|
||||
setSolution(newSolution);
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
{isLoadingRuleTypes ? (
|
||||
<EuiLoadingSpinner size="m" />
|
||||
) : (
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiText>
|
||||
<h4>
|
||||
<FormattedMessage id="alertsFiltersForm.formTitle" defaultMessage="Filters" />
|
||||
</h4>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<AlertsFiltersForm
|
||||
ruleTypeIds={ruleTypeIds}
|
||||
value={filters}
|
||||
onChange={setFilters}
|
||||
isDisabled={!solution}
|
||||
services={services}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -36,5 +36,10 @@
|
|||
"@kbn/response-ops-rule-form",
|
||||
"@kbn/task-manager-plugin",
|
||||
"@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",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -6714,6 +6714,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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue