[RAM] Rule List Tags Filtering with Infinite Scroll (#159246)

## Summary
Resolves: https://github.com/elastic/kibana/issues/150364
Fixes: https://github.com/elastic/sdh-kibana/issues/3806

Fixes a common complaint and bug where we limit the number of tags that
are displayed in the tags dropdown to 50. We now paginate these results
with a max tag of 10,000 (50 per page). It's not a true pagination
because Elasticsearch doesn't support filtering and paginating on
aggregation buckets (bucket selector doesn't work on terms). Since tags
are not nested properties or references, they can only be reached
through terms aggregation.

But at least we don't return 10000 tags from the API.

## How to test:
1. By default we show 50 tags per page, to make testing easier, you may
go to `x-pack/plugins/alerting/server/rules_client/methods/get_tags.ts`
and set `DEFAULT_TAGS_PER_PAGE` to 10 or something
2. Create some rules
3. Create some tags (spread amongst the rules would be even better)
4. Open the tag filter
5. Should be able to search, paginate, and filter rules by the tags

![ezgif
com-video-to-gif](https://user-images.githubusercontent.com/74562234/217985463-ee3ce86f-e614-4690-a48a-74a328ca9cff.gif)

### Checklist
- [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>
This commit is contained in:
Jiawei Wu 2023-06-14 09:08:02 -07:00 committed by GitHub
parent 195216f0ec
commit 65b8a10027
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1259 additions and 452 deletions

View file

@ -18,7 +18,7 @@ export const RuleTagFilterSandbox = ({ triggersActionsUi }: SandboxProps) => {
return (
<div style={{ flex: 1 }}>
{triggersActionsUi.getRuleTagFilter({
tags: ['tag1', 'tag2', 'tag3', 'tag4'],
canLoadRules: true,
selectedTags,
onChange: setSelectedTags,
})}

View file

@ -12,27 +12,24 @@ import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesClientMock } from '../rules_client.mock';
import { getRuleTagsRoute } from './get_rule_tags';
import {} from '../../common/rule_tags_aggregation';
const rulesClient = rulesClientMock.create();
jest.mock('../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
jest.mock('../../common/rule_tags_aggregation', () => ({
...jest.requireActual('../../common/rule_tags_aggregation'),
formatRuleTagsAggregationResult: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
rulesClient.getTags.mockResolvedValueOnce({
data: ['a', 'b', 'c'],
page: 1,
perPage: 10,
total: 3,
});
});
const { formatRuleTagsAggregationResult } = jest.requireMock('../../common/rule_tags_aggregation');
describe('getRuleTagsRoute', () => {
it('aggregates rule tags with proper parameters', async () => {
it('gets rule tags with proper parameters', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
@ -42,19 +39,13 @@ describe('getRuleTagsRoute', () => {
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rules/_tags"`);
const aggregateResult = { ruleTags: ['a', 'b', 'c'] };
formatRuleTagsAggregationResult.mockReturnValueOnce(aggregateResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
query: {
filter: 'test',
search: 'search text',
after: {
tags: 'c',
},
search: 'test',
per_page: 10,
page: 1,
},
},
['ok']
@ -63,60 +54,28 @@ describe('getRuleTagsRoute', () => {
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"rule_tags": Array [
"data": Array [
"a",
"b",
"c",
],
"page": 1,
"per_page": 10,
"total": 3,
},
}
`);
expect(rulesClient.aggregate).toHaveBeenCalledTimes(1);
expect(rulesClient.aggregate.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"aggs": Object {
"tags": Object {
"composite": Object {
"after": Object {
"tags": "c",
},
"size": 50,
"sources": Array [
Object {
"tags": Object {
"terms": Object {
"field": "alert.attributes.tags",
"order": "asc",
},
},
},
],
},
},
},
"options": Object {
"after": Object {
"tags": "c",
},
"defaultSearchOperator": "AND",
"filter": "test",
"search": "search text",
"searchFields": Array [
"tags",
],
},
},
]
`);
expect(res.ok).toHaveBeenCalledWith({
body: {
rule_tags: ['a', 'b', 'c'],
data: ['a', 'b', 'c'],
page: 1,
per_page: 10,
total: 3,
},
});
});
it('ensures the license allows aggregating rule tags', async () => {
it('ensures the license allows getting rule tags', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
@ -124,17 +83,13 @@ describe('getRuleTagsRoute', () => {
const [, handler] = router.get.mock.calls[0];
formatRuleTagsAggregationResult.mockReturnValueOnce({ ruleTags: ['a', 'b', 'c', 'd'] });
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
query: {
filter: 'test',
search: 'search text',
after: {
tags: 'c',
},
search: 'test',
per_page: 10,
page: 1,
},
}
);
@ -144,7 +99,7 @@ describe('getRuleTagsRoute', () => {
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
it('ensures the license check prevents aggregating rule tags', async () => {
it('ensures the license check prevents getting rule tags', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();

View file

@ -7,43 +7,29 @@
import { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import {
RuleTagsAggregationResult,
RuleTagsAggregationFormattedResult,
RuleTagsAggregationOptions,
getRuleTagsAggregation,
formatRuleTagsAggregationResult,
} from '../../common';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
import { ILicenseState } from '../lib';
import { RewriteResponseCase, RewriteRequestCase, verifyAccessAndContext } from './lib';
import {
DEFAULT_TAGS_PER_PAGE,
GetTagsParams,
GetTagsResult,
} from '../rules_client/methods/get_tags';
const querySchema = schema.object({
filter: schema.maybe(schema.string()),
page: schema.number({ defaultValue: 1, min: 1 }),
per_page: schema.maybe(schema.number({ defaultValue: DEFAULT_TAGS_PER_PAGE, min: 1 })),
search: schema.maybe(schema.string()),
after: schema.maybe(
schema.recordOf(
schema.string(),
schema.nullable(schema.oneOf([schema.string(), schema.number()]))
)
),
max_tags: schema.maybe(schema.number()),
});
const rewriteQueryReq: RewriteRequestCase<RuleTagsAggregationOptions> = ({
max_tags: maxTags,
...rest
}) => ({
const rewriteQueryReq: RewriteRequestCase<GetTagsParams> = ({ per_page: perPage, ...rest }) => ({
...rest,
...(maxTags ? { maxTags } : {}),
perPage,
});
const rewriteBodyRes: RewriteResponseCase<RuleTagsAggregationFormattedResult> = ({
ruleTags,
...rest
}) => ({
const rewriteBodyRes: RewriteResponseCase<GetTagsResult> = ({ perPage, ...rest }) => ({
...rest,
rule_tags: ruleTags,
per_page: perPage,
});
export const getRuleTagsRoute = (
@ -62,20 +48,10 @@ export const getRuleTagsRoute = (
const rulesClient = (await context.alerting).getRulesClient();
const options = rewriteQueryReq(req.query);
const aggregateResult = await rulesClient.aggregate<RuleTagsAggregationResult>({
options: {
...options,
defaultSearchOperator: 'AND',
searchFields: ['tags'],
},
aggs: getRuleTagsAggregation({
maxTags: options.maxTags,
after: options.after,
}),
});
const tagsResult = await rulesClient.getTags(options);
return res.ok({
body: rewriteBodyRes(formatRuleTagsAggregationResult(aggregateResult)),
body: rewriteBodyRes(tagsResult),
});
})
)

View file

@ -13,6 +13,7 @@ export type RulesClientMock = jest.Mocked<Schema>;
const createRulesClientMock = () => {
const mocked: RulesClientMock = {
aggregate: jest.fn().mockReturnValue({ ruleExecutionStatus: {}, ruleLastRunOutcome: {} }),
getTags: jest.fn(),
create: jest.fn(),
get: jest.fn(),
resolve: jest.fn(),

View file

@ -0,0 +1,122 @@
/*
* 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 Boom from '@hapi/boom';
import { TypeOf, schema } from '@kbn/config-schema';
import { KueryNode, nodeBuilder, nodeTypes } from '@kbn/es-query';
import { RulesClientContext } from '../types';
import { AlertingAuthorizationEntity } from '../../authorization';
import { alertingAuthorizationFilterOpts } from '../common/constants';
import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events';
import { RawRule } from '../../types';
export const DEFAULT_TAGS_PER_PAGE = 50;
const MAX_TAGS = 10000;
const getTagsParamsSchema = schema.object({
page: schema.number({ defaultValue: 1, min: 1 }),
perPage: schema.maybe(schema.number({ defaultValue: DEFAULT_TAGS_PER_PAGE, min: 1 })),
search: schema.maybe(schema.string()),
});
export type GetTagsParams = TypeOf<typeof getTagsParamsSchema>;
export interface RuleTagsAggregationResult {
tags: {
buckets: Array<{
key: string;
doc_count: number;
}>;
};
}
export interface GetTagsResult {
total: number;
page: number;
perPage: number;
data: string[];
}
export async function getTags(
context: RulesClientContext,
params: GetTagsParams
): Promise<GetTagsResult> {
let validatedParams: GetTagsParams;
try {
validatedParams = getTagsParamsSchema.validate(params);
} catch (error) {
throw Boom.badRequest(`Failed to validate params: ${error.message}`);
}
const { page, perPage = DEFAULT_TAGS_PER_PAGE, search = '' } = validatedParams;
let authorizationTuple;
try {
authorizationTuple = await context.authorization.getFindAuthorizationFilter(
AlertingAuthorizationEntity.Rule,
alertingAuthorizationFilterOpts
);
} catch (error) {
context.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.AGGREGATE,
error,
})
);
throw error;
}
const { filter: authorizationFilter } = authorizationTuple;
const filter =
authorizationFilter && search
? nodeBuilder.and([
nodeBuilder.is('alert.attributes.tags', nodeTypes.wildcard.buildNode(`${search}*`)),
authorizationFilter as KueryNode,
])
: authorizationFilter;
const response = await context.unsecuredSavedObjectsClient.find<
RawRule,
RuleTagsAggregationResult
>({
filter,
type: 'alert',
aggs: {
tags: {
terms: {
field: 'alert.attributes.tags',
order: {
_key: 'asc',
},
size: MAX_TAGS,
},
},
},
});
const filteredTags = (response.aggregations?.tags?.buckets || []).reduce<string[]>(
(result, bucket) => {
if (bucket.key.startsWith(search)) {
result.push(bucket.key);
}
return result;
},
[]
);
const startIndex = (page - 1) * perPage;
const endIndex = startIndex + perPage;
const chunkedTags = filteredTags.slice(startIndex, endIndex);
return {
total: filteredTags.length,
page,
perPage,
data: chunkedTags,
};
}

View file

@ -53,6 +53,7 @@ import { unmuteInstance } from './methods/unmute_instance';
import { runSoon } from './methods/run_soon';
import { listRuleTypes } from './methods/list_rule_types';
import { getAlertFromRaw, GetAlertFromRawParams } from './lib/get_alert_from_raw';
import { getTags, GetTagsParams } from './methods/get_tags';
export type ConstructorOptions = Omit<
RulesClientContext,
@ -165,6 +166,8 @@ export class RulesClient {
return this.context.spaceId;
}
public getTags = (params: GetTagsParams) => getTags(this.context, params);
public getAlertFromRaw = (params: GetAlertFromRawParams) =>
getAlertFromRaw(
this.context,

View file

@ -0,0 +1,286 @@
/*
* 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 { v4 } from 'uuid';
import { RulesClient, ConstructorOptions } from '../rules_client';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { ruleTypeRegistryMock } from '../../rule_type_registry.mock';
import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock';
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks';
import { AlertingAuthorization } from '../../authorization/alerting_authorization';
import { ActionsAuthorization } from '@kbn/actions-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup } from './lib';
import { RecoveredActionGroup } from '../../../common';
import { RegistryRuleType } from '../../rule_type_registry';
const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertingAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
const auditLogger = auditLoggerMock.create();
const kibanaVersion = 'v7.10.0';
const rulesClientParams: jest.Mocked<ConstructorOptions> = {
taskManager,
ruleTypeRegistry,
unsecuredSavedObjectsClient,
minimumScheduleInterval: { value: '1m', enforce: false },
authorization: authorization as unknown as AlertingAuthorization,
actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization,
spaceId: 'default',
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
getEventLogClient: jest.fn(),
kibanaVersion,
isAuthenticationTypeAPIKey: jest.fn(),
getAuthenticationAPIKey: jest.fn(),
};
const listedTypes = new Set<RegistryRuleType>([
{
actionGroups: [],
actionVariables: undefined,
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
id: 'myType',
name: 'myType',
producer: 'myApp',
enabledInLicense: true,
},
]);
beforeEach(() => {
getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry);
(auditLogger.log as jest.Mock).mockClear();
});
const getMockAggregationResult = (tags: string[]) => {
return {
aggregations: {
tags: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: tags.map((tag) => ({
key: tag,
doc_count: 1,
})),
},
},
page: 1,
per_page: 20,
total: 1,
saved_objects: [],
};
};
describe('getTags()', () => {
beforeEach(() => {
authorization.getFindAuthorizationFilter.mockResolvedValue({
ensureRuleTypeIsAuthorized() {},
});
unsecuredSavedObjectsClient.find.mockResolvedValue(getMockAggregationResult(['a', 'b', 'c']));
ruleTypeRegistry.list.mockReturnValue(listedTypes);
authorization.filterByRuleTypeAuthorization.mockResolvedValue(
new Set([
{
id: 'myType',
name: 'Test',
actionGroups: [{ id: 'default', name: 'Default' }],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
producer: 'alerts',
authorizedConsumers: {
myApp: { read: true, all: true },
},
enabledInLicense: true,
},
])
);
});
test('calls saved objects client with given params to get rule tags', async () => {
const rulesClient = new RulesClient(rulesClientParams);
const result = await rulesClient.getTags({
search: '',
page: 1,
perPage: 100,
});
expect(unsecuredSavedObjectsClient.find).toHaveBeenLastCalledWith({
aggs: {
tags: { terms: { field: 'alert.attributes.tags', order: { _key: 'asc' }, size: 10000 } },
},
filter: undefined,
type: 'alert',
});
expect(result.data).toEqual(['a', 'b', 'c']);
expect(result.page).toEqual(1);
expect(result.perPage).toEqual(100);
expect(result.total).toEqual(3);
});
test('should paginate long results', async () => {
const rulesClient = new RulesClient(rulesClientParams);
const tags = [...Array(200)].map(() => v4());
unsecuredSavedObjectsClient.find.mockResolvedValue(getMockAggregationResult(tags));
let result = await rulesClient.getTags({
search: '',
page: 1,
perPage: 10,
});
expect(result.data).toEqual(tags.slice(0, 10));
expect(result.page).toEqual(1);
expect(result.perPage).toEqual(10);
expect(result.total).toEqual(200);
result = await rulesClient.getTags({
search: '',
page: 2,
perPage: 10,
});
expect(result.data).toEqual(tags.slice(10, 20));
expect(result.page).toEqual(2);
expect(result.perPage).toEqual(10);
expect(result.total).toEqual(200);
result = await rulesClient.getTags({
search: '',
page: 20,
perPage: 10,
});
expect(result.data).toEqual(tags.slice(190, 200));
expect(result.page).toEqual(20);
expect(result.perPage).toEqual(10);
expect(result.total).toEqual(200);
result = await rulesClient.getTags({
search: '',
page: 21,
perPage: 10,
});
expect(result.data).toEqual([]);
expect(result.page).toEqual(21);
expect(result.perPage).toEqual(10);
expect(result.total).toEqual(200);
});
test('should search and paginate for tags', async () => {
const rulesClient = new RulesClient(rulesClientParams);
const tags = [
'a',
'aa',
'aaa',
'a1',
'a2',
'a3',
'a4',
'a5',
'a6',
'a7',
'b',
'bb',
'bbb',
'c',
'd',
'e',
'f',
'g',
'1',
'11',
'1_1',
'11_1',
'110',
'2',
'3',
'4',
];
unsecuredSavedObjectsClient.find.mockResolvedValue(getMockAggregationResult(tags));
let result = await rulesClient.getTags({
search: 'a',
page: 1,
perPage: 5,
});
expect(result.data).toEqual(['a', 'aa', 'aaa', 'a1', 'a2']);
expect(result.page).toEqual(1);
expect(result.perPage).toEqual(5);
expect(result.total).toEqual(10);
result = await rulesClient.getTags({
search: 'a',
page: 2,
perPage: 5,
});
expect(result.data).toEqual(['a3', 'a4', 'a5', 'a6', 'a7']);
expect(result.page).toEqual(2);
expect(result.perPage).toEqual(5);
expect(result.total).toEqual(10);
result = await rulesClient.getTags({
search: 'aa',
page: 1,
perPage: 5,
});
expect(result.data).toEqual(['aa', 'aaa']);
expect(result.page).toEqual(1);
expect(result.perPage).toEqual(5);
expect(result.total).toEqual(2);
result = await rulesClient.getTags({
search: '1',
page: 1,
perPage: 5,
});
expect(result.data).toEqual(['1', '11', '1_1', '11_1', '110']);
expect(result.page).toEqual(1);
expect(result.perPage).toEqual(5);
expect(result.total).toEqual(5);
});
test('should validate getTag inputs', async () => {
const rulesClient = new RulesClient(rulesClientParams);
await expect(rulesClient.getTags({ page: -1 })).rejects.toThrow(
'Failed to validate params: [page]: Value must be equal to or greater than [1].'
);
await expect(rulesClient.getTags({ page: 1, perPage: 0 })).rejects.toThrow(
'Failed to validate params: [perPage]: Value must be equal to or greater than [1].'
);
const result = await rulesClient.getTags({ page: 1 });
expect(result.perPage).toEqual(50);
});
});

View file

@ -1,78 +0,0 @@
/*
* 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 from 'react';
import { renderHook } from '@testing-library/react-hooks/dom';
import { useLoadTagsQuery as useLoadTags } from './use_load_tags_query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useKibana } from '../../common/lib/kibana';
import { IToasts } from '@kbn/core-notifications-browser';
import { waitFor } from '@testing-library/dom';
const MOCK_TAGS = ['a', 'b', 'c'];
jest.mock('../../common/lib/kibana');
jest.mock('../lib/rule_api/aggregate', () => ({
loadRuleTags: jest.fn(),
}));
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const { loadRuleTags } = jest.requireMock('../lib/rule_api/aggregate');
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const wrapper = ({ children }: { children: Node }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
describe('useLoadTags', () => {
beforeEach(() => {
useKibanaMock().services.notifications.toasts = {
addDanger: jest.fn(),
} as unknown as IToasts;
loadRuleTags.mockResolvedValue({
ruleTags: MOCK_TAGS,
});
});
afterEach(() => {
queryClient.clear();
jest.clearAllMocks();
});
it('should call loadRuleTags API and handle result', async () => {
const { rerender, result, waitForNextUpdate } = renderHook(
() => useLoadTags({ enabled: true }),
{
wrapper,
}
);
rerender();
await waitForNextUpdate();
expect(loadRuleTags).toBeCalled();
expect(result.current.tags).toEqual(MOCK_TAGS);
});
it('should call onError if API fails', async () => {
loadRuleTags.mockRejectedValue('');
const { result } = renderHook(() => useLoadTags({ enabled: true }), { wrapper });
expect(loadRuleTags).toBeCalled();
expect(result.current.tags).toEqual([]);
await waitFor(() =>
expect(useKibanaMock().services.notifications.toasts.addDanger).toBeCalled()
);
});
});

View file

@ -0,0 +1,182 @@
/*
* 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 from 'react';
import { renderHook } from '@testing-library/react-hooks/dom';
import { useLoadTagsQuery } from './use_load_tags_query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useKibana } from '../../common/lib/kibana';
import { IToasts } from '@kbn/core-notifications-browser';
import { waitFor } from '@testing-library/dom';
const MOCK_TAGS = ['a', 'b', 'c'];
jest.mock('../../common/lib/kibana');
jest.mock('../lib/rule_api/aggregate', () => ({
loadRuleTags: jest.fn(),
}));
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const { loadRuleTags } = jest.requireMock('../lib/rule_api/aggregate');
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const wrapper = ({ children }: { children: Node }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
describe('useLoadTagsQuery', () => {
beforeEach(() => {
useKibanaMock().services.notifications.toasts = {
addDanger: jest.fn(),
} as unknown as IToasts;
loadRuleTags.mockResolvedValue({
data: MOCK_TAGS,
page: 1,
perPage: 50,
total: MOCK_TAGS.length,
});
});
afterEach(() => {
queryClient.clear();
jest.clearAllMocks();
});
it('should call loadRuleTags API and handle result', async () => {
const { rerender, result, waitForNextUpdate } = renderHook(
() =>
useLoadTagsQuery({
enabled: true,
search: 'test',
perPage: 50,
page: 1,
}),
{
wrapper,
}
);
rerender();
await waitForNextUpdate();
expect(loadRuleTags).toHaveBeenLastCalledWith(
expect.objectContaining({
search: 'test',
perPage: 50,
page: 1,
})
);
expect(result.current.tags).toEqual(MOCK_TAGS);
expect(result.current.hasNextPage).toEqual(false);
});
it('should support pagination', async () => {
loadRuleTags.mockResolvedValue({
data: ['a', 'b', 'c', 'd', 'e'],
page: 1,
perPage: 5,
total: 10,
});
const { rerender, result, waitForNextUpdate } = renderHook(
() =>
useLoadTagsQuery({
enabled: true,
perPage: 5,
page: 1,
}),
{
wrapper,
}
);
rerender();
await waitForNextUpdate();
expect(loadRuleTags).toHaveBeenLastCalledWith(
expect.objectContaining({
perPage: 5,
page: 1,
})
);
expect(result.current.tags).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(result.current.hasNextPage).toEqual(true);
loadRuleTags.mockResolvedValue({
data: ['a', 'b', 'c', 'd', 'e'],
page: 2,
perPage: 5,
total: 10,
});
result.current.fetchNextPage();
expect(loadRuleTags).toHaveBeenLastCalledWith(
expect.objectContaining({
perPage: 5,
page: 2,
})
);
rerender();
await waitForNextUpdate();
expect(result.current.hasNextPage).toEqual(false);
});
it('should support pagination when there are no tags', async () => {
loadRuleTags.mockResolvedValue({
data: [],
page: 1,
perPage: 5,
total: 0,
});
const { rerender, result, waitForNextUpdate } = renderHook(
() =>
useLoadTagsQuery({
enabled: true,
perPage: 5,
page: 1,
}),
{
wrapper,
}
);
rerender();
await waitForNextUpdate();
expect(loadRuleTags).toHaveBeenLastCalledWith(
expect.objectContaining({
perPage: 5,
page: 1,
})
);
expect(result.current.tags).toEqual([]);
expect(result.current.hasNextPage).toEqual(false);
});
it('should call onError if API fails', async () => {
loadRuleTags.mockRejectedValue('');
const { result } = renderHook(() => useLoadTagsQuery({ enabled: true }), { wrapper });
expect(loadRuleTags).toBeCalled();
expect(result.current.tags).toEqual([]);
await waitFor(() =>
expect(useKibanaMock().services.notifications.toasts.addDanger).toBeCalled()
);
});
});

View file

@ -5,26 +5,49 @@
* 2.0.
*/
import { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { useQuery } from '@tanstack/react-query';
import { useInfiniteQuery } from '@tanstack/react-query';
import { loadRuleTags } from '../lib/rule_api/aggregate';
import { useKibana } from '../../common/lib/kibana';
import { LoadRuleTagsProps } from '../lib/rule_api';
import { GetRuleTagsResponse } from '../lib/rule_api/aggregate_helpers';
interface UseLoadTagsQueryProps {
enabled: boolean;
refresh?: Date;
search?: string;
perPage?: number;
page?: number;
}
const EMPTY_TAGS: string[] = [];
// React query will refetch all prev pages when the cache keys change:
// https://github.com/TanStack/query/discussions/3576
export function useLoadTagsQuery(props: UseLoadTagsQueryProps) {
const { enabled, refresh } = props;
const { enabled, refresh, search, perPage, page = 1 } = props;
const {
http,
notifications: { toasts },
} = useKibana().services;
const queryFn = () => {
return loadRuleTags({ http });
const queryFn = ({ pageParam }: { pageParam?: LoadRuleTagsProps }) => {
if (pageParam) {
return loadRuleTags({
http,
perPage: pageParam.perPage,
page: pageParam.page,
search,
});
}
return loadRuleTags({
http,
perPage,
page,
search,
});
};
const onErrorFn = () => {
@ -35,21 +58,48 @@ export function useLoadTagsQuery(props: UseLoadTagsQueryProps) {
);
};
const { refetch, data } = useQuery({
queryKey: [
'loadRuleTags',
{
refresh: refresh?.toDateString(),
},
],
queryFn,
onError: onErrorFn,
enabled,
refetchOnWindowFocus: false,
});
const getNextPageParam = (lastPage: GetRuleTagsResponse) => {
const totalPages = Math.max(1, Math.ceil(lastPage.total / lastPage.perPage));
if (totalPages === lastPage.page) {
return;
}
return {
...lastPage,
page: lastPage.page + 1,
};
};
const { refetch, data, fetchNextPage, isLoading, isFetching, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: [
'loadRuleTags',
search,
perPage,
page,
{
refresh: refresh?.toISOString(),
},
],
queryFn,
onError: onErrorFn,
enabled,
getNextPageParam,
refetchOnWindowFocus: false,
});
const tags = useMemo(() => {
return (
data?.pages.reduce<string[]>((result, current) => {
return result.concat(current.data);
}, []) || EMPTY_TAGS
);
}, [data]);
return {
tags: data?.ruleTags ?? [],
loadTags: refetch,
tags,
hasNextPage,
refetch,
isLoading: isLoading || isFetching || isFetchingNextPage,
fetchNextPage,
};
}

View file

@ -288,23 +288,39 @@ describe('loadRuleAggregations', () => {
`);
});
test('loadRuleTags should call the aggregate API with no filters', async () => {
test('loadRuleTags should call the getTags API', async () => {
const resolvedValue = {
rule_tags: ['a', 'b', 'c'],
data: ['a', 'b', 'c'],
total: 3,
page: 2,
per_page: 30,
};
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadRuleTags({
http,
search: 'test',
page: 2,
perPage: 30,
});
expect(result).toEqual({
ruleTags: ['a', 'b', 'c'],
data: ['a', 'b', 'c'],
page: 2,
perPage: 30,
total: 3,
});
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/internal/alerting/rules/_aggregate",
"/internal/alerting/rules/_tags",
Object {
"query": Object {
"page": 2,
"per_page": 30,
"search": "test",
},
},
]
`);
});

View file

@ -4,24 +4,33 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { HttpSetup } from '@kbn/core/public';
import { AsApiContract } from '@kbn/actions-plugin/common';
import {
RuleAggregationFormattedResult,
RuleTagsAggregationFormattedResult,
} from '@kbn/alerting-plugin/common';
import { RuleAggregationFormattedResult } from '@kbn/alerting-plugin/common';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
import { mapFiltersToKql } from './map_filters_to_kql';
import { LoadRuleAggregationsProps, rewriteBodyRes, rewriteTagsBodyRes } from './aggregate_helpers';
import {
LoadRuleAggregationsProps,
LoadRuleTagsProps,
rewriteBodyRes,
rewriteTagsBodyRes,
GetRuleTagsResponse,
} from './aggregate_helpers';
// TODO: https://github.com/elastic/kibana/issues/131682
export async function loadRuleTags({
http,
}: {
http: HttpSetup;
}): Promise<RuleTagsAggregationFormattedResult> {
const res = await http.get<AsApiContract<RuleTagsAggregationFormattedResult>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/_aggregate`
search,
perPage,
page,
}: LoadRuleTagsProps): Promise<GetRuleTagsResponse> {
const res = await http.get<AsApiContract<GetRuleTagsResponse>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/_tags`,
{
query: {
search,
per_page: perPage,
page,
},
}
);
return rewriteTagsBodyRes(res);
}

View file

@ -7,10 +7,7 @@
import { HttpSetup } from '@kbn/core/public';
import { RewriteRequestCase } from '@kbn/actions-plugin/common';
import {
RuleAggregationFormattedResult,
RuleTagsAggregationFormattedResult,
} from '@kbn/alerting-plugin/common';
import { RuleAggregationFormattedResult } from '@kbn/alerting-plugin/common';
import { RuleStatus } from '../../../types';
export const rewriteBodyRes: RewriteRequestCase<RuleAggregationFormattedResult> = ({
@ -31,10 +28,19 @@ export const rewriteBodyRes: RewriteRequestCase<RuleAggregationFormattedResult>
ruleTags,
});
export const rewriteTagsBodyRes: RewriteRequestCase<RuleTagsAggregationFormattedResult> = ({
rule_tags: ruleTags,
}: any) => ({
ruleTags,
export interface GetRuleTagsResponse {
total: number;
page: number;
perPage: number;
data: string[];
}
export const rewriteTagsBodyRes: RewriteRequestCase<GetRuleTagsResponse> = ({
per_page: perPage,
...rest
}) => ({
perPage,
...rest,
});
export interface LoadRuleAggregationsProps {
@ -47,3 +53,10 @@ export interface LoadRuleAggregationsProps {
ruleStatusesFilter?: RuleStatus[];
tagsFilter?: string[];
}
export interface LoadRuleTagsProps {
http: HttpSetup;
search?: string;
perPage?: number;
page: number;
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
export type { LoadRuleAggregationsProps } from './aggregate_helpers';
export type { LoadRuleAggregationsProps, LoadRuleTagsProps } from './aggregate_helpers';
export type { LoadRulesProps } from './rules_helpers';
export type {
LoadExecutionLogAggregationsProps,

View file

@ -6,86 +6,122 @@
*/
import React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { EuiFilterButton, EuiSelectable, EuiFilterGroup } from '@elastic/eui';
import { fireEvent, render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { RuleTagFilter } from './rule_tag_filter';
const onChangeMock = jest.fn();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const observe = jest.fn();
const unobserve = jest.fn();
const disconnect = jest.fn();
jest.mock('../../../../common/lib/kibana');
const WithProviders = ({ children }: { children: any }) => (
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</IntlProvider>
);
jest.mock('../../../lib/rule_api/aggregate', () => ({
loadRuleTags: jest.fn(),
}));
const { loadRuleTags } = jest.requireMock('../../../lib/rule_api/aggregate');
const renderWithProviders = (ui: any) => {
return render(ui, { wrapper: WithProviders });
};
const tags = ['a', 'b', 'c', 'd', 'e', 'f'];
describe('rule_tag_filter', () => {
beforeEach(() => {
onChangeMock.mockReset();
});
it('renders correctly', () => {
const wrapper = mountWithIntl(
<RuleTagFilter tags={tags} selectedTags={[]} onChange={onChangeMock} />
);
expect(wrapper.find(EuiFilterButton).exists()).toBeTruthy();
expect(wrapper.find('.euiNotificationBadge').last().text()).toEqual('0');
});
it('can open the popover correctly', () => {
const wrapper = mountWithIntl(
<RuleTagFilter tags={tags} selectedTags={[]} onChange={onChangeMock} />
);
expect(wrapper.find('[data-test-subj="ruleTagFilterSelectable"]').exists()).toBeFalsy();
wrapper.find(EuiFilterButton).simulate('click');
expect(wrapper.find('[data-test-subj="ruleTagFilterSelectable"]').exists()).toBeTruthy();
expect(wrapper.find('li').length).toEqual(tags.length);
});
it('can select tags', () => {
const wrapper = mountWithIntl(
<RuleTagFilter tags={tags} selectedTags={[]} onChange={onChangeMock} />
);
wrapper.find(EuiFilterButton).simulate('click');
wrapper.find('[data-test-subj="ruleTagFilterOption-a"]').at(0).simulate('click');
expect(onChangeMock).toHaveBeenCalledWith(['a']);
wrapper.setProps({
selectedTags: ['a'],
Object.assign(window, {
IntersectionObserver: jest.fn(() => ({
observe,
unobserve,
disconnect,
})),
});
jest.clearAllMocks();
loadRuleTags.mockResolvedValue({
data: tags,
page: 1,
perPage: 50,
total: 6,
});
wrapper.find('[data-test-subj="ruleTagFilterOption-a"]').at(0).simulate('click');
expect(onChangeMock).toHaveBeenCalledWith([]);
wrapper.find('[data-test-subj="ruleTagFilterOption-b"]').at(0).simulate('click');
expect(onChangeMock).toHaveBeenCalledWith(['a', 'b']);
});
it('renders selected tags even if they get deleted from the tags array', () => {
const selectedTags = ['g', 'h'];
const wrapper = mountWithIntl(
<RuleTagFilter tags={tags} selectedTags={selectedTags} onChange={onChangeMock} />
);
it('renders correctly', async () => {
renderWithProviders(<RuleTagFilter selectedTags={[]} onChange={onChangeMock} />);
expect(await screen.findByTestId('ruleTagFilterButton')).toBeInTheDocument();
expect(await screen.findByLabelText('0 available filters')).toBeInTheDocument();
});
wrapper.find(EuiFilterButton).simulate('click');
it('can open the popover correctly', async () => {
renderWithProviders(<RuleTagFilter selectedTags={[]} onChange={onChangeMock} />);
expect(screen.queryByTestId('ruleTagFilterSelectable')).not.toBeInTheDocument();
expect(wrapper.find(EuiSelectable).props().options.length).toEqual(
tags.length + selectedTags.length
);
// Open popover
fireEvent.click(await screen.findByTestId('ruleTagFilterButton'));
expect(await screen.findByTestId('ruleTagFilterSelectable')).toBeInTheDocument();
expect((await screen.findAllByRole('option')).length).toEqual(tags.length);
// Close popover
fireEvent.click(await screen.findByTestId('ruleTagFilterButton'));
await waitForElementToBeRemoved(() => screen.queryByTestId('ruleTagFilterSelectable'));
expect(screen.queryByTestId('ruleTagFilterSelectable')).not.toBeInTheDocument();
});
it('can select tags', async () => {
renderWithProviders(<RuleTagFilter selectedTags={[]} onChange={onChangeMock} />);
// Open popover
fireEvent.click(await screen.findByTestId('ruleTagFilterButton'));
fireEvent.click(await screen.findByTestId('ruleTagFilterOption-a'));
expect(onChangeMock).toHaveBeenLastCalledWith(['a']);
});
it('can unselect tags', async () => {
renderWithProviders(<RuleTagFilter selectedTags={['a']} onChange={onChangeMock} />);
// Open popover
fireEvent.click(await screen.findByTestId('ruleTagFilterButton'));
fireEvent.click(await screen.findByTestId('ruleTagFilterOption-a'));
expect(onChangeMock).toHaveBeenLastCalledWith([]);
});
it('renders selected tags even if they get deleted from the tags array', async () => {
renderWithProviders(<RuleTagFilter selectedTags={['g', 'h']} onChange={onChangeMock} />);
// Open popover
fireEvent.click(await screen.findByTestId('ruleTagFilterButton'));
expect((await screen.findAllByRole('option')).length).toEqual(tags.length + 2);
});
it('renders the tag filter with a EuiFilterGroup if isGrouped is false', async () => {
const wrapper = mountWithIntl(
<RuleTagFilter tags={tags} selectedTags={[]} onChange={onChangeMock} />
renderWithProviders(<RuleTagFilter selectedTags={[]} onChange={onChangeMock} />);
expect(await screen.findByTestId('ruleTagFilterUngrouped')).toBeInTheDocument();
});
it('renders the tag filter without EuiFilterGroup if isGrouped is true', async () => {
renderWithProviders(
<RuleTagFilter selectedTags={[]} onChange={onChangeMock} isGrouped={true} />
);
expect(wrapper.find(EuiFilterGroup).exists()).toBeTruthy();
wrapper.setProps({
isGrouped: true,
});
expect(wrapper.find(EuiFilterGroup).exists()).toBeFalsy();
expect(screen.queryByTestId('ruleTagFilterUngrouped')).not.toBeInTheDocument();
});
});

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import React, { useMemo, useState, useCallback } from 'react';
import React, { memo, useMemo, useState, useCallback, useEffect, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiSelectable,
@ -16,12 +17,13 @@ import {
EuiSelectableOption,
EuiSpacer,
} from '@elastic/eui';
import { useLoadTagsQuery } from '../../../hooks/use_load_tags_query';
export interface RuleTagFilterProps {
tags: string[];
selectedTags: string[];
isGrouped?: boolean; // Whether or not this should appear as the child of a EuiFilterGroup
isLoading?: boolean;
canLoadRules?: boolean;
refresh?: Date;
loadingMessage?: EuiSelectableProps['loadingMessage'];
noMatchesMessage?: EuiSelectableProps['noMatchesMessage'];
emptyMessage?: EuiSelectableProps['emptyMessage'];
@ -35,16 +37,128 @@ export interface RuleTagFilterProps {
const getOptionDataTestSubj = (tag: string) => `ruleTagFilterOption-${tag}`;
export const RuleTagFilter = (props: RuleTagFilterProps) => {
const {
tags = [],
selectedTags = [],
isGrouped = false,
isLoading = false,
const loadingText = i18n.translate('xpack.triggersActionsUI.sections.ruleTagFilter.loading', {
defaultMessage: 'Loading tags',
});
const EMPTY_TAGS: string[] = [];
const OptionWrapper = memo(
({
label,
setObserver,
canSetObserver,
}: {
label: string;
setObserver: (ref: HTMLDivElement) => void;
canSetObserver: boolean;
}) => {
const internalSetObserver = useCallback(
(ref: HTMLDivElement | null) => {
if (canSetObserver && ref) {
setObserver(ref);
}
},
[canSetObserver, setObserver]
);
return <div ref={internalSetObserver}>{label}</div>;
}
);
const RuleTagFilterPopoverButton = memo(
({
selectedTags,
onClosePopover,
buttonDataTestSubj,
}: {
selectedTags: string[];
onClosePopover: () => void;
buttonDataTestSubj?: string;
}) => {
return (
<EuiFilterButton
data-test-subj={buttonDataTestSubj}
iconType="arrowDown"
hasActiveFilters={selectedTags.length > 0}
numActiveFilters={selectedTags.length}
numFilters={selectedTags.length}
onClick={onClosePopover}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.ruleTagFilterButton"
defaultMessage="Tags"
/>
</EuiFilterButton>
);
}
);
const RuleTagFilterList = memo(
({
options,
renderOption,
onChange,
onSearchTextChange,
isLoading,
loadingMessage,
noMatchesMessage,
emptyMessage,
errorMessage,
selectableDataTestSubj,
}: {
options: EuiSelectableOption[];
onChange: (options: EuiSelectableOption[]) => void;
renderOption: EuiSelectableProps['renderOption'];
onSearchTextChange: (searchText: string) => void;
isLoading: boolean;
loadingMessage?: EuiSelectableProps['loadingMessage'];
noMatchesMessage?: EuiSelectableProps['noMatchesMessage'];
emptyMessage?: EuiSelectableProps['emptyMessage'];
errorMessage?: EuiSelectableProps['errorMessage'];
selectableDataTestSubj?: string;
}) => {
return (
<EuiSelectable
searchable
searchProps={{
onChange: onSearchTextChange,
}}
listProps={{
// We need to specify undefined here as the selectable list will
// sometimes scroll to the first item in the list when paginating
activeOptionIndex: undefined,
}}
data-test-subj={selectableDataTestSubj}
options={options}
noMatchesMessage={isLoading ? loadingMessage : noMatchesMessage}
emptyMessage={isLoading ? loadingMessage : emptyMessage}
errorMessage={errorMessage}
renderOption={renderOption}
onChange={onChange}
>
{(list, search) => (
<>
{search}
<EuiSpacer size="xs" />
{list}
</>
)}
</EuiSelectable>
);
}
);
export const RuleTagFilter = memo((props: RuleTagFilterProps) => {
const {
selectedTags = EMPTY_TAGS,
isGrouped = false,
canLoadRules = true,
refresh,
loadingMessage = loadingText,
noMatchesMessage,
emptyMessage,
errorMessage,
dataTestSubj = 'ruleTagFilter',
selectableDataTestSubj = 'ruleTagFilterSelectable',
optionDataTestSubj = getOptionDataTestSubj,
@ -52,13 +166,51 @@ export const RuleTagFilter = (props: RuleTagFilterProps) => {
onChange = () => {},
} = props;
const observerRef = useRef<IntersectionObserver>();
const [searchText, setSearchText] = useState<string>('');
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const allTags = useMemo(() => {
return [...new Set([...tags, ...selectedTags])].sort();
}, [tags, selectedTags]);
const {
tags = EMPTY_TAGS,
isLoading,
hasNextPage,
fetchNextPage,
} = useLoadTagsQuery({
enabled: canLoadRules,
refresh,
search: searchText,
});
const options = useMemo(
const fetchNext = useCallback(async () => {
if (hasNextPage && !isLoading) {
await fetchNextPage();
observerRef.current?.disconnect();
}
}, [fetchNextPage, hasNextPage, isLoading]);
useEffect(() => {
return () => observerRef.current?.disconnect();
}, []);
const allTags = useMemo(() => {
return [...new Set(selectedTags.sort().concat(tags))];
}, [selectedTags, tags]);
// Attaches an intersection observer to the last element
// to trigger a callback to paginate when the user scrolls to it
const setObserver = useCallback(
(ref: HTMLDivElement) => {
observerRef.current?.disconnect();
observerRef.current = new IntersectionObserver(fetchNext, {
root: null,
threshold: 1,
});
observerRef.current?.observe(ref);
},
[fetchNext]
);
const options: EuiSelectableOption[] = useMemo(
() =>
allTags.map((tag) => ({
label: tag,
@ -68,6 +220,19 @@ export const RuleTagFilter = (props: RuleTagFilterProps) => {
[allTags, selectedTags, optionDataTestSubj]
);
const renderOption = useCallback(
(option: EuiSelectableOption) => {
return (
<OptionWrapper
label={option.label}
setObserver={setObserver}
canSetObserver={option.label === allTags[allTags.length - 1]}
/>
);
},
[setObserver, allTags]
);
const onChangeInternal = useCallback(
(newOptions: EuiSelectableOption[]) => {
const newSelectedTags = newOptions.reduce<string[]>((result, option) => {
@ -82,27 +247,16 @@ export const RuleTagFilter = (props: RuleTagFilterProps) => {
[onChange]
);
const onClosePopover = () => {
setIsPopoverOpen(!isPopoverOpen);
};
const onSearchTextChange = useCallback(
(newSearchText: string) => {
setSearchText(newSearchText);
},
[setSearchText]
);
const renderButton = () => {
return (
<EuiFilterButton
data-test-subj={buttonDataTestSubj}
iconType="arrowDown"
hasActiveFilters={selectedTags.length > 0}
numActiveFilters={selectedTags.length}
numFilters={selectedTags.length}
onClick={onClosePopover}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.ruleTagFilterButton"
defaultMessage="Tags"
/>
</EuiFilterButton>
);
};
const onClosePopover = useCallback(() => {
setIsPopoverOpen((prevIsPopoverOpen) => !prevIsPopoverOpen);
}, [setIsPopoverOpen]);
const Container = useMemo(() => {
if (isGrouped) {
@ -112,36 +266,35 @@ export const RuleTagFilter = (props: RuleTagFilterProps) => {
}, [isGrouped]);
return (
<Container>
<Container {...(isGrouped ? {} : { 'data-test-subj': 'ruleTagFilterUngrouped' })}>
<EuiPopover
data-test-subj={dataTestSubj}
isOpen={isPopoverOpen}
closePopover={onClosePopover}
button={renderButton()}
button={
<RuleTagFilterPopoverButton
selectedTags={selectedTags}
onClosePopover={onClosePopover}
buttonDataTestSubj={buttonDataTestSubj}
/>
}
>
<EuiSelectable
searchable
data-test-subj={selectableDataTestSubj}
<RuleTagFilterList
isLoading={isLoading}
options={options}
renderOption={renderOption}
onChange={onChangeInternal}
onSearchTextChange={onSearchTextChange}
loadingMessage={loadingMessage}
noMatchesMessage={noMatchesMessage}
emptyMessage={emptyMessage}
errorMessage={errorMessage}
onChange={onChangeInternal}
>
{(list, search) => (
<>
{search}
<EuiSpacer size="xs" />
{list}
</>
)}
</EuiSelectable>
selectableDataTestSubj={selectableDataTestSubj}
/>
</EuiPopover>
</Container>
);
};
});
// eslint-disable-next-line import/no-default-export
export { RuleTagFilter as default };

View file

@ -164,7 +164,12 @@ describe('Update Api Key', () => {
addSuccess,
addError,
} as unknown as IToasts;
loadRuleTags.mockResolvedValue({});
loadRuleTags.mockResolvedValue({
data: [],
page: 1,
perPage: 50,
total: 0,
});
loadRuleAggregationsWithKueryFilter.mockResolvedValue({});
});
@ -208,7 +213,12 @@ describe('rules_list component empty', () => {
loadRuleTypes.mockResolvedValue([ruleTypeFromApi]);
loadAllActions.mockResolvedValue([]);
loadRuleAggregationsWithKueryFilter.mockResolvedValue({});
loadRuleTags.mockResolvedValue({});
loadRuleTags.mockResolvedValue({
data: ruleTags,
page: 1,
perPage: 50,
total: 4,
});
const actionTypeRegistry = actionTypeRegistryMock.create();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
@ -278,7 +288,10 @@ describe('rules_list ', () => {
},
});
loadRuleTags.mockResolvedValue({
ruleTags,
data: [],
page: 1,
perPage: 50,
total: 0,
});
const ruleTypeMock: RuleTypeModel = {
@ -857,37 +870,6 @@ describe('rules_list ', () => {
expect(screen.queryByTestId('ruleTagFilter')).toBeInTheDocument();
});
it('can filter by tags', async () => {
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => true);
renderWithProviders(<RulesList />);
await waitForElementToBeRemoved(() => screen.queryByTestId('centerJustifiedSpinner'));
expect(loadRulesWithKueryFilter).toHaveBeenLastCalledWith(
expect.objectContaining({
tagsFilter: [],
})
);
const ruleTagFilterButtonEl = screen.getByTestId('ruleTagFilterButton');
fireEvent.click(ruleTagFilterButtonEl);
fireEvent.click(await screen.findByTestId('ruleTagFilterOption-a'));
expect(loadRulesWithKueryFilter).toHaveBeenLastCalledWith(
expect.objectContaining({
tagsFilter: ['a'],
})
);
fireEvent.click(await screen.findByTestId('ruleTagFilterOption-b'));
expect(loadRulesWithKueryFilter).toHaveBeenLastCalledWith(
expect.objectContaining({
tagsFilter: ['a', 'b'],
})
);
});
it('rule list items with actions are editable if canExecuteAction is true', async () => {
renderWithProviders(<RulesList />);
await waitForElementToBeRemoved(() => screen.queryByTestId('centerJustifiedSpinner'));
@ -1097,7 +1079,10 @@ describe('rule list with different rule types', () => {
ruleTags,
});
loadRuleTags.mockResolvedValue({
ruleTags,
data: ruleTags,
page: 1,
perPage: 50,
total: 4,
});
const ruleTypeMock: RuleTypeModel = {
id: 'test_rule_type',
@ -1154,7 +1139,10 @@ describe('rules_list with show only capability', () => {
ruleTags,
});
loadRuleTags.mockResolvedValue({
ruleTags,
data: ruleTags,
page: 1,
perPage: 50,
total: 4,
});
});

View file

@ -88,7 +88,6 @@ import { useLoadActionTypesQuery } from '../../../hooks/use_load_action_types_qu
import { useLoadRuleAggregationsQuery } from '../../../hooks/use_load_rule_aggregations_query';
import { useLoadRuleTypesQuery } from '../../../hooks/use_load_rule_types_query';
import { useLoadRulesQuery } from '../../../hooks/use_load_rules_query';
import { useLoadTagsQuery } from '../../../hooks/use_load_tags_query';
import { useLoadConfigQuery } from '../../../hooks/use_load_config_query';
import {
@ -127,6 +126,7 @@ export interface RulesListProps {
onSearchFilterChange?: (search: string) => void;
onStatusFilterChange?: (status: RuleStatus[]) => void;
onTypeFilterChange?: (type: string[]) => void;
onRefresh?: (refresh: Date) => void;
setHeaderActions?: (components?: React.ReactNode[]) => void;
}
@ -165,6 +165,7 @@ export const RulesList = ({
onSearchFilterChange,
onStatusFilterChange,
onTypeFilterChange,
onRefresh,
setHeaderActions,
}: RulesListProps) => {
const history = useHistory();
@ -228,6 +229,8 @@ export const RulesList = ({
const [isCloningRule, setIsCloningRule] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [localRefresh, setLocalRefresh] = useState<Date>(new Date());
// Fetch config
const { config } = useLoadConfigQuery();
// Fetch rule types
@ -274,12 +277,6 @@ export const RulesList = ({
refresh,
});
// Fetch tags
const { tags, loadTags } = useLoadTagsQuery({
enabled: isRuleStatusFilterEnabled && canLoadRules,
refresh,
});
const { showSpinner, showRulesList, showNoAuthPrompt, showCreateFirstRulePrompt } = useUiState({
authorizedToCreateAnyRules,
filters,
@ -307,15 +304,16 @@ export const RulesList = ({
if (!ruleTypesState || !hasAnyAuthorizedRuleType) {
return;
}
const now = new Date();
setLocalRefresh(now);
onRefresh?.(now);
await loadRules();
await loadRuleAggregations();
if (isRuleStatusFilterEnabled) {
await loadTags();
}
}, [
loadRules,
loadTags,
loadRuleAggregations,
setLocalRefresh,
onRefresh,
isRuleStatusFilterEnabled,
hasAnyAuthorizedRuleType,
ruleTypesState,
@ -822,7 +820,8 @@ export const RulesList = ({
setInputText={setInputText}
showActionFilter={showActionFilter}
showErrors={showErrors}
tags={tags}
canLoadRules={canLoadRules}
refresh={refresh || localRefresh}
updateFilters={updateFilters}
onClearSelection={onClearSelection}
onRefreshRules={refreshRules}

View file

@ -39,7 +39,8 @@ interface RulesListFiltersBarProps {
rulesStatusesTotal: Record<string, number>;
showActionFilter: boolean;
showErrors: boolean;
tags: string[];
canLoadRules: boolean;
refresh?: Date;
onClearSelection: () => void;
onRefreshRules: () => void;
onToggleRuleErrors: () => void;
@ -63,7 +64,8 @@ export const RulesListFiltersBar = React.memo((props: RulesListFiltersBarProps)
setInputText,
showActionFilter = true,
showErrors,
tags,
canLoadRules,
refresh,
updateFilters,
} = props;
@ -76,7 +78,8 @@ export const RulesListFiltersBar = React.memo((props: RulesListFiltersBarProps)
return [
<RuleTagFilter
isGrouped
tags={tags}
refresh={refresh}
canLoadRules={canLoadRules}
selectedTags={filters.tags}
onChange={(value) => updateFilters({ filter: 'tags', value })}
/>,

View file

@ -6,9 +6,16 @@
*/
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RuleTagFilter } from '../application/sections';
import type { RuleTagFilterProps } from '../application/sections/rules_list/components/rule_tag_filter';
const queryClient = new QueryClient();
export const getRuleTagFilterLazy = (props: RuleTagFilterProps) => {
return <RuleTagFilter {...props} />;
return (
<QueryClientProvider client={queryClient}>
<RuleTagFilter {...props} />
</QueryClientProvider>
);
};

View file

@ -54,6 +54,8 @@ export default function createDeleteTests({ getService }: FtrProviderContext) {
.send(getTestRuleData())
.expect(200);
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
await supertest
.delete(`${getUrlPrefix(Spaces.other.id)}/api/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')

View file

@ -16,94 +16,178 @@ const tags = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
export default function createAggregateTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const createRule = async (overrides = {}) => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData(overrides))
.expect(200);
return createdRule.id;
};
describe('getRuleTags', () => {
const objectRemover = new ObjectRemover(supertest);
const createRule = async (overrides = {}) => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData(overrides))
.expect(200);
objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting');
return createdRule.id;
};
afterEach(() => objectRemover.removeAll());
it('should get rule tags when there are no rules', async () => {
const response = await supertest.get(
`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags`
);
const response = await supertest
.get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags`)
.expect(200);
expect(response.status).to.eql(200);
expect(response.body.rule_tags.filter((tag: string) => tag !== 'foo')).to.eql([]);
expect(response.body).to.eql({
data: [],
per_page: 50,
page: 1,
total: 0,
});
});
it('should get rule tags from all rules', async () => {
await Promise.all(
tags.map(async (tag) => {
const ruleId = await createRule({ tags: [tag] });
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
tags.map(async (tag, index) => {
await createRule({ tags: [tag, `${tag}_${index}`] });
})
);
const ruleId = await createRule({ tags: ['a', 'b', 'c'] });
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
await createRule({ tags: ['a', 'b', 'c', '1', '2'] });
const response = await supertest.get(
`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags`
);
const response = await supertest
.get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags`)
.expect(200);
expect(response.status).to.eql(200);
expect(response.body.rule_tags.filter((tag: string) => tag !== 'foo').sort()).to.eql(
tags.sort()
);
expect(response.body).to.eql({
data: [
'1',
'2',
'a',
'a_0',
'b',
'b_1',
'c',
'c_2',
'd',
'd_3',
'e',
'e_4',
'f',
'f_5',
'g',
'g_6',
'h',
'h_7',
'i',
'i_8',
'j',
'j_9',
],
per_page: 50,
page: 1,
total: 22,
});
});
it('should paginate rule tags', async () => {
await Promise.all(
tags.map(async (tag) => {
const ruleId = await createRule({ tags: [tag] });
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
})
);
await createRule({
tags: ['1', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '110'],
});
await createRule({
tags: ['2', '20', '21', '22', '23', '24', '25', '26', '1', '111', '1111'],
});
await createRule({
tags: ['3', '30', '31', '32', '33', '34', '35', '36', '37', '1', '111', '11_11'],
});
const ruleId = await createRule({ tags: ['foo'] });
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
let response = await supertest
.get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags?page=1&per_page=10`)
.expect(200);
const response = await supertest.get(
`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags?max_tags=5`
);
expect(response.body).to.eql({
data: ['1', '10', '11', '110', '111', '1111', '11_11', '12', '13', '14'],
per_page: 10,
page: 1,
total: 32,
});
expect(response.status).to.eql(200);
expect(response.body.rule_tags).to.eql(tags.sort().slice(0, 5));
response = await supertest
.get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags?page=2&per_page=10`)
.expect(200);
const paginatedResponse = await supertest.get(
`${getUrlPrefix(
Spaces.space1.id
)}/internal/alerting/rules/_tags?max_tags=5&after=${JSON.stringify({
tags: 'e',
})}`
);
expect(response.body).to.eql({
data: ['15', '16', '17', '18', '19', '2', '20', '21', '22', '23'],
per_page: 10,
page: 2,
total: 32,
});
expect(paginatedResponse.status).to.eql(200);
expect(paginatedResponse.body.rule_tags).to.eql(['f', 'foo', 'g', 'h', 'i']);
response = await supertest
.get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags?page=4&per_page=10`)
.expect(200);
expect(response.body).to.eql({
data: ['36', '37'],
per_page: 10,
page: 4,
total: 32,
});
});
it('should search rule tags', async () => {
await Promise.all(
tags.map(async (tag) => {
const ruleId = await createRule({ tags: [tag] });
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
})
);
it('should search and paginate rule tags', async () => {
await createRule({
tags: ['1', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '110'],
});
await createRule({
tags: ['2', '20', '21', '22', '23', '24', '25', '26', '1', '111', '1111'],
});
await createRule({
tags: ['3', '30', '31', '32', '33', '34', '35', '36', '37', '1', '11111', '11_11'],
});
const response = await supertest.get(
`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags?search=a`
);
let response = await supertest
.get(
`${getUrlPrefix(
Spaces.space1.id
)}/internal/alerting/rules/_tags?page=1&per_page=5&search=1`
)
.expect(200);
expect(response.body.rule_tags.filter((tag: string) => tag !== 'foo')).to.eql(['a']);
expect(response.body).to.eql({
data: ['1', '10', '11', '110', '111'],
per_page: 5,
page: 1,
total: 16,
});
response = await supertest
.get(
`${getUrlPrefix(
Spaces.space1.id
)}/internal/alerting/rules/_tags?page=2&per_page=5&search=1`
)
.expect(200);
expect(response.body).to.eql({
data: ['1111', '11111', '11_11', '12', '13'],
per_page: 5,
page: 2,
total: 16,
});
response = await supertest
.get(
`${getUrlPrefix(
Spaces.space1.id
)}/internal/alerting/rules/_tags?page=1&per_page=5&search=11`
)
.expect(200);
expect(response.body).to.eql({
data: ['11', '110', '111', '1111', '11111'],
per_page: 5,
page: 1,
total: 6,
});
});
});
}