mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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  ### 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:
parent
195216f0ec
commit
65b8a10027
22 changed files with 1259 additions and 452 deletions
|
@ -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,
|
||||
})}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
})
|
||||
)
|
||||
|
|
|
@ -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(),
|
||||
|
|
122
x-pack/plugins/alerting/server/rules_client/methods/get_tags.ts
Normal file
122
x-pack/plugins/alerting/server/rules_client/methods/get_tags.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 })}
|
||||
/>,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue