Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9d8983a
Implement alerts filters form
umbopepato Mar 24, 2025
854495d
Add solution selector and tests
umbopepato Mar 24, 2025
9982fb6
[CI] Auto-commit changed files from 'node scripts/yarn_deduplicate'
kibanamachine Mar 24, 2025
7741bf2
[CI] Auto-commit changed files from 'node scripts/eslint --no-cache -…
kibanamachine Mar 26, 2025
e3d8a54
Merge branch 'main' of github.com:elastic/kibana into 213061-alerts-b…
umbopepato Mar 27, 2025
1d8fca4
Move all translations to translations.ts file
umbopepato Mar 27, 2025
048e51d
Merge branch 'main' into 213061-alerts-boolean-filters-ui
umbopepato Mar 31, 2025
89856ca
Split filters metadata tests in smaller cases with better descriptions
umbopepato Mar 31, 2025
8b1029f
Merge branch 'main' into 213061-alerts-boolean-filters-ui
umbopepato Mar 31, 2025
989472f
Merge branch 'main' of github.com:elastic/kibana into 213061-alerts-b…
umbopepato Apr 3, 2025
4cfc7a1
Implement suggested changes
umbopepato Apr 3, 2025
7d77719
Merge branch 'main' into 213061-alerts-boolean-filters-ui
umbopepato Apr 7, 2025
0138a34
Use better disabled assertion in test
umbopepato Apr 10, 2025
1dc216f
Implement suggested changes
umbopepato Apr 11, 2025
c0cdc11
Use clearer boolean conversion
umbopepato Apr 11, 2025
6991785
Reset value when changing filter type
umbopepato Apr 11, 2025
f6f2324
Use flat expression as unique filters form state
umbopepato Apr 14, 2025
9823b39
Remove unused utils
umbopepato Apr 14, 2025
6dac42e
Add test cases
umbopepato Apr 14, 2025
8786ed0
Merge branch 'main' of github.com:elastic/kibana into 213061-alerts-b…
umbopepato Apr 14, 2025
b439e12
Add placeholders to filters
umbopepato Apr 15, 2025
265436e
Improve alerts filters form test
umbopepato Apr 15, 2025
b23bf48
Merge branch 'main' of github.com:elastic/kibana into 213061-alerts-b…
umbopepato Apr 15, 2025
1990bab
[CI] Auto-commit changed files from 'node scripts/yarn_deduplicate'
kibanamachine Apr 15, 2025
3c8d622
Implement suggested changes
umbopepato Apr 17, 2025
a2e5746
Merge branch 'main' of github.com:elastic/kibana into 213061-alerts-b…
umbopepato Apr 17, 2025
7aa5569
Merge branch '213061-alerts-boolean-filters-ui' of github.com:umbopep…
umbopepato Apr 17, 2025
108db58
Merge branch 'main' into 213061-alerts-boolean-filters-ui
umbopepato Apr 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @kbn/response-ops-alerts-filters-form

A form to create and edit boolean filter expressions for alert document search queries.
Original file line number Diff line number Diff line change
@@ -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', () => {
Comment thread
cnasikas marked this conversation as resolved.
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);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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[]>;
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
Loading