[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:
Umberto Pepato 2025-04-17 16:18:24 +02:00 committed by GitHub
parent c5ff7aa155
commit c44efc52f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1813 additions and 17 deletions

1
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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",

View file

@ -0,0 +1,3 @@
# @kbn/response-ops-alerts-filters-form
A form to create and edit boolean filter expressions for alert document search queries.

View file

@ -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);
});
});
});
});

View file

@ -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[]>;

View file

@ -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);
});
});
});
});

View file

@ -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[]>;

View file

@ -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),
]);
});
});

View file

@ -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>
);
};

View file

@ -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);
});
});

View file

@ -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} />
)}
</>
);
};

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", 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();
});
});

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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;
};

View file

@ -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;

View file

@ -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',
],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-browser",
"id": "@kbn/response-ops-alerts-filters-form",
"owner": "@elastic/response-ops",
"group": "platform",
"visibility": "shared"
}

View file

@ -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"
}

View file

@ -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';

View file

@ -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',
}
);

View file

@ -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",
]
}

View file

@ -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[];

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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,
};
}

View file

@ -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"],

View file

@ -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>

View file

@ -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>
);
};

View file

@ -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'),
},
],
},
{

View file

@ -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",
]
}

View file

@ -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 ""