[8.x] [ResponseOps][Alerts] Move the alerts table to a dedicated package (#207878) (#210895)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[ResponseOps][Alerts] Move the alerts table to a dedicated package
(#207878)](https://github.com/elastic/kibana/pull/207878)

<!--- Backport version: 9.6.4 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Umberto
Pepato","email":"umbopepato@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-02-12T10:07:55Z","message":"[ResponseOps][Alerts]
Move the alerts table to a dedicated package (#207878)\n\n##
Summary\r\n\r\nThis PR turns the AlertsTable into a standalone
component, making it\r\nindependent from the `TriggersActionsUI`
plugin.\r\n\r\n#### Removes the alerts table registry\r\n\r\nAll
configuration is now managed through the AlertsTable component\r\nprops.
Shared configurations are handled by giving consumers the ability\r\nto
directly provide alerts table wrapper components (see for example
the\r\n`renderAlertsTable` prop of `getCases`).\r\n\r\n#### Moves the
alerts table to dedicated package(s)\r\n\r\nFollowing the feature-driven
structure we're introducing for ResponseOps\r\n(alerting) client-side
packages:\r\n- `@kbn/response-ops-alerts-table`\r\n-
`@kbn/response-ops-alerts-apis`\r\n-
`@kbn/response-ops-alerts-fields-browser`\r\n\r\n#### Initial work on
improving composition and organization\r\n\r\n- Reorganizes the table
code into a by-entity-type folder structure\r\n(`components/`, `hooks/`,
...)\r\n- Simplifies some components and breaks into smaller units when
possible\r\n\r\n## To verify\r\n\r\nFor consumers of the alerts
table:\r\n- Check that all your tables have the same behavior as before
(columns,\r\nsort, row actions, bulk actions, etc.)\r\n- Check that your
\"shared\" tables (i.e. cases alerts view in O11y and\r\nSecurity) have
the expected configuration and behavior\r\n\r\n> [!WARNING]\r\n> This PR
moves a lot of files. Git might not always recognize the\r\ncorrect
delete/add file pairs. If you see weird diffs feel free to reach\r\nout
for help!\r\n\r\n### Checklist\r\n\r\n-
[x]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [x] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n\r\n### Identify risks\r\n\r\n| Risk
| Description | Severity | Mitigation |\r\n|---|---|---|---|\r\n| Table
misconfigurations | Some table configurations might slightly\r\ndiffer
from the previous AlertsTableRegistry-backed version | Low |\r\nQuick
fix |\r\n\r\n## References\r\n\r\nCloses
#195180\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Christos Nasikas
<xristosnasikas@gmail.com>","sha":"a74066d6f83fc38feaa4d7e7b1cf7d3afd53c6f7","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:ResponseOps","backport:version","v9.1.0","v8.19.0"],"title":"[ResponseOps][Alerts]
Move the alerts table to a dedicated
package","number":207878,"url":"https://github.com/elastic/kibana/pull/207878","mergeCommit":{"message":"[ResponseOps][Alerts]
Move the alerts table to a dedicated package (#207878)\n\n##
Summary\r\n\r\nThis PR turns the AlertsTable into a standalone
component, making it\r\nindependent from the `TriggersActionsUI`
plugin.\r\n\r\n#### Removes the alerts table registry\r\n\r\nAll
configuration is now managed through the AlertsTable component\r\nprops.
Shared configurations are handled by giving consumers the ability\r\nto
directly provide alerts table wrapper components (see for example
the\r\n`renderAlertsTable` prop of `getCases`).\r\n\r\n#### Moves the
alerts table to dedicated package(s)\r\n\r\nFollowing the feature-driven
structure we're introducing for ResponseOps\r\n(alerting) client-side
packages:\r\n- `@kbn/response-ops-alerts-table`\r\n-
`@kbn/response-ops-alerts-apis`\r\n-
`@kbn/response-ops-alerts-fields-browser`\r\n\r\n#### Initial work on
improving composition and organization\r\n\r\n- Reorganizes the table
code into a by-entity-type folder structure\r\n(`components/`, `hooks/`,
...)\r\n- Simplifies some components and breaks into smaller units when
possible\r\n\r\n## To verify\r\n\r\nFor consumers of the alerts
table:\r\n- Check that all your tables have the same behavior as before
(columns,\r\nsort, row actions, bulk actions, etc.)\r\n- Check that your
\"shared\" tables (i.e. cases alerts view in O11y and\r\nSecurity) have
the expected configuration and behavior\r\n\r\n> [!WARNING]\r\n> This PR
moves a lot of files. Git might not always recognize the\r\ncorrect
delete/add file pairs. If you see weird diffs feel free to reach\r\nout
for help!\r\n\r\n### Checklist\r\n\r\n-
[x]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [x] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n\r\n### Identify risks\r\n\r\n| Risk
| Description | Severity | Mitigation |\r\n|---|---|---|---|\r\n| Table
misconfigurations | Some table configurations might slightly\r\ndiffer
from the previous AlertsTableRegistry-backed version | Low |\r\nQuick
fix |\r\n\r\n## References\r\n\r\nCloses
#195180\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Christos Nasikas
<xristosnasikas@gmail.com>","sha":"a74066d6f83fc38feaa4d7e7b1cf7d3afd53c6f7"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/207878","number":207878,"mergeCommit":{"message":"[ResponseOps][Alerts]
Move the alerts table to a dedicated package (#207878)\n\n##
Summary\r\n\r\nThis PR turns the AlertsTable into a standalone
component, making it\r\nindependent from the `TriggersActionsUI`
plugin.\r\n\r\n#### Removes the alerts table registry\r\n\r\nAll
configuration is now managed through the AlertsTable component\r\nprops.
Shared configurations are handled by giving consumers the ability\r\nto
directly provide alerts table wrapper components (see for example
the\r\n`renderAlertsTable` prop of `getCases`).\r\n\r\n#### Moves the
alerts table to dedicated package(s)\r\n\r\nFollowing the feature-driven
structure we're introducing for ResponseOps\r\n(alerting) client-side
packages:\r\n- `@kbn/response-ops-alerts-table`\r\n-
`@kbn/response-ops-alerts-apis`\r\n-
`@kbn/response-ops-alerts-fields-browser`\r\n\r\n#### Initial work on
improving composition and organization\r\n\r\n- Reorganizes the table
code into a by-entity-type folder structure\r\n(`components/`, `hooks/`,
...)\r\n- Simplifies some components and breaks into smaller units when
possible\r\n\r\n## To verify\r\n\r\nFor consumers of the alerts
table:\r\n- Check that all your tables have the same behavior as before
(columns,\r\nsort, row actions, bulk actions, etc.)\r\n- Check that your
\"shared\" tables (i.e. cases alerts view in O11y and\r\nSecurity) have
the expected configuration and behavior\r\n\r\n> [!WARNING]\r\n> This PR
moves a lot of files. Git might not always recognize the\r\ncorrect
delete/add file pairs. If you see weird diffs feel free to reach\r\nout
for help!\r\n\r\n### Checklist\r\n\r\n-
[x]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [x] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [ ] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n\r\n### Identify risks\r\n\r\n| Risk
| Description | Severity | Mitigation |\r\n|---|---|---|---|\r\n| Table
misconfigurations | Some table configurations might slightly\r\ndiffer
from the previous AlertsTableRegistry-backed version | Low |\r\nQuick
fix |\r\n\r\n## References\r\n\r\nCloses
#195180\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Christos Nasikas
<xristosnasikas@gmail.com>","sha":"a74066d6f83fc38feaa4d7e7b1cf7d3afd53c6f7"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Umberto Pepato 2025-02-13 16:12:15 +01:00 committed by GitHub
parent 0a314bdab3
commit f1d15570a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
424 changed files with 12052 additions and 11960 deletions

3
.github/CODEOWNERS vendored
View file

@ -758,6 +758,9 @@ src/platform/packages/private/kbn-reporting/server @elastic/appex-sharedux
src/platform/packages/shared/kbn-resizable-layout @elastic/kibana-data-discovery
examples/resizable_layout_examples @elastic/kibana-data-discovery
x-pack/test/plugin_functional/plugins/resolver_test @elastic/security-solution
packages/response-ops/alerts_apis @elastic/response-ops
packages/response-ops/alerts_fields_browser @elastic/response-ops
packages/response-ops/alerts_table @elastic/response-ops
src/platform/packages/shared/response-ops/rule_form @elastic/response-ops
src/platform/packages/shared/response-ops/rule_params @elastic/response-ops
examples/response_stream @elastic/ml-ui

View file

@ -763,6 +763,9 @@
"@kbn/resizable-layout": "link:src/platform/packages/shared/kbn-resizable-layout",
"@kbn/resizable-layout-examples-plugin": "link:examples/resizable_layout_examples",
"@kbn/resolver-test-plugin": "link:x-pack/test/plugin_functional/plugins/resolver_test",
"@kbn/response-ops-alerts-apis": "link:packages/response-ops/alerts_apis",
"@kbn/response-ops-alerts-fields-browser": "link:packages/response-ops/alerts_fields_browser",
"@kbn/response-ops-alerts-table": "link:packages/response-ops/alerts_table",
"@kbn/response-ops-rule-form": "link:src/platform/packages/shared/response-ops/rule_form",
"@kbn/response-ops-rule-params": "link:src/platform/packages/shared/response-ops/rule_params",
"@kbn/response-stream-plugin": "link:examples/response_stream",

View file

@ -0,0 +1,3 @@
# @kbn/response-ops-alerts-apis
Client-side Alerts HTTP API fetchers and React Query wrappers.

View file

@ -1,15 +1,18 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { getMutedAlerts } from './get_rules_muted_alerts';
import { getMutedAlertsInstancesByRule } from './get_muted_alerts_instances_by_rule';
const http = httpServiceMock.createStartContract();
describe('getMutedAlerts', () => {
describe('getMutedAlertsInstancesByRule', () => {
const apiRes = {
page: 1,
per_page: 10,
@ -24,7 +27,7 @@ describe('getMutedAlerts', () => {
});
test('should call find API with correct params', async () => {
const result = await getMutedAlerts(http, { ruleIds: ['foo'] });
const result = await getMutedAlertsInstancesByRule({ http, ruleIds: ['foo'] });
expect(result).toEqual({
page: 1,
@ -45,7 +48,7 @@ describe('getMutedAlerts', () => {
});
test('should call find API with multiple ruleIds', async () => {
const result = await getMutedAlerts(http, { ruleIds: ['foo', 'bar'] });
const result = await getMutedAlertsInstancesByRule({ http, ruleIds: ['foo', 'bar'] });
expect(result).toEqual({
page: 1,

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { HttpStart } from '@kbn/core-http-browser';
import { nodeBuilder } from '@kbn/es-query';
const INTERNAL_FIND_RULES_URL = '/internal/alerting/rules/_find';
export interface Rule {
id: string;
muted_alert_ids: string[];
}
export interface FindRulesResponse {
data: Rule[];
}
export interface GetMutedAlertsInstancesByRuleParams {
ruleIds: string[];
http: HttpStart;
signal?: AbortSignal;
}
export const getMutedAlertsInstancesByRule = async ({
http,
ruleIds,
signal,
}: GetMutedAlertsInstancesByRuleParams) => {
const filterNode = nodeBuilder.or(ruleIds.map((id) => nodeBuilder.is('alert.id', `alert:${id}`)));
return http.post<FindRulesResponse>(INTERNAL_FIND_RULES_URL, {
body: JSON.stringify({
filter: JSON.stringify(filterNode),
fields: ['id', 'mutedInstanceIds'],
page: 1,
per_page: ruleIds.length,
}),
signal,
});
};

View file

@ -1,12 +1,14 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { muteAlertInstance } from './mute_alert';
import { muteAlertInstance } from './mute_alert_instance';
const http = httpServiceMock.createStartContract();

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { HttpSetup } from '@kbn/core/public';
import { BASE_ALERTING_API_PATH } from '../constants';
export interface MuteAlertInstanceParams {
id: string;
instanceId: string;
http: HttpSetup;
}
export const muteAlertInstance = ({ id, instanceId, http }: MuteAlertInstanceParams) => {
return http.post<void>(
`${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/alert/${encodeURIComponent(
instanceId
)}/_mute`
);
};

View file

@ -1,12 +1,14 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { unmuteAlertInstance } from './unmute_alert';
import { unmuteAlertInstance } from './unmute_alert_instance';
const http = httpServiceMock.createStartContract();

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { HttpSetup } from '@kbn/core/public';
import { BASE_ALERTING_API_PATH } from '../constants';
export interface UnmuteAlertInstanceParams {
id: string;
instanceId: string;
http: HttpSetup;
}
export const unmuteAlertInstance = ({ id, instanceId, http }: UnmuteAlertInstanceParams) => {
return http.post<void>(
`${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/alert/${encodeURIComponent(
instanceId
)}/_unmute`
);
};

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const BASE_ALERTING_API_PATH = '/api/alerting';
export const queryKeys = {
root: 'alerts',
mutedAlerts: (ruleIds: string[]) =>
[queryKeys.root, 'mutedInstanceIdsForRuleIds', ruleIds] as const,
};
export const mutationKeys = {
root: 'alerts',
muteAlertInstance: () => [mutationKeys.root, 'muteAlertInstance'] as const,
unmuteAlertInstance: () => [mutationKeys.root, 'unmuteAlertInstance'] as const,
};

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { renderHook, waitFor } from '@testing-library/react';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { Wrapper } from '@kbn/alerts-ui-shared/src/common/test_utils/wrapper';
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import * as api from '../apis/get_muted_alerts_instances_by_rule';
import { useGetMutedAlertsQuery } from './use_get_muted_alerts_query';
jest.mock('../apis/get_muted_alerts_instances_by_rule');
const ruleIds = ['a', 'b'];
describe('useGetMutedAlertsQuery', () => {
const http = httpServiceMock.createStartContract();
const notifications = notificationServiceMock.createStartContract();
const addErrorMock = notifications.toasts.addError;
beforeEach(() => {
jest.clearAllMocks();
});
it('calls the api when invoked with the correct parameters', async () => {
const muteAlertInstanceSpy = jest.spyOn(api, 'getMutedAlertsInstancesByRule');
renderHook(() => useGetMutedAlertsQuery({ http, notifications, ruleIds }), {
wrapper: Wrapper,
});
await waitFor(() =>
expect(muteAlertInstanceSpy).toHaveBeenCalledWith(expect.objectContaining({ ruleIds }))
);
});
it('does not call the api if the enabled option is false', async () => {
const spy = jest.spyOn(api, 'getMutedAlertsInstancesByRule');
renderHook(() => useGetMutedAlertsQuery({ http, notifications, ruleIds }, { enabled: false }), {
wrapper: Wrapper,
});
await waitFor(() => expect(spy).not.toHaveBeenCalled());
});
it('shows a toast error when the api returns an error', async () => {
const spy = jest
.spyOn(api, 'getMutedAlertsInstancesByRule')
.mockRejectedValue(new Error('An error'));
renderHook(() => useGetMutedAlertsQuery({ http, notifications, ruleIds }), {
wrapper: Wrapper,
});
await waitFor(() => expect(spy).toHaveBeenCalled());
await waitFor(() => expect(addErrorMock).toHaveBeenCalled());
});
});

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import { useQuery } from '@tanstack/react-query';
import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
import { QueryOptionsOverrides } from '@kbn/alerts-ui-shared/src/common/types/tanstack_query_utility_types';
import type { HttpStart } from '@kbn/core-http-browser';
import type { NotificationsStart } from '@kbn/core-notifications-browser';
import { queryKeys } from '../constants';
import { MutedAlerts, ServerError } from '../types';
import {
getMutedAlertsInstancesByRule,
GetMutedAlertsInstancesByRuleParams,
} from '../apis/get_muted_alerts_instances_by_rule';
const ERROR_TITLE = i18n.translate('xpack.responseOpsAlertsApis.mutedAlerts.api.get', {
defaultMessage: 'Error fetching muted alerts data',
});
const getMutedAlerts = ({ http, signal, ruleIds }: GetMutedAlertsInstancesByRuleParams) =>
getMutedAlertsInstancesByRule({ http, ruleIds, signal }).then(({ data: rules }) =>
rules?.reduce((mutedAlerts, rule) => {
mutedAlerts[rule.id] = rule.muted_alert_ids;
return mutedAlerts;
}, {} as MutedAlerts)
);
export interface UseGetMutedAlertsQueryParams {
ruleIds: string[];
http: HttpStart;
notifications: NotificationsStart;
}
export const useGetMutedAlertsQuery = (
{ ruleIds, http, notifications: { toasts } }: UseGetMutedAlertsQueryParams,
{ enabled }: QueryOptionsOverrides<typeof getMutedAlerts> = {}
) => {
return useQuery({
context: AlertsQueryContext,
queryKey: queryKeys.mutedAlerts(ruleIds),
queryFn: ({ signal }) => getMutedAlerts({ http, signal, ruleIds }),
onError: (error: ServerError) => {
if (error.name !== 'AbortError') {
toasts.addError(error.body?.message ? new Error(error.body.message) : error, {
title: ERROR_TITLE,
});
}
},
enabled: ruleIds.length > 0 && enabled !== false,
});
};

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { renderHook, waitFor } from '@testing-library/react';
import { Wrapper } from '@kbn/alerts-ui-shared/src/common/test_utils/wrapper';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import * as api from '../apis/mute_alert_instance';
import { useMuteAlertInstance } from './use_mute_alert_instance';
jest.mock('../apis/mute_alert_instance');
const params = { ruleId: '', alertInstanceId: '' };
describe('useMuteAlertInstance', () => {
const http = httpServiceMock.createStartContract();
const notifications = notificationServiceMock.createStartContract();
const addErrorMock = notifications.toasts.addError;
beforeEach(() => {
jest.clearAllMocks();
});
it('calls the api when invoked with the correct parameters', async () => {
const muteAlertInstanceSpy = jest.spyOn(api, 'muteAlertInstance');
const { result } = renderHook(() => useMuteAlertInstance({ http, notifications }), {
wrapper: Wrapper,
});
result.current.mutate(params);
await waitFor(() => {
expect(muteAlertInstanceSpy).toHaveBeenCalledWith({
id: params.ruleId,
instanceId: params.alertInstanceId,
http: expect.anything(),
});
});
});
it('shows a toast error when the api returns an error', async () => {
const spy = jest.spyOn(api, 'muteAlertInstance').mockRejectedValue(new Error('An error'));
const { result } = renderHook(() => useMuteAlertInstance({ http, notifications }), {
wrapper: Wrapper,
});
result.current.mutate(params);
await waitFor(() => {
expect(spy).toHaveBeenCalled();
expect(addErrorMock).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useMutation } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
import type { HttpStart } from '@kbn/core-http-browser';
import type { NotificationsStart } from '@kbn/core-notifications-browser';
import { mutationKeys } from '../constants';
import type { ServerError, ToggleAlertParams } from '../types';
import { muteAlertInstance } from '../apis/mute_alert_instance';
const ERROR_TITLE = i18n.translate('alertsApis.muteAlert.error', {
defaultMessage: 'Error muting alert',
});
export interface UseMuteAlertInstanceParams {
http: HttpStart;
notifications: NotificationsStart;
}
export const useMuteAlertInstance = ({
http,
notifications: { toasts },
}: UseMuteAlertInstanceParams) => {
return useMutation(
({ ruleId, alertInstanceId }: ToggleAlertParams) =>
muteAlertInstance({ http, id: ruleId, instanceId: alertInstanceId }),
{
mutationKey: mutationKeys.muteAlertInstance(),
context: AlertsQueryContext,
onSuccess() {
toasts.addSuccess(
i18n.translate('xpack.responseOpsAlertsApis.alertsTable.alertMuted', {
defaultMessage: 'Alert muted',
})
);
},
onError: (error: ServerError) => {
if (error.name !== 'AbortError') {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{
title: ERROR_TITLE,
}
);
}
},
}
);
};

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { renderHook, waitFor } from '@testing-library/react';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { Wrapper } from '@kbn/alerts-ui-shared/src/common/test_utils/wrapper';
import { useUnmuteAlertInstance } from './use_unmute_alert_instance';
import * as api from '../apis/unmute_alert_instance';
jest.mock('../apis/unmute_alert_instance');
const params = { ruleId: '', alertInstanceId: '' };
describe('useUnmuteAlertInstance', () => {
const http = httpServiceMock.createStartContract();
const notifications = notificationServiceMock.createStartContract();
const addErrorMock = notifications.toasts.addError;
beforeEach(() => {
jest.clearAllMocks();
});
it('calls the api when invoked with the correct parameters', async () => {
const muteAlertInstanceSpy = jest.spyOn(api, 'unmuteAlertInstance');
const { result } = renderHook(() => useUnmuteAlertInstance({ http, notifications }), {
wrapper: Wrapper,
});
result.current.mutate(params);
await waitFor(() => {
expect(muteAlertInstanceSpy).toHaveBeenCalledWith({
id: params.ruleId,
instanceId: params.alertInstanceId,
http: expect.anything(),
});
});
});
it('shows a toast error when the api returns an error', async () => {
const spy = jest.spyOn(api, 'unmuteAlertInstance').mockRejectedValue(new Error('An error'));
const { result } = renderHook(() => useUnmuteAlertInstance({ http, notifications }), {
wrapper: Wrapper,
});
result.current.mutate(params);
await waitFor(() => {
expect(spy).toHaveBeenCalled();
expect(addErrorMock).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useMutation } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
import type { HttpStart } from '@kbn/core-http-browser';
import type { NotificationsStart } from '@kbn/core-notifications-browser';
import { mutationKeys } from '../constants';
import type { ServerError, ToggleAlertParams } from '../types';
import { unmuteAlertInstance } from '../apis/unmute_alert_instance';
const ERROR_TITLE = i18n.translate('alertsApis.unmuteAlert.error', {
defaultMessage: 'Error unmuting alert',
});
export interface UseUnmuteAlertInstanceParams {
http: HttpStart;
notifications: NotificationsStart;
}
export const useUnmuteAlertInstance = ({
http,
notifications: { toasts },
}: UseUnmuteAlertInstanceParams) => {
return useMutation(
({ ruleId, alertInstanceId }: ToggleAlertParams) =>
unmuteAlertInstance({ http, id: ruleId, instanceId: alertInstanceId }),
{
mutationKey: mutationKeys.unmuteAlertInstance(),
context: AlertsQueryContext,
onSuccess() {
toasts.addSuccess(
i18n.translate('xpack.responseOpsAlertsApis.alertsTable.alertUnmuted', {
defaultMessage: 'Alert unmuted',
})
);
},
onError: (error: ServerError) => {
if (error.name !== 'AbortError') {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{
title: ERROR_TITLE,
}
);
}
},
}
);
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/response-ops/alerts_apis'],
setupFilesAfterEnv: ['<rootDir>/packages/response-ops/alerts_apis/setup_tests.ts'],
};

View file

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

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/response-ops-alerts-apis",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
/* eslint-disable import/no-extraneous-dependencies */
import '@testing-library/jest-dom';
import 'jest-styled-components';

View file

@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/core-http-browser",
"@kbn/i18n",
"@kbn/alerts-ui-shared",
"@kbn/core-notifications-browser",
"@kbn/core-http-browser-mocks",
"@kbn/core-notifications-browser-mocks",
"@kbn/es-query"
]
}

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser';
export type ServerError = IHttpFetchError<ResponseErrorBody>;
export interface ToggleAlertParams {
ruleId: string;
alertInstanceId: string;
}
/**
* Map from rule ids to muted alert instance ids
*/
export type MutedAlerts = Record<string, string[]>;

View file

@ -0,0 +1,3 @@
# @kbn/response-ops-alerts-fields-browser
A picker component for alert document fields.

View file

@ -7,15 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { TechnicalRuleDataFieldName } from '@kbn/rule-data-utils';
import { css } from '@emotion/react';
import { UseEuiTheme } from '@elastic/eui';
export interface BasicFields {
_id: string;
_index: string;
}
export type Alert = BasicFields & {
[Property in TechnicalRuleDataFieldName]?: string[];
} & {
[x: string]: unknown[];
export const styles = {
badgesGroup: ({ euiTheme }: { euiTheme: UseEuiTheme['euiTheme'] }) => css`
margin-top: ${euiTheme.size.xs};
min-height: 24px;
`,
};

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { render } from '@testing-library/react';

View file

@ -1,9 +1,12 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback } from 'react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import { styles } from './categories_badges.styles';

View file

@ -7,11 +7,5 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export interface LegacyField {
field: string;
value: string[];
}
export interface EsQuerySnapshot {
request: string[];
response: string[];
}
export { CategoriesBadges } from './categories_badges';
export type { CategoriesBadgesProps } from './categories_badges';

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { css } from '@emotion/react';
export const styles = {
countBadge: css`
margin-left: 5px;
`,
categoryName: ({ bold }: { bold: boolean }) => css`
font-weight: ${bold ? 'bold' : 'normal'};
`,
selectableContainer: css`
width: 300px;
`,
};

View file

@ -1,9 +1,12 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { render } from '@testing-library/react';
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';

View file

@ -1,9 +1,12 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, useMemo, useState } from 'react';
import { omit } from 'lodash';
import {
@ -17,7 +20,7 @@ import {
EuiSelectable,
FilterChecked,
} from '@elastic/eui';
import { BrowserFields } from '@kbn/rule-registry-plugin/common';
import type { BrowserFields } from '@kbn/rule-registry-plugin/common';
import * as i18n from '../../translations';
import { getFieldCount, isEscape } from '../../helpers';
import { styles } from './categories_selector.styles';

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { CategoriesSelector } from './categories_selector';

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { css } from '@emotion/react';
export const styles = {
buttonContainer: css`
display: inline-block;
position: relative;
`,
};

View file

@ -1,17 +1,19 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
import { mockBrowserFields } from './mock';
import { FIELD_BROWSER_WIDTH } from './helpers';
import { mockBrowserFields } from '../../mock';
import { FIELD_BROWSER_WIDTH } from '../../helpers';
import { FieldBrowserComponent } from './field_browser';
import type { FieldBrowserProps } from './types';
import type { FieldBrowserProps } from '../../types';
const defaultProps: FieldBrowserProps = {
browserFields: mockBrowserFields,

View file

@ -1,18 +1,21 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import { debounce } from 'lodash';
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { BrowserFields } from '@kbn/rule-registry-plugin/common';
import type { FieldBrowserProps } from './types';
import { FieldBrowserModal } from './field_browser_modal';
import { filterBrowserFieldsByFieldName, filterSelectedBrowserFields } from './helpers';
import * as i18n from './translations';
import type { BrowserFields } from '@kbn/rule-registry-plugin/common';
import type { FieldBrowserProps } from '../../types';
import { FieldBrowserModal } from '../field_browser_modal/field_browser_modal';
import { filterBrowserFieldsByFieldName, filterSelectedBrowserFields } from '../../helpers';
import * as i18n from '../../translations';
import { styles } from './field_browser.styles';
const FIELDS_BUTTON_CLASS_NAME = 'fields-button';

View file

@ -1,14 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { mount } from 'enzyme';
import React from 'react';
import { mockBrowserFields } from './mock';
import { mockBrowserFields } from '../../mock';
import { FieldBrowserModal, FieldBrowserModalProps } from './field_browser_modal';
const mockOnHide = jest.fn();

View file

@ -1,9 +1,12 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import {
EuiFlexGroup,
EuiFlexItem,
@ -18,16 +21,20 @@ import {
} from '@elastic/eui';
import React, { useCallback } from 'react';
import { BrowserFields } from '@kbn/rule-registry-plugin/common';
import type { FieldBrowserProps } from './types';
import { Search } from './components/search';
import type { BrowserFields } from '@kbn/rule-registry-plugin/common';
import type { FieldBrowserProps } from '../../types';
import { Search } from '../search';
import { CLOSE_BUTTON_CLASS_NAME, FIELD_BROWSER_WIDTH, RESET_FIELDS_CLASS_NAME } from './helpers';
import {
CLOSE_BUTTON_CLASS_NAME,
FIELD_BROWSER_WIDTH,
RESET_FIELDS_CLASS_NAME,
} from '../../helpers';
import * as i18n from './translations';
import { CategoriesSelector } from './components/categories_selector';
import { CategoriesBadges } from './components/categories_badges';
import { FieldTable } from './components/field_table';
import * as i18n from '../../translations';
import { CategoriesSelector } from '../categories_selector';
import { CategoriesBadges } from '../categories_badges';
import { FieldTable } from '../field_table';
export type FieldBrowserModalProps = Pick<
FieldBrowserProps,

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { css } from '@emotion/react';

View file

@ -1,9 +1,12 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { omit } from 'lodash/fp';
import { render } from '@testing-library/react';

View file

@ -1,9 +1,12 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import {
EuiCheckbox,
@ -15,7 +18,7 @@ import {
EuiScreenReaderOnly,
} from '@elastic/eui';
import { uniqBy } from 'lodash/fp';
import { BrowserFields } from '@kbn/rule-registry-plugin/common';
import type { BrowserFields } from '@kbn/rule-registry-plugin/common';
import { EcsFlat } from '@elastic/ecs';
import { EcsMetadata } from '@kbn/alerts-as-data-utils/src/field_maps/types';

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { getFieldItemsData, getFieldColumns } from './field_items';

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { mount } from 'enzyme';

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { FieldName } from './field_name';

View file

@ -1,9 +1,12 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { UseEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';

View file

@ -1,9 +1,12 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiInMemoryTable,
@ -12,7 +15,7 @@ import {
useEuiTheme,
CriteriaWithPagination,
} from '@elastic/eui';
import { BrowserFields } from '@kbn/rule-registry-plugin/common';
import type { BrowserFields } from '@kbn/rule-registry-plugin/common';
import { getFieldColumns, getFieldItemsData } from '../field_items';
import { CATEGORY_TABLE_CLASS_NAME, TABLE_HEIGHT } from '../../helpers';
import type { BrowserFieldItem, FieldBrowserProps, GetFieldTableColumns } from '../../types';

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { css } from '@emotion/react';
export const styles = {
count: css`
font-weight: bold;
`,
};

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';

View file

@ -1,9 +1,12 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, useState } from 'react';
import {
EuiText,

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { FieldTable } from './field_table';
export type { FieldTableProps } from './field_table';

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { Search } from './search';

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { mount } from 'enzyme';

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { mockBrowserFields } from './mock';
@ -15,7 +17,7 @@ import {
filterBrowserFieldsByFieldName,
filterSelectedBrowserFields,
} from './helpers';
import { BrowserFields } from '@kbn/rule-registry-plugin/common';
import type { BrowserFields } from '@kbn/rule-registry-plugin/common';
import { EcsFlat } from '@elastic/ecs';
describe('helpers', () => {

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EcsMetadata } from '@kbn/alerts-as-data-utils/src/field_maps/types';
@ -11,9 +13,9 @@ import {
ALERT_MAINTENANCE_WINDOW_IDS,
DefaultAlertFieldName,
} from '@kbn/rule-data-utils';
import { BrowserField, BrowserFields } from '@kbn/rule-registry-plugin/common';
import type { BrowserField, BrowserFields } from '@kbn/rule-registry-plugin/common';
import { isEmpty } from 'lodash/fp';
import { CASES, MAINTENANCE_WINDOWS } from '../translations';
import { CASES, MAINTENANCE_WINDOWS } from './translations';
export const FIELD_BROWSER_WIDTH = 925;
export const TABLE_HEIGHT = 260;

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { FieldBrowser } from './components/field_browser/field_browser';
export type { FieldBrowserProps, FieldBrowserOptions } from './types';
export { FieldBrowser };

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/response-ops/alerts_fields_browser'],
setupFilesAfterEnv: ['<rootDir>/packages/response-ops/alerts_fields_browser/setup_tests.ts'],
};

View file

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

View file

@ -1,12 +1,14 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { BrowserFields } from '@kbn/rule-registry-plugin/common';
import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import type { BrowserFields } from '@kbn/rule-registry-plugin/common';
const DEFAULT_INDEX_PATTERN = [
'apm-*-transaction*',

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/response-ops-alerts-fields-browser",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';

View file

@ -0,0 +1,118 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
export const CASES = i18n.translate('responseOpsAlertsFieldsBrowser.cases.label', {
defaultMessage: 'Cases',
});
export const MAINTENANCE_WINDOWS = i18n.translate(
'responseOpsAlertsFieldsBrowser.maintenanceWindows.label',
{
defaultMessage: 'Maintenance Windows',
}
);
export const CATEGORY = i18n.translate('responseOpsAlertsFieldsBrowser.categoryLabel', {
defaultMessage: 'Category',
});
export const CATEGORIES = i18n.translate('responseOpsAlertsFieldsBrowser.categoriesTitle', {
defaultMessage: 'Categories',
});
export const CATEGORIES_COUNT = (totalCount: number) =>
i18n.translate('responseOpsAlertsFieldsBrowser.categoriesCountTitle', {
values: { totalCount },
defaultMessage: '{totalCount} {totalCount, plural, =1 {category} other {categories}}',
});
export const CLOSE = i18n.translate('responseOpsAlertsFieldsBrowser.closeButton', {
defaultMessage: 'Close',
});
export const FIELDS_BROWSER = i18n.translate('responseOpsAlertsFieldsBrowser.fieldBrowserTitle', {
defaultMessage: 'Fields',
});
export const DESCRIPTION = i18n.translate('responseOpsAlertsFieldsBrowser.descriptionLabel', {
defaultMessage: 'Description',
});
export const DESCRIPTION_FOR_FIELD = (field: string) =>
i18n.translate('responseOpsAlertsFieldsBrowser.descriptionForScreenReaderOnly', {
values: {
field,
},
defaultMessage: 'Description for field {field}:',
});
export const NAME = i18n.translate('responseOpsAlertsFieldsBrowser.fieldName', {
defaultMessage: 'Name',
});
export const FIELD = i18n.translate('responseOpsAlertsFieldsBrowser.fieldLabel', {
defaultMessage: 'Field',
});
export const FIELDS = i18n.translate('responseOpsAlertsFieldsBrowser.fieldsTitle', {
defaultMessage: 'Fields',
});
export const FIELDS_SHOWING = i18n.translate('responseOpsAlertsFieldsBrowser.fieldsCountShowing', {
defaultMessage: 'Showing',
});
export const FIELDS_COUNT = (totalCount: number) =>
i18n.translate('responseOpsAlertsFieldsBrowser.fieldsCountTitle', {
values: { totalCount },
defaultMessage: '{totalCount, plural, =1 {field} other {fields}}',
});
export const FILTER_PLACEHOLDER = i18n.translate(
'responseOpsAlertsFieldsBrowser.filterPlaceholder',
{
defaultMessage: 'Field name',
}
);
export const NO_FIELDS_MATCH = i18n.translate('responseOpsAlertsFieldsBrowser.noFieldsMatchLabel', {
defaultMessage: 'No fields match',
});
export const NO_FIELDS_MATCH_INPUT = (searchInput: string) =>
i18n.translate('responseOpsAlertsFieldsBrowser.noFieldsMatchInputLabel', {
defaultMessage: 'No fields match {searchInput}',
values: {
searchInput,
},
});
export const RESET_FIELDS = i18n.translate('responseOpsAlertsFieldsBrowser.resetFieldsLink', {
defaultMessage: 'Reset Fields',
});
export const VIEW_COLUMN = (field: string) =>
i18n.translate('responseOpsAlertsFieldsBrowser.viewColumnCheckboxAriaLabel', {
values: { field },
defaultMessage: 'View {field} column',
});
export const VIEW_LABEL = i18n.translate('responseOpsAlertsFieldsBrowser.viewLabel', {
defaultMessage: 'View',
});
export const VIEW_VALUE_SELECTED = i18n.translate('responseOpsAlertsFieldsBrowser.viewSelected', {
defaultMessage: 'selected',
});
export const VIEW_VALUE_ALL = i18n.translate('responseOpsAlertsFieldsBrowser.viewAll', {
defaultMessage: 'all',
});

View file

@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"@emotion/react/types/css-prop"
]
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/i18n",
"@kbn/rule-registry-plugin",
"@kbn/alerts-as-data-utils",
"@kbn/rule-data-utils",
]
}

View file

@ -1,12 +1,14 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { EuiBasicTableColumn } from '@elastic/eui';
import { BrowserFields } from '@kbn/rule-registry-plugin/common';
import type { BrowserFields } from '@kbn/rule-registry-plugin/common';
/**
* An item rendered in the table

View file

@ -0,0 +1,175 @@
# @kbn/response-ops-alerts-table
An abstraction on top of `EuiDataGrid` dedicated to rendering alert documents.
## Usage
In addition to `EuiDataGrid`'s functionality, the table manages the paginated and cached fetching of alerts, based on
the provided `ruleTypeIds` and `consumers` (the final query can be refined through the `query` and `initialSort` props).
The `id` prop is used to persist the table state in `localStorage`.
```tsx
<AlertsTable
id="my-alerts-table"
ruleTypeIds={ruleTypeIds}
consumers={consumers}
query={esQuery}
initialSort={defaultAlertsTableSort}
renderCellValue={renderCellValue}
renderActionsCell={AlertActionsCell}
services={{ ... }}
/>
```
## Columns
Just like in `EuiDataGrid`, the columns are customizable through the `columns` prop. In addition to those, the table
renders an "Actions" column with default alert call-to-actions and provides row selection and bulk actions
functionality.
```tsx
// The @kbn/rule-data-utils package exports constants
// for many common alert field keys
import { ALERT_RULE_NAME } from '@kbn/rule-data-utils';
<AlertsTable columns={[
{
displayAsText: 'Rule',
id: ALERT_RULE_NAME,
initialWidth: 230,
},
]} />
```
## Cells, popovers and flyouts
All the sub-components of the table are customizable through the `render*`
props (`renderCellValue`, `renderCellPopover`, `renderActionsCell`, etc.). Values passed to these props are treated as
components, allowing hooks, context, and other React concepts to be used.
```tsx
const CustomCellValue: GetAlertsTableProp<'renderCellValue'> = ({ alert }) => {
// ...
};
<AlertsTable renderCellValue={CustomCellValue} />
```
## Render context
All the sub-component renderers receive as part of their props a context object (
see [EuiDataGrid's `cellContext`](https://eui.elastic.co/#/tabular-content/data-grid-cells-popovers%23cell-context))
with common utilities and services (i.e. the fetched alerts, loading states etc.).
You can add properties to this context by means of the `additionalContext` prop:
```tsx
<AlertsTable
additionalContext={{
myCustomProperty: 'my custom value',
}}
renderCellValue={({ myCustomProperty /*string*/ }) => {
// ...
}}
/>
```
The context type is inferred based on the `additionalContext` prop and all render functions props are typed accordingly.
To avoid prop drilling, you can use the `useAlertsTableContext` hook to access the same context in any sub-component.
```tsx
const CustomCellValue = ({ alert }) => {
const { alertsCount, myCustomProperty } = useAlertsTableContext<MyAdditionalContext>();
// ...
};
```
In order to define your custom sub-components separately from the table but still benefit from the context type
inference, you may want to extract props from the `AlertsTableProps` type. The `GetAlertsTableProp` utility type is
provided for this: it extracts the type of a specific prop from the `AlertsTableProps` type, excluding `undefined` in
case of optional props.
```tsx
import type { GetAlertsTableProp } from '@kbn/response-ops-alerts-table/types';
export const CustomCellValue: GetAlertsTableProp<'renderCellValue'> = ({ alert }) => {
// ...
};
```
If you also have an additional context, you can define a type for it and wrap the `AlertsTableProps`, providing it as a
generic:
```tsx
import type { AlertsTableProps } from '@kbn/response-ops-alerts-table/types';
interface MyAdditionalContext {
myCustomProperty: string;
}
export type MyAlertsTableProps = AlertsTableProps<MyAdditionalContext>;
export type GetMyAlertsTableProp<K extends keyof MyAdditionalContext> = Exclude<MyAlertsTableProps[K], undefined>;
export const CustomCellValue: GetMyAlertsTableProp<'renderCellValue'> = ({ myCustomProperty }) => {
// ...
};
<AlertsTable<MyAdditionalContext>
additionalContext={{
myCustomProperty: 'my custom value',
}}
renderCellValue={CustomCellValue}
/>
```
## Dependencies
The table relies on the following Kibana services, expected in the `services` prop:
- `data`
- `http`
- `notifications`
- `fieldFormats`
- `application`
- `licensing`
- `settings`
- `cases` (optional)
## Integrations
The table has built-in integration with Maintenance Windows and Cases. If alerts have maintenance windows or cases
associated to them, they will be loaded and made available through the `maintenanceWindows` and `cases` properties of
the render context.
A special cell renderer is used by default for the `kibana.alert.maintenance_window_ids` and `kibana.alert.case_ids`
columns.
## Lazy loading
Contrary to the previous implementation exported by `triggersActionsUI`, this package doesn't prescribe how to lazy load
the table component; a default export is just provided for convenience. However, do consider that
the `React.lazy` function loses the original generic types of the component. To make the type inference work correctly,
you can assert the type of the lazy loaded component using a type import:
```tsx
import type { AlertsTable as AlertsTableType } from '@kbn/response-ops-alerts-table';
const AlertsTable = React.lazy(() => import('@kbn/response-ops-alerts-table')) as AlertsTableType;
```
## Mocking
When mocking the table, keep in mind that the component is manually typed as a normal function component (to keep its
generic types), but it's actually a memoized, forwardRef'ed component. To mock it properly, mock the entire module:
```tsx
jest.mock('@kbn/response-ops-alerts-table', () => ({
AlertsTable: jest.fn().mockImplementation(() => <div data-test-subj="alerts-table"/>),
}));
```
## What's new compared to `triggersActionsUI`?
- The alerts table registry was removed. The table is now a standalone component and the configuration is based entirely
on props.
- All the custom renderers (cell, cell popover, actions cell, etc.) are now exposed as strongly typed props (`render*`).
- More `EuiDataGrid` props are exposed for customization.

View file

@ -1,14 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { bulkGetCases } from './bulk_get_cases';
import { coreMock } from '@kbn/core/public/mocks';
describe('Bulk Get Cases API', () => {
describe('bulkGetCases', () => {
const abortCtrl = new AbortController();
const mockCoreSetup = coreMock.createSetup();
const http = mockCoreSetup.http;

View file

@ -1,12 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { CaseStatuses } from '@kbn/cases-components';
import { HttpStart } from '@kbn/core-http-browser';
const INTERNAL_BULK_GET_CASES_URL = '/internal/cases/_bulk_get';
export interface Case {
@ -37,15 +40,9 @@ export interface CasesBulkGetResponse {
}>;
}
export const bulkGetCases = async (
http: HttpStart,
params: { ids: string[] },
signal?: AbortSignal
): Promise<CasesBulkGetResponse> => {
const res = await http.post<CasesBulkGetResponse>(INTERNAL_BULK_GET_CASES_URL, {
export const bulkGetCases = (http: HttpStart, params: { ids: string[] }, signal?: AbortSignal) => {
return http.post<CasesBulkGetResponse>(INTERNAL_BULK_GET_CASES_URL, {
body: JSON.stringify({ ...params }),
signal,
});
return res;
};

View file

@ -1,14 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { coreMock } from '@kbn/core/public/mocks';
import { bulkGetMaintenanceWindows } from './bulk_get_maintenance_windows';
describe('Bulk Get Maintenance Windows API', () => {
describe('bulkGetMaintenanceWindows', () => {
const mockCoreSetup = coreMock.createSetup();
const http = mockCoreSetup.http;

View file

@ -1,16 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { HttpStart } from '@kbn/core-http-browser';
import {
MaintenanceWindow,
INTERNAL_ALERTING_API_MAINTENANCE_WINDOW_PATH,
} from '@kbn/alerting-plugin/common';
import { AsApiContract } from '@kbn/actions-plugin/common';
import type { HttpStart } from '@kbn/core-http-browser';
import type { MaintenanceWindow } from '@kbn/alerting-plugin/common';
import type { AsApiContract } from '@kbn/actions-plugin/common';
import { INTERNAL_ALERTING_API_MAINTENANCE_WINDOW_PATH } from '../constants';
export interface BulkGetMaintenanceWindowsParams {
http: HttpStart;

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { ComponentProps, useEffect } from 'react';
import { screen, render } from '@testing-library/react';
import { ActionsCellHost } from './actions_cell_host';
import { createPartialObjectMock } from '../utils/test';
import { mockRenderContext } from '../mocks/context.mock';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
const props = createPartialObjectMock<ComponentProps<typeof ActionsCellHost>>({
...mockRenderContext,
rowIndex: 0,
});
describe('ActionsCellHost', () => {
it('should render the provided custom actions cell', () => {
render(
<ActionsCellHost
{...props}
renderActionsCell={jest.fn(() => (
<div data-test-subj="renderActionsCell" />
))}
/>
);
expect(screen.getByTestId('renderActionsCell')).toBeInTheDocument();
});
it('should catch errors from the custom actions cell', async () => {
const CustomActionsCell = () => {
useEffect(() => {
throw new Error('test error');
}, []);
return null;
};
render(
<IntlProvider locale="en">
<ActionsCellHost {...props} renderActionsCell={CustomActionsCell} />
</IntlProvider>
);
expect(await screen.findByTestId('errorCell')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { SetRequired } from 'type-fest';
import React, { ComponentProps, useCallback } from 'react';
import { EuiFlexGroup } from '@elastic/eui';
import { typedMemo } from '../utils/react';
import { AdditionalContext, AlertsTableProps, BulkActionsVerbs } from '../types';
import { ErrorBoundary } from './error_boundary';
import { ErrorCell } from './error_cell';
/**
* Entry point for rendering actions cells (in control columns)
*/
export const ActionsCellHost = typedMemo(
<AC extends AdditionalContext>(
// The actions control column is active only when the user provided the `renderActionsCell` prop
props: SetRequired<
ComponentProps<NonNullable<AlertsTableProps<AC>['renderActionsCell']>>,
'renderActionsCell'
>
) => {
const {
rowIndex,
pageSize,
pageIndex,
alerts,
oldAlertsData,
ecsAlertsData,
bulkActionsStore,
renderActionsCell: ActionsCell,
visibleRowIndex,
} = props;
const idx = rowIndex - pageSize * pageIndex;
const alert = alerts[idx];
const legacyAlert = oldAlertsData[idx];
const ecsAlert = ecsAlertsData[idx];
const [, updateBulkActionsState] = bulkActionsStore;
const setIsActionLoading = useCallback(
(_isLoading: boolean = true) => {
updateBulkActionsState({
action: BulkActionsVerbs.updateRowLoadingState,
rowIndex: visibleRowIndex,
isLoading: _isLoading,
});
},
[visibleRowIndex, updateBulkActionsState]
);
if (!alert) {
return null;
}
return (
<EuiFlexGroup gutterSize="none" responsive={false}>
<ErrorBoundary fallback={ErrorCell}>
<ActionsCell
{...(props as ComponentProps<NonNullable<AlertsTableProps<AC>['renderActionsCell']>>)}
alert={alert}
legacyAlert={legacyAlert}
ecsAlert={ecsAlert}
setIsActionLoading={setIsActionLoading}
/>
</ErrorBoundary>
</EuiFlexGroup>
);
}
);

View file

@ -1,72 +1,64 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { Alert } from '@kbn/alerting-types';
import { AlertLifecycleStatusCell } from './alert_lifecycle_status_cell';
import { CellComponentProps } from '../types';
import { Alert } from '../../../../types';
import { AppMockRenderer, createAppMockRenderer } from '../../test_utils';
import { getCasesMockMap } from '../cases/index.mock';
import { getMaintenanceWindowMockMap } from '../maintenance_windows/index.mock';
jest.mock('../../../../common/lib/kibana');
import { getCasesMapMock } from '../mocks/cases.mock';
import { getMaintenanceWindowsMapMock } from '../mocks/maintenance_windows.mock';
describe('AlertLifecycleStatusCell', () => {
const casesMap = getCasesMockMap();
const maintenanceWindowsMap = getMaintenanceWindowMockMap();
const alert = {
const casesMap = getCasesMapMock();
const maintenanceWindowsMap = getMaintenanceWindowsMapMock();
const alert: Alert = {
_id: 'alert-id',
_index: 'alert-index',
'kibana.alert.status': ['active'],
} as Alert;
};
const props: CellComponentProps = {
const props = {
isLoading: false,
alert,
cases: casesMap,
maintenanceWindows: maintenanceWindowsMap,
columnId: 'kibana.alert.status',
showAlertStatusWithFlapping: true,
};
let appMockRender: AppMockRenderer;
beforeEach(() => {
appMockRender = createAppMockRenderer();
});
// Assertion used to avoid defining all the (numerous) context properties
} as CellComponentProps;
it('shows the status', async () => {
appMockRender.render(<AlertLifecycleStatusCell {...props} />);
render(<AlertLifecycleStatusCell {...props} />);
expect(screen.getByText('Active')).toBeInTheDocument();
});
it('does not shows the status if showAlertStatusWithFlapping=false', async () => {
appMockRender.render(
<AlertLifecycleStatusCell {...props} showAlertStatusWithFlapping={false} />
);
render(<AlertLifecycleStatusCell {...props} showAlertStatusWithFlapping={false} />);
expect(screen.queryByText('Active')).not.toBeInTheDocument();
});
it('shows the status with flapping', async () => {
appMockRender.render(
render(
<AlertLifecycleStatusCell
{...props}
alert={{ ...alert, 'kibana.alert.flapping': ['true'] } as Alert}
alert={{ ...alert, 'kibana.alert.flapping': ['true'] }}
/>
);
expect(screen.getByText('Flapping')).toBeInTheDocument();
});
it('shows the status with multiple values', async () => {
appMockRender.render(
render(
<AlertLifecycleStatusCell
{...props}
alert={{ ...alert, 'kibana.alert.status': ['active', 'recovered'] } as Alert}
alert={{ ...alert, 'kibana.alert.status': ['active', 'recovered'] }}
/>
);
@ -74,12 +66,7 @@ describe('AlertLifecycleStatusCell', () => {
});
it('shows the default cell if the status is empty', async () => {
appMockRender.render(
<AlertLifecycleStatusCell
{...props}
alert={{ ...alert, 'kibana.alert.status': [] } as unknown as Alert}
/>
);
render(<AlertLifecycleStatusCell {...props} alert={{ ...alert, 'kibana.alert.status': [] }} />);
expect(screen.getByText('--')).toBeInTheDocument();
});

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { AlertStatus, ALERT_FLAPPING, ALERT_STATUS } from '@kbn/rule-data-utils';
import React, { memo } from 'react';
import { EuiBadge, EuiFlexGroup, EuiToolTip, useEuiTheme } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { AlertLifecycleStatusBadge } from '@kbn/alerts-ui-shared';
import { DefaultCell } from './default_cell';
import { useAlertMutedState } from '../hooks/use_alert_muted_state';
import type { CellComponent } from '../types';
export const AlertLifecycleStatusCell: CellComponent = memo((props) => {
const { euiTheme } = useEuiTheme();
const { alert, showAlertStatusWithFlapping } = props;
const { isMuted } = useAlertMutedState(alert);
if (!showAlertStatusWithFlapping) {
return null;
}
const alertStatus = (alert?.[ALERT_STATUS] ?? []) as string[] | undefined;
if (Array.isArray(alertStatus) && alertStatus.length) {
const flapping = alert?.[ALERT_FLAPPING]?.[0] as boolean | undefined;
return (
<EuiFlexGroup gutterSize="s">
<AlertLifecycleStatusBadge
alertStatus={alertStatus.join() as AlertStatus}
flapping={flapping}
/>
{isMuted && (
<EuiToolTip
content={i18n.translate('xpack.triggersActionsUI.sections.alertsTable.alertMuted', {
defaultMessage: 'Alert muted',
})}
>
<EuiBadge
iconType="bellSlash"
css={css`
padding-inline: ${euiTheme.size.xs};
`}
/>
</EuiToolTip>
)}
</EuiFlexGroup>
);
}
return <DefaultCell {...props} />;
});

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useMemo } from 'react';

View file

@ -0,0 +1,496 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { FunctionComponent, useMemo, useReducer } from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
import { AlertsDataGrid } from './alerts_data_grid';
import { AlertsDataGridProps, BulkActionsState } from '../types';
import { AdditionalContext, RenderContext } from '../types';
import { EuiButton, EuiButtonIcon, EuiDataGridColumnCellAction, EuiFlexItem } from '@elastic/eui';
import { bulkActionsReducer } from '../reducers/bulk_actions_reducer';
import { getJsDomPerformanceFix } from '../utils/test';
import { useCaseViewNavigation } from '../hooks/use_case_view_navigation';
import { act } from 'react-dom/test-utils';
import { AlertsTableContextProvider } from '../contexts/alerts_table_context';
import {
mockRenderContext,
mockColumns,
mockAlerts,
createMockBulkActionsState,
} from '../mocks/context.mock';
import {
CELL_ACTIONS_EXPAND_TEST_ID,
CELL_ACTIONS_POPOVER_TEST_ID,
FIELD_BROWSER_BTN_TEST_ID,
FIELD_BROWSER_CUSTOM_CREATE_BTN_TEST_ID,
FIELD_BROWSER_TEST_ID,
} from '../constants';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { testQueryClientConfig } from '@kbn/alerts-ui-shared/src/common/test_utils/test_query_client_config';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
jest.mock('../hooks/use_case_view_navigation');
const cellActionOnClickMockedFn = jest.fn();
const { fix, cleanup } = getJsDomPerformanceFix();
beforeAll(() => {
fix();
});
afterAll(() => {
cleanup();
});
export type BaseAlertsDataGridProps = AlertsDataGridProps;
export type TestAlertsDataGridProps = Partial<Omit<BaseAlertsDataGridProps, 'renderContext'>> & {
renderContext?: Partial<RenderContext<AdditionalContext>>;
};
const queryClient = new QueryClient(testQueryClientConfig);
export const mockDataGridProps: Partial<BaseAlertsDataGridProps> = {
pageSizeOptions: [1, 10, 20, 50, 100],
leadingControlColumns: [],
trailingControlColumns: [],
visibleColumns: mockColumns.map((c) => c.id),
'data-test-subj': 'testTable',
onToggleColumn: jest.fn(),
onResetColumns: jest.fn(),
onChangeVisibleColumns: jest.fn(),
query: {},
sort: [],
alertsQuerySnapshot: { request: [], response: [] },
onSortChange: jest.fn(),
onChangePageIndex: jest.fn(),
onChangePageSize: jest.fn(),
getBulkActions: () => [
{
id: 0,
items: [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: () => {},
},
],
},
],
fieldsBrowserOptions: {
createFieldButton: () => <EuiButton data-test-subj={FIELD_BROWSER_CUSTOM_CREATE_BTN_TEST_ID} />,
},
casesConfiguration: { featureId: 'test-feature-id', owner: ['cases'] },
};
describe('AlertsDataGrid', () => {
const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock;
useCaseViewNavigationMock.mockReturnValue({ navigateToCaseView: jest.fn() });
const TestComponent: React.FunctionComponent<
Omit<TestAlertsDataGridProps, 'renderContext'> & {
initialBulkActionsState?: BulkActionsState;
renderContext?: Partial<RenderContext<AdditionalContext>>;
}
> = (props) => {
const bulkActionsStore = useReducer(
bulkActionsReducer,
props.initialBulkActionsState || createMockBulkActionsState()
);
const renderContext = useMemo(
() => ({
...mockRenderContext,
bulkActionsStore,
...props.renderContext,
}),
[bulkActionsStore, props.renderContext]
);
return (
<AlertsTableContextProvider value={renderContext}>
<QueryClientProvider client={queryClient} context={AlertsQueryContext}>
<IntlProvider locale="en">
<AlertsDataGrid {...({ ...props, renderContext } as BaseAlertsDataGridProps)} />
</IntlProvider>
</QueryClientProvider>
</AlertsTableContextProvider>
);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('Alerts table UI', () => {
it('should support sorting', async () => {
const { container } = render(<TestComponent {...mockDataGridProps} />);
await userEvent.click(container.querySelector('.euiDataGridHeaderCell__button')!, {
pointerEventsCheck: 0,
});
await waitForEuiPopoverOpen();
await userEvent.click(
screen.getByTestId(`dataGridHeaderCellActionGroup-${mockColumns[0].id}`),
{
pointerEventsCheck: 0,
}
);
await userEvent.click(screen.getByTitle('Sort A-Z'), {
pointerEventsCheck: 0,
});
expect(mockDataGridProps.onSortChange).toHaveBeenCalledWith([
{ direction: 'asc', id: 'kibana.alert.rule.name' },
]);
});
it('should support pagination', async () => {
render(<TestComponent {...mockDataGridProps} />);
await userEvent.click(screen.getByTestId('pagination-button-1'), {
pointerEventsCheck: 0,
});
expect(mockDataGridProps.onChangePageIndex).toHaveBeenCalledWith(1);
});
it('should show when it was updated', () => {
render(<TestComponent {...mockDataGridProps} />);
expect(screen.getByTestId('toolbar-updated-at')).not.toBe(null);
});
it('should show alerts count', () => {
render(<TestComponent {...mockDataGridProps} />);
expect(screen.getByTestId('toolbar-alerts-count')).not.toBe(null);
});
it('should show alert status', () => {
render(
<TestComponent
{...mockDataGridProps}
renderContext={{
renderCellValue: undefined,
pageSize: 10,
}}
/>
);
expect(screen.queryAllByTestId('alertLifecycleStatusBadge')[0].textContent).toEqual(
'Flapping'
);
expect(screen.queryAllByTestId('alertLifecycleStatusBadge')[1].textContent).toEqual('Active');
expect(screen.queryAllByTestId('alertLifecycleStatusBadge')[2].textContent).toEqual(
'Recovered'
);
expect(screen.queryAllByTestId('alertLifecycleStatusBadge')[3].textContent).toEqual(
'Recovered'
);
});
describe('leading control columns', () => {
it('should render other leading controls', () => {
const props: TestAlertsDataGridProps = {
...mockDataGridProps,
leadingControlColumns: [
{
id: 'selection',
width: 67,
headerCellRender: () => <span data-test-subj="testHeader">Test header</span>,
rowCellRender: () => <h2 data-test-subj="testCell">Test cell</h2>,
},
],
};
render(<TestComponent {...props} />);
expect(screen.queryByTestId('testHeader')).not.toBe(null);
expect(screen.queryByTestId('testCell')).not.toBe(null);
});
});
describe('actions column', () => {
it('should render custom actions cells', () => {
render(
<TestComponent
{...mockDataGridProps}
renderContext={{
renderActionsCell: () => (
<>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="analyzeEvent"
color="primary"
onClick={() => {}}
size="s"
data-test-subj="testAction"
aria-label="testActionLabel"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="analyzeEvent"
color="primary"
onClick={() => {}}
size="s"
data-test-subj="testAction2"
aria-label="testActionLabel2"
/>
</EuiFlexItem>
</>
),
}}
/>
);
expect(screen.queryByTestId('testAction')).toBeInTheDocument();
expect(screen.queryByTestId('testAction2')).toBeInTheDocument();
expect(screen.queryByTestId('expandColumnCellOpenFlyoutButton-0')).not.toBeInTheDocument();
});
it('should render no action column if neither the action nor the expand action config is set', () => {
render(
<TestComponent
{...mockDataGridProps}
renderContext={{
renderActionsCell: undefined,
}}
/>
);
expect(screen.queryByTestId('expandColumnHeaderLabel')).not.toBeInTheDocument();
expect(screen.queryByTestId('expandColumnCellOpenFlyoutButton')).not.toBeInTheDocument();
});
describe('row loading state on action', () => {
type ExtractFunctionComponent<T> = T extends FunctionComponent<infer P> ? T : never;
const mockRenderActionsCell = jest.fn(
mockRenderContext.renderActionsCell as ExtractFunctionComponent<
typeof mockRenderContext.renderActionsCell
>
);
const props: TestAlertsDataGridProps = {
...mockDataGridProps,
actionsColumnWidth: 124,
renderContext: {
pageSize: 10,
renderActionsCell: mockRenderActionsCell,
},
};
it('should show the row loader when callback triggered', async () => {
render(<TestComponent {...props} />);
fireEvent.click((await screen.findAllByTestId('testAction'))[0]);
// the callback given to our clients to run when they want to update the loading state
act(() => {
mockRenderActionsCell.mock.calls[0][0].setIsActionLoading!(true);
});
expect(await screen.findAllByTestId('row-loader')).toHaveLength(1);
const selectedOptions = await screen.findAllByTestId('dataGridRowCell');
// first row, first column
expect(within(selectedOptions[0]).getByLabelText('Loading')).toBeDefined();
expect(
within(selectedOptions[0]).queryByTestId('bulk-actions-row-cell')
).not.toBeInTheDocument();
// second row, first column
expect(within(selectedOptions[6]).queryByLabelText('Loading')).not.toBeInTheDocument();
expect(within(selectedOptions[6]).getByTestId('bulk-actions-row-cell')).toBeDefined();
});
it('should show the row loader when callback triggered with false', async () => {
const initialBulkActionsState = {
...createMockBulkActionsState(),
rowSelection: new Map([[0, { isLoading: true }]]),
};
render(<TestComponent {...props} initialBulkActionsState={initialBulkActionsState} />);
fireEvent.click((await screen.findAllByTestId('testAction'))[0]);
// the callback given to our clients to run when they want to update the loading state
act(() => {
mockRenderActionsCell.mock.calls[0][0].setIsActionLoading!(false);
});
expect(screen.queryByTestId('row-loader')).not.toBeInTheDocument();
});
});
});
describe('cell Actions', () => {
const mockGetCellActionsForColumn = jest.fn(
(columnId: string): EuiDataGridColumnCellAction[] => [
({ rowIndex, Component }) => {
const label = 'Fake Cell First Action';
return (
<Component
onClick={() => cellActionOnClickMockedFn(columnId, rowIndex)}
data-test-subj={'fake-cell-first-action'}
iconType="refresh"
aria-label={label}
/>
);
},
]
);
const props: TestAlertsDataGridProps = {
...mockDataGridProps,
cellActionsOptions: {
getCellActionsForColumn: mockGetCellActionsForColumn,
visibleCellActions: 2,
disabledCellActions: [],
},
};
it('should render cell actions on hover', async () => {
render(<TestComponent {...props} />);
const reasonFirstRow = (await screen.findAllByTestId('dataGridRowCell'))[3];
fireEvent.mouseOver(reasonFirstRow);
expect(await screen.findByTestId('fake-cell-first-action')).toBeInTheDocument();
});
it('should render expandable cell actions', async () => {
render(<TestComponent {...props} />);
const reasonFirstRow = (await screen.findAllByTestId('dataGridRowCell'))[3];
fireEvent.mouseOver(reasonFirstRow);
expect(await screen.findByTestId(CELL_ACTIONS_EXPAND_TEST_ID)).toBeVisible();
fireEvent.click(await screen.findByTestId(CELL_ACTIONS_EXPAND_TEST_ID));
expect(await screen.findByTestId(CELL_ACTIONS_POPOVER_TEST_ID)).toBeVisible();
expect(await screen.findAllByLabelText(/fake cell first action/i)).toHaveLength(2);
});
});
describe('Fields browser', () => {
it('fields browser is working correctly', async () => {
render(
<TestComponent
{...mockDataGridProps}
initialBulkActionsState={{
...createMockBulkActionsState(),
rowSelection: new Map(),
}}
/>
);
const fieldBrowserBtn = screen.getByTestId(FIELD_BROWSER_BTN_TEST_ID);
expect(fieldBrowserBtn).toBeVisible();
fireEvent.click(fieldBrowserBtn);
expect(await screen.findByTestId(FIELD_BROWSER_TEST_ID)).toBeVisible();
expect(await screen.findByTestId(FIELD_BROWSER_CUSTOM_CREATE_BTN_TEST_ID)).toBeVisible();
});
it('syncs the columns state correctly between the column selector and the field selector', async () => {
const columnToHide = mockColumns[0];
render(
<TestComponent
{...mockDataGridProps}
toolbarVisibility={{
showColumnSelector: true,
}}
initialBulkActionsState={{
...createMockBulkActionsState(),
rowSelection: new Map(),
}}
/>
);
const fieldBrowserBtn = await screen.findByTestId(FIELD_BROWSER_BTN_TEST_ID);
const columnSelectorBtn = await screen.findByTestId('dataGridColumnSelectorButton');
// Open the column visibility selector and hide the column
fireEvent.click(columnSelectorBtn);
const columnVisibilityToggle = await screen.findByTestId(
`dataGridColumnSelectorToggleColumnVisibility-${columnToHide.id}`
);
fireEvent.click(columnVisibilityToggle);
// Open the field browser
fireEvent.click(fieldBrowserBtn);
expect(await screen.findByTestId(FIELD_BROWSER_TEST_ID)).toBeVisible();
// The column should be checked in the field browser, independent of its visibility status
const columnCheckbox: HTMLInputElement = await screen.findByTestId(
`field-${columnToHide.id}-checkbox`
);
expect(columnCheckbox).toBeChecked();
});
});
describe('cases column', () => {
const props: TestAlertsDataGridProps = {
...mockDataGridProps,
renderContext: {
pageSize: mockAlerts.length,
},
};
it('should show the cases column', async () => {
render(<TestComponent {...props} />);
expect(await screen.findByText('Cases')).toBeInTheDocument();
});
it('should show the cases titles correctly', async () => {
render(<TestComponent {...props} renderContext={{ pageSize: 10 }} />);
expect(await screen.findByText('Test case')).toBeInTheDocument();
expect(await screen.findByText('Test case 2')).toBeInTheDocument();
});
it('show loading skeleton if it loads cases', async () => {
render(
<TestComponent
{...props}
renderContext={{
pageSize: 10,
isLoading: true,
isLoadingCases: true,
}}
/>
);
expect((await screen.findAllByTestId('cases-cell-loading')).length).toBe(4);
});
it('shows the cases tooltip', async () => {
render(<TestComponent {...props} />);
expect(await screen.findByText('Test case')).toBeInTheDocument();
await userEvent.hover(screen.getByText('Test case'));
expect(await screen.findByTestId('cases-components-tooltip')).toBeInTheDocument();
});
});
describe('dynamic row height mode', () => {
it('should render a non-virtualized grid body when the dynamicRowHeight option is on', async () => {
const { container } = render(<TestComponent {...mockDataGridProps} dynamicRowHeight />);
expect(container.querySelector('.euiDataGrid__customRenderBody')).toBeTruthy();
});
it('should render a virtualized grid body when the dynamicRowHeight option is off', async () => {
const { container } = render(<TestComponent {...mockDataGridProps} />);
expect(container.querySelector('.euiDataGrid__virtualized')).toBeTruthy();
});
});
});
});

View file

@ -0,0 +1,387 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { FC, lazy, Suspense, useCallback, useMemo } from 'react';
import {
EuiDataGrid,
EuiDataGridControlColumn,
EuiDataGridProps,
EuiDataGridStyle,
RenderCellValue,
tint,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { ActionsCellHost } from './actions_cell_host';
import { ControlColumnHeaderCell } from './control_column_header_cell';
import { CellValueHost } from './cell_value_host';
import { BulkActionsCell } from './bulk_actions_cell';
import { BulkActionsHeader } from './bulk_actions_header_cell';
import { AdditionalContext, AlertsDataGridProps, CellActionsOptions } from '../types';
import { useGetToolbarVisibility } from '../hooks/use_toolbar_visibility';
import { InspectButtonContainer } from './alerts_query_inspector';
import { typedMemo } from '../utils/react';
import type { AlertsFlyout as AlertsFlyoutType } from './alerts_flyout';
import { useBulkActions } from '../hooks/use_bulk_actions';
import { useSorting } from '../hooks/use_sorting';
import { CellPopoverHost } from './cell_popover_host';
import { NonVirtualizedGridBody } from './non_virtualized_grid_body';
const AlertsFlyout = lazy(() => import('./alerts_flyout')) as typeof AlertsFlyoutType;
const defaultGridStyle: EuiDataGridStyle = {
border: 'none',
header: 'underline',
fontSize: 's',
};
const defaultCellActionsOptions: CellActionsOptions = {
getCellActionsForColumn: () => [],
disabledCellActions: [],
};
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const DEFAULT_ACTIONS_COLUMN_WIDTH = 75;
const stableMappedRowClasses: EuiDataGridStyle['rowClasses'] = {};
export const AlertsDataGrid = typedMemo(
<AC extends AdditionalContext>(props: AlertsDataGridProps<AC>) => {
const {
ruleTypeIds,
query,
visibleColumns,
onToggleColumn,
onResetColumns,
onChangeVisibleColumns,
onColumnResize,
showInspectButton = false,
leadingControlColumns: additionalLeadingControlColumns,
trailingControlColumns,
onSortChange,
sort: sortingFields,
rowHeightsOptions,
dynamicRowHeight,
alertsQuerySnapshot,
additionalToolbarControls,
toolbarVisibility: toolbarVisibilityProp,
shouldHighlightRow,
renderContext,
hideBulkActions,
casesConfiguration,
flyoutAlertIndex,
setFlyoutAlertIndex,
onPaginateFlyout,
onChangePageSize,
onChangePageIndex,
actionsColumnWidth = DEFAULT_ACTIONS_COLUMN_WIDTH,
getBulkActions,
fieldsBrowserOptions,
cellActionsOptions,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
height,
...euiDataGridProps
} = props;
const {
isLoading,
alerts,
alertsCount,
isLoadingAlerts,
browserFields,
renderActionsCell,
pageIndex,
pageSize,
refresh: refreshQueries,
columns,
dataGridRef,
services: { http, notifications, application, cases: casesService, settings },
} = renderContext;
const { colorMode } = useEuiTheme();
const { sortingColumns, onSort } = useSorting(onSortChange, visibleColumns, sortingFields);
const {
isBulkActionsColumnActive,
bulkActionsState,
bulkActions,
setIsBulkActionsLoading,
clearSelection,
} = useBulkActions({
ruleTypeIds,
query,
alertsCount: alerts.length,
casesConfig: casesConfiguration,
getBulkActions,
refresh: refreshQueries,
hideBulkActions,
http,
notifications,
application,
casesService,
});
const refresh = useCallback(() => {
refreshQueries();
clearSelection();
}, [clearSelection, refreshQueries]);
const columnIds = useMemo(() => columns.map((column) => column.id), [columns]);
const toolbarVisibility = useGetToolbarVisibility({
bulkActions,
alertsCount,
rowSelection: bulkActionsState.rowSelection,
alerts,
isLoading,
columnIds,
onToggleColumn,
onResetColumns,
browserFields,
additionalToolbarControls,
setIsBulkActionsLoading,
clearSelection,
refresh,
fieldsBrowserOptions,
alertsQuerySnapshot,
showInspectButton,
toolbarVisibilityProp,
settings,
});
const leadingControlColumns: EuiDataGridControlColumn[] | undefined = useMemo(() => {
const controlColumns = [
...(additionalLeadingControlColumns ?? []),
...(isBulkActionsColumnActive
? [
{
id: 'bulkActions',
width: 30,
headerCellRender: BulkActionsHeader,
rowCellRender: BulkActionsCell,
},
]
: []),
// If the user provided an actions cell renderer, add the actions column
...(renderActionsCell
? [
{
id: 'expandColumn',
width: actionsColumnWidth,
headerCellRender: ControlColumnHeaderCell,
// Though untyped, rowCellRender's CellPropsWithContext contains the correct context
rowCellRender:
ActionsCellHost as unknown as EuiDataGridControlColumn['rowCellRender'],
},
]
: []),
];
if (controlColumns.length) {
return controlColumns;
}
}, [
additionalLeadingControlColumns,
isBulkActionsColumnActive,
renderActionsCell,
actionsColumnWidth,
]);
const flyoutRowIndex = flyoutAlertIndex + pageIndex * pageSize;
// Row classes do not deal with visible row indices, so we need to handle page offset
const activeRowClasses = useMemo<NonNullable<EuiDataGridStyle['rowClasses']>>(
() => ({
[flyoutRowIndex]: 'alertsTableActiveRow',
}),
[flyoutRowIndex]
);
const handleFlyoutClose = useCallback(() => setFlyoutAlertIndex(-1), [setFlyoutAlertIndex]);
const dataGridPagination = useMemo(
() => ({
pageIndex,
pageSize,
pageSizeOptions,
onChangeItemsPerPage: onChangePageSize,
onChangePage: onChangePageIndex,
}),
[onChangePageIndex, onChangePageSize, pageIndex, pageSize, pageSizeOptions]
);
const { getCellActionsForColumn, visibleCellActions, disabledCellActions } =
cellActionsOptions ?? defaultCellActionsOptions;
const columnsWithCellActions = useMemo(() => {
if (getCellActionsForColumn) {
return columns.map((col, idx) => ({
...col,
...(!(disabledCellActions ?? []).includes(col.id)
? {
cellActions: getCellActionsForColumn(col.id, idx) ?? [],
visibleCellActions,
}
: {}),
}));
}
return columns;
}, [getCellActionsForColumn, columns, disabledCellActions, visibleCellActions]);
// Update highlighted rows when alerts or pagination changes
const highlightedRowClasses = useMemo(() => {
if (shouldHighlightRow) {
const emptyShouldHighlightRow: EuiDataGridStyle['rowClasses'] = {};
return alerts.reduce<NonNullable<EuiDataGridStyle['rowClasses']>>(
(rowClasses, alert, index) => {
if (shouldHighlightRow(alert)) {
rowClasses[index + pageIndex * pageSize] = 'alertsTableHighlightedRow';
}
return rowClasses;
},
emptyShouldHighlightRow
);
} else {
return stableMappedRowClasses;
}
}, [shouldHighlightRow, alerts, pageIndex, pageSize]);
const mergedGridStyle = useMemo(() => {
const propGridStyle: NonNullable<EuiDataGridStyle> = props.gridStyle ?? {};
// Merges default row classes, custom ones and adds the active row class style
return {
...defaultGridStyle,
...propGridStyle,
rowClasses: {
// We're spreading the highlighted row classes first, so that the active
// row classed can override the highlighted row classes.
...highlightedRowClasses,
...activeRowClasses,
},
};
}, [activeRowClasses, highlightedRowClasses, props.gridStyle]);
// Merges the default grid style with the grid style that comes in through props.
const actualGridStyle = useMemo(() => {
const propGridStyle: NonNullable<EuiDataGridStyle> = props.gridStyle ?? {};
// If ANY additional rowClasses have been provided, we need to merge them with our internal ones
if (propGridStyle.rowClasses) {
// Get all row indices with a rowClass.
const mergedKeys = [
...Object.keys(mergedGridStyle.rowClasses || {}),
...Object.keys(propGridStyle.rowClasses || {}),
];
// Deduplicate keys to avoid extra iterations
const dedupedKeys = Array.from(new Set(mergedKeys));
// For each index, merge row classes
mergedGridStyle.rowClasses = dedupedKeys.reduce<
NonNullable<EuiDataGridStyle['rowClasses']>
>((rowClasses, key) => {
const intKey = parseInt(key, 10);
// Use internal row classes over custom row classes.
rowClasses[intKey] =
mergedGridStyle.rowClasses?.[intKey] || propGridStyle.rowClasses?.[intKey] || '';
return rowClasses;
}, {});
}
return mergedGridStyle;
}, [props.gridStyle, mergedGridStyle]);
const renderCustomGridBody = useCallback<NonNullable<EuiDataGridProps['renderCustomGridBody']>>(
({ visibleColumns: _visibleColumns, Cell, headerRow, footerRow }) => (
<>
{headerRow}
<NonVirtualizedGridBody
alerts={alerts}
visibleColumns={_visibleColumns}
Cell={Cell}
actualGridStyle={actualGridStyle}
pageIndex={pageIndex}
pageSize={pageSize}
isLoading={isLoadingAlerts}
stripes={props.gridStyle?.stripes}
/>
{footerRow}
</>
),
[alerts, actualGridStyle, pageIndex, pageSize, isLoadingAlerts, props.gridStyle?.stripes]
);
const sortProps = useMemo(() => {
return { columns: sortingColumns, onSort };
}, [sortingColumns, onSort]);
const columnVisibility = useMemo(() => {
return { visibleColumns, setVisibleColumns: onChangeVisibleColumns };
}, [visibleColumns, onChangeVisibleColumns]);
const rowStyles = useMemo(
() => css`
.alertsTableHighlightedRow {
background-color: ${euiThemeVars.euiColorHighlight};
}
.alertsTableActiveRow {
background-color: ${colorMode === 'LIGHT'
? tint(euiThemeVars.euiColorLightShade, 0.5)
: euiThemeVars.euiColorLightShade};
}
`,
[colorMode]
);
return (
<InspectButtonContainer>
<section style={{ width: '100%' }} data-test-subj={props['data-test-subj']}>
<Suspense fallback={null}>
{flyoutAlertIndex > -1 && (
<AlertsFlyout<AC>
{...renderContext}
alert={alerts[flyoutAlertIndex]}
alertsCount={alertsCount}
onClose={handleFlyoutClose}
flyoutIndex={flyoutAlertIndex + pageIndex * pageSize}
onPaginate={onPaginateFlyout}
/>
)}
</Suspense>
{alertsCount > 0 && (
<EuiDataGrid
{...euiDataGridProps}
// As per EUI docs, it is not recommended to switch between undefined and defined height.
// If user changes height, it is better to unmount and mount the component.
// Ref: https://eui.elastic.co/#/tabular-content/data-grid#virtualization
key={height ? 'fixedHeight' : 'autoHeight'}
ref={dataGridRef}
css={rowStyles}
aria-label="Alerts table"
data-test-subj="alertsTable"
height={height}
columns={columnsWithCellActions}
columnVisibility={columnVisibility}
trailingControlColumns={trailingControlColumns}
leadingControlColumns={leadingControlColumns}
rowCount={alertsCount}
renderCustomGridBody={dynamicRowHeight ? renderCustomGridBody : undefined}
cellContext={renderContext}
// Cast necessary because `cellContext` is untyped in EuiDataGrid
renderCellValue={CellValueHost as RenderCellValue}
renderCellPopover={CellPopoverHost}
gridStyle={actualGridStyle}
sorting={sortProps}
toolbarVisibility={toolbarVisibility}
pagination={dataGridPagination}
rowHeightsOptions={rowHeightsOptions}
onColumnResize={onColumnResize}
/>
)}
</section>
</InspectButtonContainer>
);
}
);
(AlertsDataGrid as FC).displayName = 'AlertsDataGrid';

View file

@ -1,54 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import React, { ComponentProps } from 'react';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { act } from 'react-dom/test-utils';
import { AlertsFlyout } from './alerts_flyout';
import { Alert, AlertsField } from '../../../../types';
import { AlertsField, FlyoutSectionRenderer } from '../types';
import { createPartialObjectMock } from '../utils/test';
type FlyoutProps = ComponentProps<FlyoutSectionRenderer>;
const onClose = jest.fn();
const onPaginate = jest.fn();
const props = {
const props = createPartialObjectMock<FlyoutProps>({
alert: {
[AlertsField.name]: ['one'],
[AlertsField.reason]: ['two'],
_id: '0123456789',
_index: '.alerts-default',
} as unknown as Alert,
alertsTableConfiguration: {
id: 'test',
casesFeatureId: 'testCases',
columns: [
{
id: AlertsField.name,
displayAsText: 'Name',
initialWidth: 150,
},
{
id: AlertsField.reason,
displayAsText: 'Reason',
initialWidth: 250,
},
],
useInternalFlyout: () => ({
body: () => <h3>Internal flyout body</h3>,
header: null,
footer: () => null,
}),
getRenderCellValue: jest.fn().mockImplementation((rcvProps) => {
return `${rcvProps.colIndex}:${rcvProps.rowIndex}`;
}),
[AlertsField.name]: ['one'],
[AlertsField.reason]: ['two'],
},
tableId: 'test',
columns: [
{
id: AlertsField.name,
displayAsText: 'Name',
initialWidth: 150,
},
{
id: AlertsField.reason,
displayAsText: 'Reason',
initialWidth: 250,
},
],
renderCellValue: jest.fn((rcvProps) => {
return (
<>
`${rcvProps.colIndex}:${rcvProps.rowIndex}`
</>
);
}),
renderFlyoutBody: () => <h3 data-test-subj="test-flyout-body">Internal flyout body</h3>,
flyoutIndex: 0,
alertsCount: 4,
isLoading: false,
onClose,
onPaginate,
};
});
describe('AlertsFlyout', () => {
afterEach(() => {
@ -61,25 +64,15 @@ describe('AlertsFlyout', () => {
await nextTick();
wrapper.update();
});
expect(wrapper.find('h3').first().text()).toBe('Internal flyout body');
expect(wrapper.find('[data-test-subj="test-flyout-body"]').first().text()).toBe(
'Internal flyout body'
);
});
const base = {
body: () => null,
header: () => null,
footer: () => null,
};
it(`should use header from useInternalFlyout configuration`, async () => {
const customProps = {
it(`should use header from the alerts table props`, async () => {
const customProps: FlyoutProps = {
...props,
alertsTableConfiguration: {
...props.alertsTableConfiguration,
useInternalFlyout: () => ({
...base,
header: () => <h4>Header</h4>,
footer: () => null,
}),
},
renderFlyoutHeader: () => <h4>Header</h4>,
};
const wrapper = mountWithIntl(<AlertsFlyout {...customProps} />);
await act(async () => {
@ -89,16 +82,10 @@ describe('AlertsFlyout', () => {
expect(wrapper.find('h4').first().text()).toBe('Header');
});
it(`should use body from useInternalFlyout configuration`, async () => {
const customProps = {
it(`should use body the alerts table props`, async () => {
const customProps: FlyoutProps = {
...props,
alertsTableConfiguration: {
...props.alertsTableConfiguration,
useInternalFlyout: () => ({
...base,
body: () => <h5>Body</h5>,
}),
},
renderFlyoutBody: () => <h5>Body</h5>,
};
const wrapper = mountWithIntl(<AlertsFlyout {...customProps} />);
await act(async () => {
@ -108,16 +95,10 @@ describe('AlertsFlyout', () => {
expect(wrapper.find('h5').first().text()).toBe('Body');
});
it(`should use footer from useInternalFlyout configuration`, async () => {
const customProps = {
it(`should use footer from the alerts table props`, async () => {
const customProps: FlyoutProps = {
...props,
alertsTableConfiguration: {
...props.alertsTableConfiguration,
useInternalFlyout: () => ({
...base,
footer: () => <h6>Footer</h6>,
}),
},
renderFlyoutFooter: () => <h6>Footer</h6>,
};
const wrapper = mountWithIntl(<AlertsFlyout {...customProps} />);
await act(async () => {

View file

@ -1,10 +1,13 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { Suspense, lazy, useCallback, useMemo, useRef, useEffect } from 'react';
import React, { Suspense, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFlyout,
@ -15,9 +18,16 @@ import {
EuiPagination,
EuiProgress,
} from '@elastic/eui';
import type { Alert, AlertsTableConfigurationRegistry } from '../../../../types';
import usePrevious from 'react-use/lib/usePrevious';
import type { Alert } from '@kbn/alerting-types';
import { DefaultAlertsFlyoutBody, DefaultAlertsFlyoutHeader } from './default_alerts_flyout';
import {
AdditionalContext,
FlyoutSectionProps,
FlyoutSectionRenderer,
RenderContext,
} from '../types';
const AlertsFlyoutHeader = lazy(() => import('./alerts_flyout_header'));
const PAGINATION_LABEL = i18n.translate(
'xpack.triggersActionsUI.sections.alertsTable.alertsFlyout.paginationLabel',
{
@ -25,93 +35,74 @@ const PAGINATION_LABEL = i18n.translate(
}
);
function usePrevious(alert: Alert) {
const ref = useRef<Alert | null>(null);
useEffect(() => {
if (alert) {
ref.current = alert;
}
});
return ref.current;
}
interface AlertsFlyoutProps {
export const AlertsFlyout = <AC extends AdditionalContext>({
alert,
...renderContext
}: RenderContext<AC> & {
alert: Alert;
alertsTableConfiguration: AlertsTableConfigurationRegistry;
flyoutIndex: number;
alertsCount: number;
isLoading: boolean;
onClose: () => void;
onPaginate: (pageIndex: number) => void;
id?: string;
}
export const AlertsFlyout: React.FunctionComponent<AlertsFlyoutProps> = ({
alert,
alertsTableConfiguration,
flyoutIndex,
alertsCount,
isLoading,
onClose,
onPaginate,
id,
}: AlertsFlyoutProps) => {
}) => {
const {
header: Header,
body: Body,
footer: Footer,
} = alertsTableConfiguration?.useInternalFlyout?.() ?? {
header: AlertsFlyoutHeader,
body: null,
footer: null,
};
flyoutIndex,
alertsCount,
onClose,
onPaginate,
isLoading,
renderFlyoutHeader: Header = DefaultAlertsFlyoutHeader,
renderFlyoutBody: Body = DefaultAlertsFlyoutBody,
renderFlyoutFooter,
} = renderContext;
const Footer: FlyoutSectionRenderer<AC> | undefined = renderFlyoutFooter;
const prevAlert = usePrevious(alert);
const passedProps = useMemo(
() => ({
alert: alert === undefined && prevAlert != null ? prevAlert : alert,
id,
isLoading,
}),
const props = useMemo(
() =>
({
...renderContext,
// Show the previous alert while loading the next one
alert: alert === undefined && prevAlert != null ? prevAlert : alert,
} as FlyoutSectionProps<AC>),
// eslint-disable-next-line react-hooks/exhaustive-deps
[alert, id, isLoading]
);
const FlyoutBody = useCallback(
() =>
Body ? (
<Suspense fallback={null}>
<Body {...passedProps} />
</Suspense>
) : null,
[Body, passedProps]
);
const FlyoutFooter = useCallback(
() =>
Footer ? (
<Suspense fallback={null}>
<Footer {...passedProps} />
</Suspense>
) : null,
[Footer, passedProps]
[alert, renderContext]
);
const FlyoutHeader = useCallback(
() =>
Header ? (
<Suspense fallback={null}>
<Header {...passedProps} />
<Header<AC> {...props} />
</Suspense>
) : null,
[Header, passedProps]
[Header, props]
);
const FlyoutBody = useCallback(
() =>
Body ? (
<Suspense fallback={null}>
<Body<AC> {...props} />
</Suspense>
) : null,
[Body, props]
);
const FlyoutFooter = useCallback(
() =>
Footer ? (
<Suspense fallback={null}>
<Footer {...props} />
</Suspense>
) : null,
[Footer, props]
);
return (
<EuiFlyout onClose={onClose} size="m" data-test-subj="alertsFlyout" ownFocus={false}>
{isLoading && <EuiProgress size="xs" color="accent" data-test-subj="alertsFlyoutLoading" />}
<EuiFlyoutHeader hasBorder>
<Suspense fallback={null}>
<FlyoutHeader />
</Suspense>
<FlyoutHeader />
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="none" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
@ -131,3 +122,8 @@ export const AlertsFlyout: React.FunctionComponent<AlertsFlyoutProps> = ({
</EuiFlyout>
);
};
// Lazy loading helpers
// eslint-disable-next-line import/no-default-export
export { AlertsFlyout as default };
export type AlertsFlyout = typeof AlertsFlyout;

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { render, screen, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AlertsQueryInspector } from './alerts_query_inspector';
import { AlertsQueryInspectorModal } from './alerts_query_inspector_modal';
jest.mock('./alerts_query_inspector_modal');
jest
.mocked(AlertsQueryInspectorModal.type)
.mockImplementation(() => <div data-test-subj="mocked-modal" />);
describe('AlertsQueryInspector', () => {
const alertsQuerySnapshot = {
request: [''],
response: [''],
};
afterEach(() => {
cleanup();
});
test('open Inspect Modal', async () => {
render(
<AlertsQueryInspector
inspectTitle={'Inspect Title'}
showInspectButton
alertsQuerySnapshot={alertsQuerySnapshot}
/>
);
await userEvent.click(await screen.findByTestId('inspect-icon-button'));
expect(await screen.findByTestId('mocked-modal')).toBeInTheDocument();
});
});

View file

@ -1,18 +1,20 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiButtonIcon } from '@elastic/eui';
import React, { useState, memo, useCallback } from 'react';
import { EsQuerySnapshot } from '@kbn/alerts-ui-shared';
import { EsQuerySnapshot } from '@kbn/alerting-types';
import { HoverVisibilityContainer } from './hover_visibility_container';
import { ModalInspectQuery } from './modal';
import * as i18n from './translations';
import { AlertsQueryInspectorModal } from './alerts_query_inspector_modal';
import * as i18n from '../translations';
export const BUTTON_CLASS = 'inspectButtonComponent';
const VISIBILITY_CLASSES = [BUTTON_CLASS];
@ -33,11 +35,14 @@ export const InspectButtonContainer: React.FC<InspectButtonContainerProps> = mem
interface InspectButtonProps {
onCloseInspect?: () => void;
showInspectButton?: boolean;
querySnapshot: EsQuerySnapshot;
alertsQuerySnapshot: EsQuerySnapshot;
inspectTitle: string;
}
const InspectButtonComponent: React.FC<InspectButtonProps> = ({ querySnapshot, inspectTitle }) => {
const AlertsQueryInspectorComponent: React.FC<InspectButtonProps> = ({
alertsQuerySnapshot,
inspectTitle,
}) => {
const [isShowingModal, setIsShowingModal] = useState(false);
const onOpenModal = useCallback(() => {
@ -60,10 +65,10 @@ const InspectButtonComponent: React.FC<InspectButtonProps> = ({ querySnapshot, i
onClick={onOpenModal}
/>
{isShowingModal && (
<ModalInspectQuery
<AlertsQueryInspectorModal
closeModal={onCloseModal}
data-test-subj="inspect-modal"
querySnapshot={querySnapshot}
alertsQuerySnapshot={alertsQuerySnapshot}
title={inspectTitle}
/>
)}
@ -71,5 +76,6 @@ const InspectButtonComponent: React.FC<InspectButtonProps> = ({ querySnapshot, i
);
};
InspectButtonComponent.displayName = 'InspectButtonComponent';
export const InspectButton = React.memo(InspectButtonComponent);
AlertsQueryInspectorComponent.displayName = 'AlertsQueryInspectorComponent';
export const AlertsQueryInspector = React.memo(AlertsQueryInspectorComponent);

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
@ -10,8 +12,8 @@ import { of } from 'rxjs';
import { fireEvent, render, screen } from '@testing-library/react';
import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import type { ModalInspectProps } from './modal';
import { ModalInspectQuery } from './modal';
import type { ModalInspectProps } from './alerts_query_inspector_modal';
import { AlertsQueryInspectorModal } from './alerts_query_inspector_modal';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
@ -32,12 +34,12 @@ const getRequest = (
const response =
'{"took": 880,"timed_out": false,"_shards": {"total": 26,"successful": 26,"skipped": 0,"failed": 0},"hits": {"max_score": null,"hits": []},"aggregations": {"hosts": {"value": 541},"hosts_histogram": {"buckets": [{"key_as_string": "2019 - 07 - 05T01: 00: 00.000Z", "key": 1562288400000, "doc_count": 1492321, "count": { "value": 105 }}, {"key_as_string": "2019 - 07 - 05T13: 00: 00.000Z", "key": 1562331600000, "doc_count": 2412761, "count": { "value": 453}},{"key_as_string": "2019 - 07 - 06T01: 00: 00.000Z", "key": 1562374800000, "doc_count": 111658, "count": { "value": 15}}],"interval": "12h"}},"status": 200}';
describe('Modal Inspect', () => {
describe('AlertsQueryInspectorModal', () => {
const closeModal = jest.fn();
const defaultProps: ModalInspectProps = {
closeModal,
title: 'Inspect',
querySnapshot: {
alertsQuerySnapshot: {
request: [getRequest()],
response: [response],
},
@ -46,7 +48,7 @@ describe('Modal Inspect', () => {
const renderModalInspectQuery = () => {
const theme = { theme$: of({ darkMode: false }) };
const userProfile = userProfileServiceMock.createStart();
return render(<ModalInspectQuery {...defaultProps} />, {
return render(<AlertsQueryInspectorModal {...defaultProps} />, {
wrapper: ({ children }) => (
<KibanaThemeProvider theme={theme} userProfile={userProfile}>
{children}

View file

@ -1,10 +1,13 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { type ReactNode } from 'react';
import {
EuiButton,
EuiCodeBlock,
@ -19,17 +22,14 @@ import {
EuiTabbedContent,
} from '@elastic/eui';
import numeral from '@elastic/numeral';
import { ReactNode } from 'react';
import React from 'react';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { isEmpty } from 'lodash';
import { EsQuerySnapshot } from '@kbn/alerts-ui-shared';
import * as i18n from './translations';
import type { EsQuerySnapshot } from '@kbn/alerting-types';
import { css } from '@emotion/react';
import * as i18n from '../translations';
export interface ModalInspectProps {
closeModal: () => void;
querySnapshot: EsQuerySnapshot;
alertsQuerySnapshot: EsQuerySnapshot;
title: string;
}
@ -48,19 +48,6 @@ interface Response {
aggregations: Record<string, unknown>;
}
const MyEuiModal = euiStyled(EuiModal)`
width: min(768px, calc(100vw - 16px));
min-height: 41vh;
.euiModal__flex {
width: 60vw;
}
.euiCodeBlock {
height: auto !important;
max-width: 718px;
}
`;
MyEuiModal.displayName = 'MyEuiModal';
const parse = function <T>(str: string): T {
try {
return JSON.parse(str);
@ -77,8 +64,12 @@ const stringify = (object: Request | Response): string => {
}
};
const ModalInspectQueryComponent = ({ closeModal, querySnapshot, title }: ModalInspectProps) => {
const { request, response } = querySnapshot;
const AlertsQueryInspectorModalComponent = ({
closeModal,
alertsQuerySnapshot,
title,
}: ModalInspectProps) => {
const { request, response } = alertsQuerySnapshot;
// using index 0 as there will be only one request and response for now
const parsedRequest: Request = parse(request[0]);
const parsedResponse: Response = parse(response[0]);
@ -188,7 +179,21 @@ const ModalInspectQueryComponent = ({ closeModal, querySnapshot, title }: ModalI
];
return (
<MyEuiModal onClose={closeModal} data-test-subj="modal-inspect-euiModal">
<EuiModal
onClose={closeModal}
data-test-subj="modal-inspect-euiModal"
css={css`
width: min(768px, calc(100vw - 16px));
min-height: 41vh;
.euiModal__flex {
width: 60vw;
}
.euiCodeBlock {
height: auto !important;
max-width: 718px;
}
`}
>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.INSPECT} {title}
@ -204,8 +209,8 @@ const ModalInspectQueryComponent = ({ closeModal, querySnapshot, title }: ModalI
{i18n.CLOSE}
</EuiButton>
</EuiModalFooter>
</MyEuiModal>
</EuiModal>
);
};
export const ModalInspectQuery = React.memo(ModalInspectQueryComponent);
export const AlertsQueryInspectorModal = React.memo(AlertsQueryInspectorModalComponent);

View file

@ -0,0 +1,954 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { FunctionComponent } from 'react';
import { BehaviorSubject } from 'rxjs';
import userEvent from '@testing-library/user-event';
import { get } from 'lodash';
import { render, waitFor, screen, act } from '@testing-library/react';
import { ALERT_CASE_IDS, ALERT_MAINTENANCE_WINDOW_IDS, ALERT_UUID } from '@kbn/rule-data-utils';
import type { Alert, LegacyField } from '@kbn/alerting-types';
import { settingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fetchAlertsFields } from '@kbn/alerts-ui-shared/src/common/apis/fetch_alerts_fields';
import { searchAlerts } from '@kbn/alerts-ui-shared/src/common/apis/search_alerts/search_alerts';
import { testQueryClientConfig } from '@kbn/alerts-ui-shared/src/common/test_utils/test_query_client_config';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { getMutedAlertsInstancesByRule } from '@kbn/response-ops-alerts-apis/apis/get_muted_alerts_instances_by_rule';
import { applicationServiceMock, notificationServiceMock } from '@kbn/core/public/mocks';
import { afterAll } from '@elastic/synthetics';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import {
AlertsDataGridProps,
AlertsTableProps,
AdditionalContext,
RenderContext,
AlertsField,
} from '../types';
import { AlertsTable } from './alerts_table';
import { AlertsDataGrid } from './alerts_data_grid';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { getCasesMock, createCasesServiceMock } from '../mocks/cases.mock';
import { getMaintenanceWindowsMock } from '../mocks/maintenance_windows.mock';
import { bulkGetCases } from '../apis/bulk_get_cases';
import { bulkGetMaintenanceWindows } from '../apis/bulk_get_maintenance_windows';
import { useLicense } from '../hooks/use_license';
import { getJsDomPerformanceFix } from '../utils/test';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
type BaseAlertsTableProps = AlertsTableProps;
// Search alerts mock
jest.mock('@kbn/alerts-ui-shared/src/common/apis/search_alerts/search_alerts');
const mockSearchAlerts = jest.mocked(searchAlerts);
const columns = [
{
id: AlertsField.name,
displayAsText: 'Name',
},
{
id: AlertsField.reason,
displayAsText: 'Reason',
},
{
id: ALERT_CASE_IDS,
displayAsText: 'Cases',
},
{
id: ALERT_MAINTENANCE_WINDOW_IDS,
displayAsText: 'Maintenance Windows',
},
];
const alerts: Alert[] = [
{
_id: 'test-1',
_index: 'alerts',
[AlertsField.name]: ['one'],
[AlertsField.reason]: ['two'],
[AlertsField.uuid]: ['1047d115-670d-469e-af7a-86fdd2b2f814'],
[ALERT_UUID]: ['alert-id-1'],
[ALERT_CASE_IDS]: ['test-id'],
[ALERT_MAINTENANCE_WINDOW_IDS]: ['test-mw-id-1'],
},
{
_id: 'test-2',
_index: 'alerts',
[AlertsField.name]: ['three'],
[AlertsField.reason]: ['four'],
[AlertsField.uuid]: ['bf5f6d63-5afd-48e0-baf6-f28c2b68db46'],
[ALERT_CASE_IDS]: ['test-id-2'],
[ALERT_MAINTENANCE_WINDOW_IDS]: ['test-mw-id-2'],
},
{
_id: 'test-3',
_index: 'alerts',
[AlertsField.name]: ['five'],
[AlertsField.reason]: ['six'],
[AlertsField.uuid]: ['1047d115-5afd-469e-baf6-f28c2b68db46'],
[ALERT_CASE_IDS]: [],
[ALERT_MAINTENANCE_WINDOW_IDS]: [],
},
];
const oldAlertsData = [
[
{
field: AlertsField.name,
value: ['one'],
},
{
field: AlertsField.reason,
value: ['two'],
},
],
[
{
field: AlertsField.name,
value: ['three'],
},
{
field: AlertsField.reason,
value: ['four'],
},
],
[
{
field: AlertsField.name,
value: ['five'],
},
{
field: AlertsField.reason,
value: ['six'],
},
],
] as LegacyField[][];
const ecsAlertsData = [
[
{
'@timestamp': ['2023-01-28T10:48:49.559Z'],
_id: 'SomeId',
_index: 'SomeIndex',
kibana: {
alert: {
rule: {
name: ['one'],
},
reason: ['two'],
},
},
},
],
[
{
'@timestamp': ['2023-01-27T10:48:49.559Z'],
_id: 'SomeId2',
_index: 'SomeIndex',
kibana: {
alert: {
rule: {
name: ['three'],
},
reason: ['four'],
},
},
},
],
[
{
'@timestamp': ['2023-01-26T10:48:49.559Z'],
_id: 'SomeId3',
_index: 'SomeIndex',
kibana: {
alert: {
rule: {
name: ['five'],
},
reason: ['six'],
},
},
},
],
];
const mockSearchAlertsResponse: Awaited<ReturnType<typeof searchAlerts>> = {
alerts,
ecsAlertsData,
oldAlertsData,
total: alerts.length,
querySnapshot: { request: [], response: [] },
};
// Alerts fields mock
jest.mock('@kbn/alerts-ui-shared/src/common/apis/fetch_alerts_fields');
jest.mocked(fetchAlertsFields).mockResolvedValue({
browserFields: {
kibana: {
fields: {
[AlertsField.uuid]: {
category: 'kibana',
name: AlertsField.uuid,
},
[AlertsField.name]: {
category: 'kibana',
name: AlertsField.name,
},
[AlertsField.reason]: {
category: 'kibana',
name: AlertsField.reason,
},
},
},
},
fields: [],
});
// Muted alerts mock
jest.mock('@kbn/response-ops-alerts-apis/apis/get_muted_alerts_instances_by_rule');
jest.mocked(getMutedAlertsInstancesByRule).mockResolvedValue({
data: [],
});
// Cases mock
jest.mock('../apis/bulk_get_cases');
const mockBulkGetCases = jest.mocked(bulkGetCases);
const mockCases = getCasesMock();
mockBulkGetCases.mockResolvedValue({ cases: mockCases, errors: [] });
// Maintenance windows mock
jest.mock('../apis/bulk_get_maintenance_windows');
jest.mock('../hooks/use_license');
const mockBulkGetMaintenanceWindows = jest.mocked(bulkGetMaintenanceWindows);
jest.mocked(useLicense).mockReturnValue({ isAtLeastPlatinum: () => true });
const mockMaintenanceWindows = getMaintenanceWindowsMock();
mockBulkGetMaintenanceWindows.mockResolvedValue({
maintenanceWindows: mockMaintenanceWindows,
errors: [],
});
// AlertsDataGrid mock
jest.mock('./alerts_data_grid', () => ({
AlertsDataGrid: jest.fn(),
}));
const mockAlertsDataGrid = jest.mocked(AlertsDataGrid);
const applicationMock = applicationServiceMock.createStartContract();
const mockCurrentAppId$ = new BehaviorSubject<string>('testAppId');
const mockCaseService = createCasesServiceMock();
const { fix, cleanup } = getJsDomPerformanceFix();
beforeAll(() => {
fix();
});
afterAll(() => {
cleanup();
});
const queryClient = new QueryClient(testQueryClientConfig);
const TestComponent: FunctionComponent<BaseAlertsTableProps> = (props) => (
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<AlertsTable {...props} />
</IntlProvider>
</QueryClientProvider>
);
describe('AlertsTable', () => {
// Storage mock
const mockStorageGet = jest.fn();
jest.mock('../utils/storage', () => ({
Storage: jest.fn().mockReturnValue({
get: mockStorageGet,
set: jest.fn(),
}),
}));
const tableProps: BaseAlertsTableProps = {
id: 'test-alerts-table',
ruleTypeIds: ['logs'],
query: {},
columns,
initialPageSize: 10,
renderActionsCell: ({ openAlertInFlyout }) => {
return (
<button
data-test-subj="expandColumnCellOpenFlyoutButton-0"
onClick={() => {
openAlertInFlyout('alert-id-1');
}}
/>
);
},
renderFlyoutBody: ({ alert }) => (
<ul>
{columns.map((column) => (
<li data-test-subj={`alertsFlyout${column.displayAsText}`} key={column.id}>
{get(alert as any, column.id, [])[0]}
</li>
))}
</ul>
),
services: {
http: httpServiceMock.createStartContract(),
application: {
...applicationMock,
getUrlForApp: jest.fn(() => ''),
capabilities: {
...applicationMock.capabilities,
cases: {
create_cases: true,
read_cases: true,
update_cases: true,
delete_cases: true,
push_cases: true,
},
maintenanceWindow: {
show: true,
},
},
currentAppId$: mockCurrentAppId$,
},
data: dataPluginMock.createStartContract(),
fieldFormats: fieldFormatsMock,
licensing: licensingMock.createStart(),
notifications: notificationServiceMock.createStartContract(),
settings: settingsServiceMock.createStartContract(),
},
};
let onChangePageIndex: AlertsDataGridProps['onChangePageIndex'];
let refresh: RenderContext<AdditionalContext>['refresh'];
mockAlertsDataGrid.mockImplementation((props) => {
const { AlertsDataGrid: ActualAlertsDataGrid } = jest.requireActual('./alerts_data_grid');
onChangePageIndex = props.onChangePageIndex;
refresh = props.renderContext.refresh;
return <ActualAlertsDataGrid {...props} />;
});
beforeEach(() => {
jest.clearAllMocks();
mockSearchAlerts.mockResolvedValue(mockSearchAlertsResponse);
});
describe('Cases', () => {
const casesTableProps = {
...tableProps,
services: {
...tableProps.services,
cases: mockCaseService,
},
};
beforeEach(() => {
jest.clearAllMocks();
mockCaseService.helpers.canUseCases = jest.fn().mockReturnValue({ create: true, read: true });
});
afterAll(() => {
mockCaseService.ui.getCasesContext = jest.fn().mockImplementation(() => null);
});
it('should show the cases column', async () => {
render(<TestComponent {...casesTableProps} />);
expect(await screen.findByText('Cases')).toBeInTheDocument();
});
it('should show the cases titles correctly', async () => {
render(<TestComponent {...casesTableProps} />);
expect(await screen.findByText('Test case')).toBeInTheDocument();
expect(await screen.findByText('Test case 2')).toBeInTheDocument();
});
it('should show the loading skeleton when fetching cases', async () => {
mockBulkGetCases.mockResolvedValue({ cases: mockCases, errors: [] });
render(<TestComponent {...casesTableProps} />);
expect((await screen.findAllByTestId('cases-cell-loading')).length).toBe(3);
});
it('should pass the correct case ids to useBulkGetCases', async () => {
render(<TestComponent {...casesTableProps} />);
await waitFor(() => {
expect(mockBulkGetCases).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ ids: ['test-id', 'test-id-2'] }),
expect.anything()
);
});
});
it('remove duplicated case ids', async () => {
mockSearchAlerts.mockResolvedValue({
...mockSearchAlertsResponse,
alerts: [...mockSearchAlertsResponse.alerts, ...mockSearchAlertsResponse.alerts],
});
render(<TestComponent {...casesTableProps} />);
await waitFor(() => {
expect(mockBulkGetCases).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ ids: ['test-id', 'test-id-2'] }),
expect.anything()
);
});
});
it('skips alerts with empty case ids', async () => {
mockSearchAlerts.mockResolvedValue({
...mockSearchAlertsResponse,
alerts: [
{
...mockSearchAlertsResponse.alerts[0],
'kibana.alert.case_ids': [],
},
mockSearchAlertsResponse.alerts[1],
],
});
render(<TestComponent {...casesTableProps} />);
await waitFor(() => {
expect(mockBulkGetCases).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ ids: ['test-id-2'] }),
expect.anything()
);
});
});
it('should not fetch cases if the user does not have permissions', async () => {
mockCaseService.helpers.canUseCases = jest
.fn()
.mockReturnValue({ create: false, read: false });
render(<TestComponent {...casesTableProps} />);
await waitFor(() => {
expect(mockBulkGetCases).not.toHaveBeenCalled();
});
});
it('should not fetch cases if the column is not visible', async () => {
mockCaseService.helpers.canUseCases = jest.fn().mockReturnValue({ create: true, read: true });
const props: BaseAlertsTableProps = {
...casesTableProps,
casesConfiguration: { featureId: 'test-feature-id', owner: ['cases'] },
};
render(
<TestComponent
{...props}
columns={[
{
id: AlertsField.name,
displayAsText: 'Name',
},
]}
/>
);
await waitFor(() => {
expect(mockBulkGetCases).not.toHaveBeenCalled();
});
});
it('calls canUseCases with an empty array if the case configuration is not defined', async () => {
render(<TestComponent {...casesTableProps} />);
expect(mockCaseService.helpers.canUseCases).toHaveBeenCalledWith([]);
});
it('calls canUseCases with the case owner if defined', async () => {
const props: BaseAlertsTableProps = {
...casesTableProps,
casesConfiguration: { featureId: 'test-feature-id', owner: ['cases'] },
};
render(<TestComponent {...props} />);
expect(mockCaseService.helpers.canUseCases).toHaveBeenCalledWith(['cases']);
});
it('should call the cases context with the correct props', async () => {
const props: BaseAlertsTableProps = {
...casesTableProps,
casesConfiguration: { featureId: 'test-feature-id', owner: ['cases'] },
};
const CasesContextMock = jest.fn().mockReturnValue(null);
mockCaseService.ui.getCasesContext = jest.fn().mockReturnValue(CasesContextMock);
render(<TestComponent {...props} />);
expect(CasesContextMock).toHaveBeenCalledWith(
{
children: expect.anything(),
owner: ['cases'],
permissions: { create: true, read: true },
features: { alerts: { sync: false } },
},
{}
);
});
it('should call the cases context with the empty owner if the case config is not defined', async () => {
const CasesContextMock = jest.fn().mockReturnValue(null);
mockCaseService.ui.getCasesContext = jest.fn().mockReturnValue(CasesContextMock);
render(<TestComponent {...casesTableProps} />);
expect(CasesContextMock).toHaveBeenCalledWith(
{
children: expect.anything(),
owner: [],
permissions: { create: true, read: true },
features: { alerts: { sync: false } },
},
{}
);
});
it('should call the cases context with correct permissions', async () => {
const CasesContextMock = jest.fn().mockReturnValue(null);
mockCaseService.ui.getCasesContext = jest.fn().mockReturnValue(CasesContextMock);
mockCaseService.helpers.canUseCases = jest
.fn()
.mockReturnValue({ create: false, read: false });
render(<TestComponent {...casesTableProps} />);
expect(CasesContextMock).toHaveBeenCalledWith(
{
children: expect.anything(),
owner: [],
permissions: { create: false, read: false },
features: { alerts: { sync: false } },
},
{}
);
});
it('should call the cases context with sync alerts turned on if defined in the cases config', async () => {
const props: BaseAlertsTableProps = {
...casesTableProps,
casesConfiguration: {
featureId: 'test-feature-id',
owner: ['cases'],
syncAlerts: true,
},
};
const CasesContextMock = jest.fn().mockReturnValue(null);
mockCaseService.ui.getCasesContext = jest.fn().mockReturnValue(CasesContextMock);
render(<TestComponent {...props} />);
expect(CasesContextMock).toHaveBeenCalledWith(
{
children: expect.anything(),
owner: ['cases'],
permissions: { create: true, read: true },
features: { alerts: { sync: true } },
},
{}
);
});
});
describe('Maintenance windows', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should show maintenance windows column', async () => {
render(<TestComponent {...tableProps} />);
expect(await screen.findByText('Maintenance Windows')).toBeInTheDocument();
});
it('should show maintenance windows titles correctly', async () => {
render(<TestComponent {...tableProps} />);
expect(await screen.findByText('test-title')).toBeInTheDocument();
expect(await screen.findByText('test-title-2')).toBeInTheDocument();
});
it('should pass the correct maintenance window ids to useBulkGetMaintenanceWindows', async () => {
render(<TestComponent {...tableProps} />);
await waitFor(() => {
expect(mockBulkGetMaintenanceWindows).toHaveBeenCalledWith(
expect.objectContaining({
ids: ['test-mw-id-1', 'test-mw-id-2'],
})
);
});
});
it('should remove duplicated maintenance window ids', async () => {
mockSearchAlerts.mockResolvedValue({
...mockSearchAlertsResponse,
alerts: [...mockSearchAlertsResponse.alerts, ...mockSearchAlertsResponse.alerts],
});
render(<TestComponent {...tableProps} />);
await waitFor(() => {
expect(mockBulkGetMaintenanceWindows).toHaveBeenCalledWith(
expect.objectContaining({
ids: ['test-mw-id-1', 'test-mw-id-2'],
})
);
});
});
it('should skip alerts with empty maintenance window ids', async () => {
mockSearchAlerts.mockResolvedValue({
...mockSearchAlertsResponse,
alerts: [
{
...mockSearchAlertsResponse.alerts[0],
'kibana.alert.maintenance_window_ids': [],
},
mockSearchAlertsResponse.alerts[1],
],
});
render(<TestComponent {...tableProps} />);
await waitFor(() => {
expect(mockBulkGetMaintenanceWindows).toHaveBeenCalledWith(
expect.objectContaining({
ids: ['test-mw-id-2'],
})
);
});
});
it('should show loading skeleton when fetching maintenance windows', async () => {
mockBulkGetMaintenanceWindows.mockResolvedValue({
maintenanceWindows: mockMaintenanceWindows,
errors: [],
});
render(<TestComponent {...tableProps} />);
expect((await screen.findAllByTestId('maintenance-window-cell-loading')).length).toBe(1);
});
it('should not fetch maintenance windows if the user does not have permission', async () => {});
it('should not fetch maintenance windows if the column is not visible', async () => {
render(
<TestComponent
{...tableProps}
columns={[
{
id: AlertsField.name,
displayAsText: 'Name',
},
]}
/>
);
await waitFor(() => {
expect(mockBulkGetMaintenanceWindows).not.toHaveBeenCalled();
});
});
});
describe('flyout', () => {
it('should show a flyout when selecting an alert', async () => {
const wrapper = render(<TestComponent {...tableProps} />);
await userEvent.click(wrapper.queryAllByTestId('expandColumnCellOpenFlyoutButton-0')[0]!);
const result = await wrapper.findAllByTestId('alertsFlyout');
expect(result.length).toBe(1);
expect(wrapper.queryByTestId('alertsFlyoutName')?.textContent).toBe('one');
expect(wrapper.queryByTestId('alertsFlyoutReason')?.textContent).toBe('two');
// Should paginate too
await userEvent.click(wrapper.queryAllByTestId('pagination-button-next')[0]);
expect(wrapper.queryByTestId('alertsFlyoutName')?.textContent).toBe('three');
expect(wrapper.queryByTestId('alertsFlyoutReason')?.textContent).toBe('four');
await userEvent.click(wrapper.queryAllByTestId('pagination-button-previous')[0]);
expect(wrapper.queryByTestId('alertsFlyoutName')?.textContent).toBe('one');
expect(wrapper.queryByTestId('alertsFlyoutReason')?.textContent).toBe('two');
});
it('should refetch data if flyout pagination exceeds the current page', async () => {
render(
<TestComponent
{...{
...tableProps,
initialPageSize: 1,
}}
/>
);
await userEvent.click(await screen.findByTestId('expandColumnCellOpenFlyoutButton-0'));
const result = await screen.findAllByTestId('alertsFlyout');
expect(result.length).toBe(1);
mockSearchAlerts.mockClear();
await userEvent.click(await screen.findByTestId('pagination-button-next'));
expect(mockSearchAlerts).toHaveBeenCalledWith(
expect.objectContaining({
pageIndex: 1,
pageSize: 1,
})
);
mockSearchAlerts.mockClear();
await userEvent.click(await screen.findByTestId('pagination-button-previous'));
expect(mockSearchAlerts).toHaveBeenCalledWith(
expect.objectContaining({
pageIndex: 0,
pageSize: 1,
})
);
});
it('Should be able to go back from last page to n - 1', async () => {
render(
<TestComponent
{...{
...tableProps,
initialPageSize: 2,
}}
/>
);
await userEvent.click(
(
await screen.findAllByTestId('expandColumnCellOpenFlyoutButton-0')
)[0]
);
const result = await screen.findAllByTestId('alertsFlyout');
expect(result.length).toBe(1);
mockSearchAlerts.mockClear();
await userEvent.click(await screen.findByTestId('pagination-button-last'));
expect(mockSearchAlerts).toHaveBeenCalledWith(
expect.objectContaining({
pageIndex: 1,
pageSize: 2,
})
);
mockSearchAlerts.mockClear();
await userEvent.click(await screen.findByTestId('pagination-button-previous'));
expect(mockSearchAlerts).toHaveBeenCalledWith(
expect.objectContaining({
pageIndex: 0,
pageSize: 2,
})
);
});
});
describe('field browser', () => {
beforeEach(() => {
jest.clearAllMocks();
mockBulkGetCases.mockResolvedValue({ cases: [], errors: [] });
mockBulkGetMaintenanceWindows.mockResolvedValue({
maintenanceWindows: mockMaintenanceWindows,
errors: [],
});
});
it('should show field browser', async () => {
render(<TestComponent {...tableProps} />);
expect(await screen.findByTestId('show-field-browser')).toBeInTheDocument();
});
it('should remove an already existing element when selected', async () => {
render(<TestComponent {...tableProps} />);
expect(screen.queryByTestId(`dataGridHeaderCell-${AlertsField.name}`)).not.toBe(null);
await userEvent.click(await screen.findByTestId('show-field-browser'));
const fieldCheckbox = screen.getByTestId(`field-${AlertsField.name}-checkbox`);
await userEvent.click(fieldCheckbox);
await userEvent.click(screen.getByTestId('close'));
await waitFor(() => {
expect(screen.queryByTestId(`dataGridHeaderCell-${AlertsField.name}`)).toBe(null);
});
});
it('should restore a default element that has been removed previously', async () => {
mockStorageGet.mockClear();
mockStorageGet.mockReturnValue({
columns: [{ displayAsText: 'Reason', id: AlertsField.reason, schema: undefined }],
sort: [
{
[AlertsField.reason]: {
order: 'asc',
},
},
],
visibleColumns: [AlertsField.reason],
});
render(<TestComponent {...tableProps} />);
expect(screen.queryByTestId(`dataGridHeaderCell-${AlertsField.name}`)).toBe(null);
await userEvent.click(await screen.findByTestId('show-field-browser'));
const fieldCheckbox = screen.getByTestId(`field-${AlertsField.name}-checkbox`);
await userEvent.click(fieldCheckbox);
await userEvent.click(screen.getByTestId('close'));
await waitFor(() => {
expect(screen.queryByTestId(`dataGridHeaderCell-${AlertsField.name}`)).not.toBe(null);
const titles: string[] = [];
screen
.getByTestId('dataGridHeader')
.querySelectorAll('.euiDataGridHeaderCell__content')
.forEach((n) => titles.push(n?.getAttribute('title') ?? ''));
expect(titles).toContain('Name');
});
});
it('should insert a new field as column when its not a default one', async () => {
const { getByTestId, queryByTestId } = render(<TestComponent {...tableProps} />);
expect(queryByTestId(`dataGridHeaderCell-${AlertsField.uuid}`)).toBe(null);
await userEvent.click(getByTestId('show-field-browser'));
const fieldCheckbox = getByTestId(`field-${AlertsField.uuid}-checkbox`);
await userEvent.click(fieldCheckbox);
await userEvent.click(getByTestId('close'));
await waitFor(() => {
expect(queryByTestId(`dataGridHeaderCell-${AlertsField.uuid}`)).not.toBe(null);
expect(
queryByTestId(`dataGridHeaderCell-${AlertsField.uuid}`)!
.querySelector('.euiDataGridHeaderCell__content')!
.getAttribute('title')
).toBe(AlertsField.uuid);
});
});
});
const testPersistentControls = () => {
describe('persistent controls', () => {
it('should show persistent controls if set', async () => {
const props: BaseAlertsTableProps = {
...tableProps,
renderAdditionalToolbarControls: () => <span>This is a persistent control</span>,
};
render(<TestComponent {...props} />);
expect(await screen.findByText('This is a persistent control')).toBeInTheDocument();
});
});
};
testPersistentControls();
const testInspectButton = () => {
describe('inspect button', () => {
it('should hide the inspect button by default', () => {
render(<TestComponent {...tableProps} />);
expect(screen.queryByTestId('inspect-icon-button')).not.toBeInTheDocument();
});
it('should show the inspect button if the right prop is set', async () => {
const props: BaseAlertsTableProps = {
...tableProps,
showInspectButton: true,
};
render(<TestComponent {...props} />);
expect(await screen.findByTestId('inspect-icon-button')).toBeInTheDocument();
});
});
};
testInspectButton();
describe('empty state', () => {
beforeEach(() => {
mockSearchAlerts.mockResolvedValue({
alerts: [],
oldAlertsData: [],
ecsAlertsData: [],
total: 0,
querySnapshot: { request: [], response: [] },
});
});
it('should render an empty screen if there are no alerts', async () => {
render(<TestComponent {...tableProps} />);
expect(await screen.findByTestId('alertsTableEmptyState')).toBeTruthy();
});
testInspectButton();
describe('when persistent controls are set', () => {
testPersistentControls();
});
});
describe('Client provided toolbar visibility options', () => {
it('hide column order control', () => {
const props: BaseAlertsTableProps = {
...tableProps,
toolbarVisibility: { showColumnSelector: false },
};
render(<TestComponent {...props} />);
expect(screen.queryByTestId('dataGridColumnSelectorButton')).not.toBeInTheDocument();
});
it('hide sort Selection', () => {
const customTableProps: BaseAlertsTableProps = {
...tableProps,
toolbarVisibility: { showSortSelector: false },
};
render(<TestComponent {...customTableProps} />);
expect(screen.queryByTestId('dataGridColumnSortingButton')).not.toBeInTheDocument();
});
});
describe('Pagination', () => {
it('resets the page index when any query parameter changes', () => {
mockSearchAlerts.mockResolvedValue({
...mockSearchAlertsResponse,
alerts: Array.from({ length: 100 }).map((_, i) => ({
_id: `${i}`,
_index: 'alerts',
[AlertsField.uuid]: [`alert-${i}`],
})),
});
const { rerender } = render(<TestComponent {...tableProps} />);
act(() => {
onChangePageIndex(1);
});
rerender(
<TestComponent
{...tableProps}
query={{ bool: { filter: [{ term: { 'kibana.alert.rule.name': 'test' } }] } }}
/>
);
expect(mockSearchAlerts).toHaveBeenLastCalledWith(expect.objectContaining({ pageIndex: 0 }));
});
it('resets the page index when refetching alerts', () => {
mockSearchAlerts.mockResolvedValue({
...mockSearchAlertsResponse,
alerts: Array.from({ length: 100 }).map((_, i) => ({
_id: `${i}`,
_index: 'alerts',
[AlertsField.uuid]: [`alert-${i}`],
})),
});
render(<TestComponent {...tableProps} />);
act(() => {
onChangePageIndex(1);
});
act(() => {
refresh();
});
expect(mockSearchAlerts).toHaveBeenLastCalledWith(expect.objectContaining({ pageIndex: 0 }));
});
});
});

View file

@ -0,0 +1,616 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, {
useState,
useCallback,
useRef,
useMemo,
useReducer,
useEffect,
useImperativeHandle,
forwardRef,
Ref,
memo,
FC,
} from 'react';
import { isEmpty } from 'lodash';
import {
EuiDataGridColumn,
EuiProgress,
EuiDataGridSorting,
EuiDataGridControlColumn,
EuiDataGridRefProps,
} from '@elastic/eui';
import {
ALERT_CASE_IDS,
ALERT_MAINTENANCE_WINDOW_IDS,
ALERT_RULE_UUID,
ALERT_UUID,
} from '@kbn/rule-data-utils';
import type { RuleRegistrySearchRequestPagination } from '@kbn/rule-registry-plugin/common';
import type { SortCombinations } from '@elastic/elasticsearch/lib/api/types';
import { QueryClientProvider, useQueryClient } from '@tanstack/react-query';
import { useSearchAlertsQuery } from '@kbn/alerts-ui-shared/src/common/hooks/use_search_alerts_query';
import { DEFAULT_ALERTS_PAGE_SIZE } from '@kbn/alerts-ui-shared/src/common/constants';
import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
import deepEqual from 'fast-deep-equal';
import { Alert } from '@kbn/alerting-types';
import { useGetMutedAlertsQuery } from '@kbn/response-ops-alerts-apis/hooks/use_get_muted_alerts_query';
import { queryKeys as alertsQueryKeys } from '@kbn/response-ops-alerts-apis/constants';
import { ErrorFallback } from './error_fallback';
import { defaultAlertsTableColumns } from '../configuration';
import { Storage } from '../utils/storage';
import { queryKeys } from '../constants';
import { AlertsDataGrid } from './alerts_data_grid';
import { EmptyState } from './empty_state';
import { RenderContext, RowSelectionState } from '../types';
import {
AdditionalContext,
AlertsDataGridProps,
AlertsTableImperativeApi,
AlertsTableProps,
} from '../types';
import { bulkActionsReducer } from '../reducers/bulk_actions_reducer';
import { useColumns } from '../hooks/use_columns';
import { InspectButtonContainer } from './alerts_query_inspector';
import { alertsTableQueryClient } from '../query_client';
import { useBulkGetCasesQuery } from '../hooks/use_bulk_get_cases';
import { useBulkGetMaintenanceWindowsQuery } from '../hooks/use_bulk_get_maintenance_windows';
import { AlertsTableContextProvider } from '../contexts/alerts_table_context';
import { ErrorBoundary } from './error_boundary';
import { usePagination } from '../hooks/use_pagination';
import { typedForwardRef } from '../utils/react';
export interface AlertsTablePersistedConfiguration {
columns: EuiDataGridColumn[];
visibleColumns?: string[];
sort: SortCombinations[];
}
type AlertWithCaseIds = Alert & Required<Pick<Alert, typeof ALERT_CASE_IDS>>;
type AlertWithMaintenanceWindowIds = Alert &
Required<Pick<Alert, typeof ALERT_MAINTENANCE_WINDOW_IDS>>;
const getCaseIdsFromAlerts = (alerts: Alert[]) => [
...new Set(
alerts
.filter((alert): alert is AlertWithCaseIds => {
const caseIds = alert[ALERT_CASE_IDS];
return caseIds != null && caseIds.length > 0;
})
.map((alert) => alert[ALERT_CASE_IDS] as string[])
.flat()
),
];
const getRuleIdsFromAlerts = (alerts: Alert[]) => [
...new Set(alerts.map((a) => a[ALERT_RULE_UUID]![0] as string)),
];
const getMaintenanceWindowIdsFromAlerts = (alerts: Alert[]) => [
...new Set(
alerts
.filter((alert): alert is AlertWithMaintenanceWindowIds => {
const maintenanceWindowIds = alert[ALERT_MAINTENANCE_WINDOW_IDS];
return maintenanceWindowIds != null && maintenanceWindowIds.length > 0;
})
.map((alert) => alert[ALERT_MAINTENANCE_WINDOW_IDS] as string[])
.flat()
),
];
const isCasesColumnEnabled = (columns: EuiDataGridColumn[]): boolean =>
columns.some(({ id }) => id === ALERT_CASE_IDS);
const isMaintenanceWindowColumnEnabled = (columns: EuiDataGridColumn[]): boolean =>
columns.some(({ id }) => id === ALERT_MAINTENANCE_WINDOW_IDS);
const emptyRowSelection = new Map<number, RowSelectionState>();
const initialBulkActionsState = {
rowSelection: emptyRowSelection,
isAllSelected: false,
areAllVisibleRowsSelected: false,
rowCount: 0,
updatedAt: Date.now(),
};
/**
* An `EuiDataGrid` abstraction to render alert documents
*
* It manages the paginated and cached fetching of alerts based on the
* provided `ruleTypeIds` and `consumers` (the final query can be refined
* through the `query` and `initialSort` props). The `id` prop is required in order
* to persist the table state in `localStorage`
*
* @example
* ```tsx
* <AlertsTable
* id="my-alerts-table"
* ruleTypeIds={ruleTypeIds}
* consumers={consumers}
* query={esQuery}
* initialSort={defaultAlertsTableSort}
* renderCellValue={CellValue}
* renderActionsCell={ActionsCell}
* services={{ ... }}
* />
* ```
*/
export const AlertsTable = memo(
forwardRef((props, ref) => {
return (
<QueryClientProvider client={alertsTableQueryClient} context={AlertsQueryContext}>
<ErrorBoundary fallback={ErrorFallback}>
<AlertsTableContent {...props} ref={ref} />
</ErrorBoundary>
</QueryClientProvider>
);
})
// Type cast to avoid losing the generic type
) as typeof AlertsTableContent;
(AlertsTable as FC).displayName = 'AlertsTable';
const DEFAULT_LEADING_CONTROL_COLUMNS: EuiDataGridControlColumn[] = [];
const DEFAULT_SORT: SortCombinations[] = [];
const DEFAULT_COLUMNS: EuiDataGridColumn[] = [];
const AlertsTableContent = typedForwardRef(
<AC extends AdditionalContext>(
{
id,
ruleTypeIds,
consumers,
query,
initialSort = DEFAULT_SORT,
initialPageSize = DEFAULT_ALERTS_PAGE_SIZE,
leadingControlColumns = DEFAULT_LEADING_CONTROL_COLUMNS,
trailingControlColumns,
rowHeightsOptions,
columns: initialColumns = defaultAlertsTableColumns,
gridStyle,
browserFields: propBrowserFields,
onUpdate,
onLoaded,
runtimeMappings,
showAlertStatusWithFlapping,
toolbarVisibility,
shouldHighlightRow,
dynamicRowHeight = false,
emptyStateHeight,
additionalContext,
renderCellValue,
renderCellPopover,
renderActionsCell,
renderFlyoutHeader,
renderFlyoutBody,
renderFlyoutFooter,
renderAdditionalToolbarControls: AdditionalToolbarControlsComponent,
lastReloadRequestTime,
services,
...publicDataGridProps
}: AlertsTableProps<AC>,
ref: Ref<AlertsTableImperativeApi>
) => {
// Memoized so that consumers can pass an inline object without causing re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
const memoizedServices = useMemo(() => services, Object.values(services));
const { casesConfiguration, showInspectButton } = publicDataGridProps;
const { data, cases: casesService, http, notifications, application, licensing } = services;
const queryClient = useQueryClient({ context: AlertsQueryContext });
const storage = useRef(new Storage(window.localStorage));
const dataGridRef = useRef<EuiDataGridRefProps>(null);
const localStorageAlertsTableConfig = storage.current.get(
id
) as Partial<AlertsTablePersistedConfiguration>;
const columnsLocal = useMemo(
() =>
!isEmpty(localStorageAlertsTableConfig?.columns)
? localStorageAlertsTableConfig!.columns!
: !isEmpty(initialColumns)
? initialColumns!
: [],
[initialColumns, localStorageAlertsTableConfig]
);
const getStorageConfig = useCallback(
() => ({
columns: columnsLocal,
sort: !isEmpty(localStorageAlertsTableConfig?.sort)
? localStorageAlertsTableConfig!.sort!
: initialSort ?? [],
visibleColumns: !isEmpty(localStorageAlertsTableConfig?.visibleColumns)
? localStorageAlertsTableConfig!.visibleColumns!
: columnsLocal.map((c) => c.id),
}),
[columnsLocal, localStorageAlertsTableConfig, initialSort]
);
const storageAlertsTable = useRef<AlertsTablePersistedConfiguration>(getStorageConfig());
storageAlertsTable.current = getStorageConfig();
const [sort, setSort] = useState<SortCombinations[]>(storageAlertsTable.current.sort);
const onPageChange = useCallback((pagination: RuleRegistrySearchRequestPagination) => {
setQueryParams((prevQueryParams) => ({
...prevQueryParams,
pageSize: pagination.pageSize,
pageIndex: pagination.pageIndex,
}));
}, []);
const {
columns,
browserFields,
isBrowserFieldDataLoading,
onToggleColumn,
onResetColumns,
visibleColumns,
onChangeVisibleColumns,
onColumnResize,
fields,
} = useColumns({
ruleTypeIds,
storageAlertsTable,
storage,
id,
defaultColumns: initialColumns ?? DEFAULT_COLUMNS,
alertsFields: propBrowserFields,
http,
});
const [queryParams, setQueryParams] = useState({
ruleTypeIds,
consumers,
fields,
query,
sort,
runtimeMappings,
pageIndex: 0,
pageSize: initialPageSize,
});
useEffect(() => {
setQueryParams(({ pageIndex: oldPageIndex, pageSize: oldPageSize, ...prevQueryParams }) => ({
ruleTypeIds,
consumers,
fields,
query,
sort,
runtimeMappings,
// Go back to the first page if the query changes
pageIndex: !deepEqual(prevQueryParams, {
ruleTypeIds,
consumers,
fields,
query,
sort,
runtimeMappings,
})
? 0
: oldPageIndex,
pageSize: oldPageSize,
}));
}, [ruleTypeIds, fields, query, runtimeMappings, sort, consumers]);
const {
data: alertsData,
refetch: refetchAlerts,
isSuccess,
isFetching: isLoadingAlerts,
} = useSearchAlertsQuery({
data,
...queryParams,
});
const {
alerts = [],
oldAlertsData = [],
ecsAlertsData = [],
total: alertsCount = -1,
querySnapshot: alertsQuerySnapshot,
} = alertsData ?? {};
useEffect(() => {
if (onLoaded && !isLoadingAlerts && isSuccess) {
onLoaded(alerts, columns);
}
}, [alerts, columns, isLoadingAlerts, isSuccess, onLoaded]);
const ruleIds = useMemo(() => getRuleIdsFromAlerts(alerts), [alerts]);
const mutedAlertsQuery = useGetMutedAlertsQuery({
ruleIds,
http,
notifications,
});
const caseIds = useMemo(() => getCaseIdsFromAlerts(alerts), [alerts]);
const casesPermissions = useMemo(() => {
return casesService?.helpers.canUseCases(casesConfiguration?.owner ?? []);
}, [casesConfiguration?.owner, casesService?.helpers]);
const casesQuery = useBulkGetCasesQuery(
{ caseIds, http, notifications },
{
enabled: isCasesColumnEnabled(columns) && !!casesPermissions?.read,
}
);
const maintenanceWindowIds = useMemo(() => getMaintenanceWindowIdsFromAlerts(alerts), [alerts]);
const maintenanceWindowsQuery = useBulkGetMaintenanceWindowsQuery(
{ ids: maintenanceWindowIds, http, application, notifications, licensing },
{ enabled: isMaintenanceWindowColumnEnabled(columns), context: AlertsQueryContext }
);
const refresh = useCallback(() => {
if (queryParams.pageIndex !== 0) {
// Refetch from the first page
setQueryParams((prevQueryParams) => ({ ...prevQueryParams, pageIndex: 0 }));
} else {
refetchAlerts();
}
queryClient.invalidateQueries(queryKeys.casesBulkGet(caseIds));
queryClient.invalidateQueries(alertsQueryKeys.mutedAlerts(ruleIds));
queryClient.invalidateQueries(queryKeys.maintenanceWindowsBulkGet(maintenanceWindowIds));
}, [caseIds, maintenanceWindowIds, queryClient, queryParams.pageIndex, refetchAlerts, ruleIds]);
useEffect(() => {
if (lastReloadRequestTime) {
refresh();
}
}, [lastReloadRequestTime, refresh]);
useImperativeHandle(ref, () => ({
refresh,
toggleColumn: onToggleColumn,
}));
const [bulkActionsState, dispatchBulkAction] = useReducer(
bulkActionsReducer,
initialBulkActionsState
);
const bulkActionsStore = useMemo(
() =>
[bulkActionsState, dispatchBulkAction] as [
typeof bulkActionsState,
typeof dispatchBulkAction
],
[bulkActionsState, dispatchBulkAction]
);
const onSortChange = useCallback(
(_sort: EuiDataGridSorting['columns']) => {
const newSort = _sort.map((sortItem) => {
return {
[sortItem.id]: {
order: sortItem.direction,
},
};
});
storageAlertsTable.current = {
...storageAlertsTable.current,
sort: newSort,
};
storage.current.set(id, storageAlertsTable.current);
setSort(newSort);
},
[id]
);
const CasesContext = useMemo(() => {
return casesService?.ui.getCasesContext();
}, [casesService?.ui]);
const isCasesContextAvailable = casesService && CasesContext;
const {
pagination,
onChangePageSize,
onChangePageIndex,
onPaginateFlyout,
flyoutAlertIndex,
setFlyoutAlertIndex,
} = usePagination({
bulkActionsStore,
onPageChange,
pageIndex: queryParams.pageIndex,
pageSize: queryParams.pageSize,
});
// TODO when every solution is using this table, we will be able to simplify it by just passing the alert index
const openAlertInFlyout = useCallback(
(alertId: string) => {
const idx = alerts.findIndex((a) => (a as any)[ALERT_UUID].includes(alertId));
setFlyoutAlertIndex(idx);
},
[alerts, setFlyoutAlertIndex]
);
const renderContext = useMemo(
() =>
({
...additionalContext,
columns,
tableId: id,
dataGridRef,
refresh,
isLoading:
isLoadingAlerts ||
casesQuery.isFetching ||
maintenanceWindowsQuery.isFetching ||
mutedAlertsQuery.isFetching,
isLoadingAlerts,
alerts,
alertsCount,
// TODO deprecate
ecsAlertsData,
oldAlertsData,
browserFields,
isLoadingCases: casesQuery.isFetching,
cases: casesQuery.data,
isLoadingMaintenanceWindows: maintenanceWindowsQuery.isFetching,
maintenanceWindows: maintenanceWindowsQuery.data,
isLoadingMutedAlerts: mutedAlertsQuery.isFetching,
mutedAlerts: mutedAlertsQuery.data,
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
showAlertStatusWithFlapping,
openAlertInFlyout,
bulkActionsStore,
renderCellValue,
renderCellPopover,
renderActionsCell,
renderFlyoutHeader,
renderFlyoutBody,
renderFlyoutFooter,
services: memoizedServices,
} as RenderContext<AC>),
[
additionalContext,
columns,
id,
refresh,
isLoadingAlerts,
casesQuery.isFetching,
casesQuery.data,
maintenanceWindowsQuery.isFetching,
maintenanceWindowsQuery.data,
mutedAlertsQuery.isFetching,
mutedAlertsQuery.data,
alerts,
alertsCount,
ecsAlertsData,
oldAlertsData,
browserFields,
pagination.pageIndex,
pagination.pageSize,
showAlertStatusWithFlapping,
openAlertInFlyout,
bulkActionsStore,
renderCellValue,
renderCellPopover,
renderActionsCell,
renderFlyoutHeader,
renderFlyoutBody,
renderFlyoutFooter,
memoizedServices,
]
);
useEffect(() => {
if (onUpdate) {
onUpdate(renderContext);
}
}, [onUpdate, renderContext]);
const additionalToolbarControls = useMemo(
() =>
AdditionalToolbarControlsComponent ? (
<AdditionalToolbarControlsComponent {...renderContext} />
) : undefined,
[AdditionalToolbarControlsComponent, renderContext]
);
const dataGridProps: AlertsDataGridProps<AC> = useMemo(
() => ({
...publicDataGridProps,
renderContext,
additionalToolbarControls,
leadingControlColumns,
trailingControlColumns,
visibleColumns,
'data-test-subj': 'internalAlertsState',
onToggleColumn,
onResetColumns,
onChangeVisibleColumns,
onColumnResize,
query,
rowHeightsOptions,
gridStyle,
toolbarVisibility,
shouldHighlightRow,
dynamicRowHeight,
ruleTypeIds,
alertsQuerySnapshot,
onChangePageIndex,
onChangePageSize,
onPaginateFlyout,
flyoutAlertIndex,
setFlyoutAlertIndex,
sort,
onSortChange,
}),
[
publicDataGridProps,
renderContext,
additionalToolbarControls,
leadingControlColumns,
trailingControlColumns,
visibleColumns,
onToggleColumn,
onResetColumns,
onChangeVisibleColumns,
onColumnResize,
query,
rowHeightsOptions,
gridStyle,
toolbarVisibility,
shouldHighlightRow,
dynamicRowHeight,
ruleTypeIds,
alertsQuerySnapshot,
onChangePageIndex,
onChangePageSize,
onPaginateFlyout,
flyoutAlertIndex,
setFlyoutAlertIndex,
sort,
onSortChange,
]
);
return (
<AlertsTableContextProvider value={renderContext}>
{!isLoadingAlerts && alertsCount <= 0 && (
<InspectButtonContainer>
<EmptyState
additionalToolbarControls={additionalToolbarControls}
alertsQuerySnapshot={alertsQuerySnapshot}
showInspectButton={showInspectButton}
height={emptyStateHeight}
/>
</InspectButtonContainer>
)}
{(isLoadingAlerts || isBrowserFieldDataLoading) && (
<EuiProgress size="xs" color="accent" data-test-subj="internalAlertsPageLoading" />
)}
{alertsCount > 0 &&
(isCasesContextAvailable ? (
<CasesContext
owner={casesConfiguration?.owner ?? []}
permissions={casesPermissions}
features={{ alerts: { sync: casesConfiguration?.syncAlerts ?? false } }}
>
<AlertsDataGrid {...dataGridProps} />
</CasesContext>
) : (
<AlertsDataGrid {...dataGridProps} />
))}
</AlertsTableContextProvider>
);
}
);
// Lazy loading helpers
// eslint-disable-next-line import/no-default-export
export { AlertsTable as default };
export type AlertsTable = typeof AlertsTable;

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiCheckbox, EuiLoadingSpinner, RenderCellValue } from '@elastic/eui';
import React, { ChangeEvent, memo, useCallback } from 'react';
import { SELECT_ROW_ARIA_LABEL } from '../translations';
import { useAlertsTableContext } from '../contexts/alerts_table_context';
import { BulkActionsVerbs } from '../types';
export const BulkActionsCell = memo(
({
visibleRowIndex: rowIndex,
}: {
/**
* Defining manually since the {@renderCellValue} type is missing this prop
* @external https://github.com/elastic/eui/issues/5811
*/
visibleRowIndex: number;
}) => {
const {
bulkActionsStore: [{ rowSelection }, updateSelectedRows],
} = useAlertsTableContext();
const isChecked = rowSelection.has(rowIndex);
const isLoading = isChecked && rowSelection.get(rowIndex)?.isLoading;
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
updateSelectedRows({ action: BulkActionsVerbs.add, rowIndex });
} else {
updateSelectedRows({ action: BulkActionsVerbs.delete, rowIndex });
}
},
[rowIndex, updateSelectedRows]
);
if (isLoading) {
return <EuiLoadingSpinner size="m" data-test-subj="row-loader" />;
}
// NOTE: id is prefixed here to avoid conflicts with labels in other sections in the app.
// see https://github.com/elastic/kibana/issues/162837
return (
<EuiCheckbox
id={`bulk-actions-row-cell-${rowIndex}`}
aria-label={SELECT_ROW_ARIA_LABEL(rowIndex + 1)}
checked={isChecked}
onChange={onChange}
data-test-subj="bulk-actions-row-cell"
/>
);
}
// See props comment above
) as unknown as RenderCellValue;

View file

@ -1,20 +1,22 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiCheckbox } from '@elastic/eui';
import React, { ChangeEvent, useContext, useCallback } from 'react';
import { BulkActionsVerbs } from '../../../../../types';
import React, { ChangeEvent, useCallback } from 'react';
import { BulkActionsVerbs } from '../types';
import { COLUMN_HEADER_ARIA_LABEL } from '../translations';
import { AlertsTableContext } from '../../contexts/alerts_table_context';
import { useAlertsTableContext } from '../contexts/alerts_table_context';
const BulkActionsHeaderComponent: React.FunctionComponent = () => {
const {
bulkActions: [{ isAllSelected, areAllVisibleRowsSelected }, updateSelectedRows],
} = useContext(AlertsTableContext);
bulkActionsStore: [{ isAllSelected, areAllVisibleRowsSelected }, updateSelectedRows],
} = useAlertsTableContext();
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {

View file

@ -1,14 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { EuiPopover, EuiButtonEmpty, EuiContextMenu } from '@elastic/eui';
import numeral from '@elastic/numeral';
import React, { useState, useCallback, useMemo, useContext, useEffect } from 'react';
import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
import {
ALERT_CASE_IDS,
ALERT_RULE_NAME,
@ -16,43 +17,28 @@ import {
ALERT_WORKFLOW_ASSIGNEE_IDS,
ALERT_WORKFLOW_TAGS,
} from '@kbn/rule-data-utils';
import {
Alerts,
BulkActionsPanelConfig,
BulkActionsVerbs,
RowSelection,
} from '../../../../../types';
import { Alert } from '@kbn/alerting-types';
import useObservable from 'react-use/lib/useObservable';
import type { SettingsStart } from '@kbn/core-ui-settings-browser';
import { BulkActionsPanelConfig, BulkActionsVerbs, RowSelection, TimelineItem } from '../types';
import * as i18n from '../translations';
import { AlertsTableContext } from '../../contexts/alerts_table_context';
import { useAlertsTableContext } from '../contexts/alerts_table_context';
interface BulkActionsProps {
totalItems: number;
panels: BulkActionsPanelConfig[];
alerts: Alerts;
alerts: Alert[];
setIsBulkActionsLoading: (loading: boolean) => void;
clearSelection: () => void;
refresh: () => void;
}
// Duplicated just for legacy reasons. Timelines plugin will be removed but
// as long as the integration still work with Timelines we have to keep it
export interface TimelineItem {
_id: string;
_index?: string | null;
data: TimelineNonEcsData[];
ecs: { _id: string; _index: string };
}
export interface TimelineNonEcsData {
field: string;
value?: string[] | null;
settings: SettingsStart;
}
const DEFAULT_NUMBER_FORMAT = 'format:number:defaultPattern';
const containerStyles = { display: 'inline-block', position: 'relative' } as const;
const selectedIdsToTimelineItemMapper = (
alerts: Alerts,
alerts: Alert[],
rowSelection: RowSelection
): TimelineItem[] => {
return Array.from(rowSelection.keys()).map((rowIndex: number) => {
@ -61,11 +47,14 @@ const selectedIdsToTimelineItemMapper = (
_id: alert._id,
_index: alert._index,
data: [
{ field: ALERT_RULE_NAME, value: alert[ALERT_RULE_NAME] },
{ field: ALERT_RULE_UUID, value: alert[ALERT_RULE_UUID] },
{ field: ALERT_CASE_IDS, value: alert[ALERT_CASE_IDS] ?? [] },
{ field: ALERT_WORKFLOW_TAGS, value: alert[ALERT_WORKFLOW_TAGS] ?? [] },
{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: alert[ALERT_WORKFLOW_ASSIGNEE_IDS] ?? [] },
{ field: ALERT_RULE_NAME, value: alert[ALERT_RULE_NAME] as string[] },
{ field: ALERT_RULE_UUID, value: alert[ALERT_RULE_UUID] as string[] },
{ field: ALERT_CASE_IDS, value: (alert[ALERT_CASE_IDS] ?? []) as string[] },
{ field: ALERT_WORKFLOW_TAGS, value: (alert[ALERT_WORKFLOW_TAGS] ?? []) as string[] },
{
field: ALERT_WORKFLOW_ASSIGNEE_IDS,
value: (alert[ALERT_WORKFLOW_ASSIGNEE_IDS] ?? []) as string[],
},
],
ecs: {
_id: alert._id,
@ -84,12 +73,12 @@ const useBulkActionsToMenuPanelMapper = (
clearSelection: BulkActionsProps['clearSelection'],
// In case bulk item action changes the alert data and need to refresh table page.
refresh: BulkActionsProps['refresh'],
alerts: Alerts,
alerts: Alert[],
closeIfPopoverIsOpen: () => void
) => {
const {
bulkActions: [{ isAllSelected, rowSelection }],
} = useContext(AlertsTableContext);
bulkActionsStore: [{ isAllSelected, rowSelection }],
} = useAlertsTableContext();
const bulkActionsPanels = useMemo(() => {
const bulkActionPanelsToReturn = [];
@ -153,16 +142,20 @@ const BulkActionsComponent: React.FC<BulkActionsProps> = ({
setIsBulkActionsLoading,
clearSelection,
refresh,
settings,
}) => {
const {
bulkActions: [{ rowSelection, isAllSelected }, updateSelectedRows],
} = useContext(AlertsTableContext);
bulkActionsStore: [{ rowSelection, isAllSelected }, updateSelectedRows],
} = useAlertsTableContext();
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false);
const [defaultNumberFormat] = useUiSetting$<string>(DEFAULT_NUMBER_FORMAT);
const [showClearSelection, setShowClearSelectiong] = useState(false);
const defaultNumberFormat = useObservable<string>(
useMemo(() => settings.client.get$(DEFAULT_NUMBER_FORMAT), [settings.client]),
settings.client.get(DEFAULT_NUMBER_FORMAT)
);
const [showClearSelection, setShowClearSelection] = useState(false);
useEffect(() => {
setShowClearSelectiong(isAllSelected);
setShowClearSelection(isAllSelected);
}, [isAllSelected]);
const selectedCount = rowSelection.size;
@ -265,6 +258,6 @@ const BulkActionsComponent: React.FC<BulkActionsProps> = ({
);
};
// disabled to be able lazy load
// Lazy loading helpers
// eslint-disable-next-line import/no-default-export
export default React.memo(BulkActionsComponent);

View file

@ -0,0 +1,136 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Alert } from '@kbn/alerting-types';
import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { CasesCell } from './cases_cell';
import { AdditionalContext, CellComponentProps, RenderContext } from '../types';
import { getCasesMapMock } from '../mocks/cases.mock';
import { getMaintenanceWindowsMapMock } from '../mocks/maintenance_windows.mock';
import { useCaseViewNavigation } from '../hooks/use_case_view_navigation';
import { createPartialObjectMock } from '../utils/test';
import { AlertsTableContextProvider } from '../contexts/alerts_table_context';
jest.mock('../hooks/use_case_view_navigation');
const useCaseViewNavigationMock = jest.mocked(useCaseViewNavigation);
const casesMap = getCasesMapMock();
const maintenanceWindowsMap = getMaintenanceWindowsMapMock();
const alert: Alert = {
_id: 'alert-id',
_index: 'alert-index',
'kibana.alert.case_ids': ['test-id'],
};
const props = createPartialObjectMock<CellComponentProps>({
isLoading: false,
alert,
cases: casesMap,
maintenanceWindows: maintenanceWindowsMap,
columnId: 'kibana.alert.case_ids',
showAlertStatusWithFlapping: false,
});
const context = createPartialObjectMock<RenderContext<AdditionalContext>>({
services: {
application: applicationServiceMock.createStartContract(),
},
});
const navigateToCaseView = jest.fn();
useCaseViewNavigationMock.mockReturnValue({ navigateToCaseView });
const TestComponent = (_props: CellComponentProps) => (
<IntlProvider locale="en">
<AlertsTableContextProvider value={context}>
<CasesCell {..._props} />
</AlertsTableContextProvider>
</IntlProvider>
);
describe('CasesCell', () => {
it('renders the cases cell', async () => {
render(<TestComponent {...props} />);
expect(screen.getByText('Test case')).toBeInTheDocument();
});
it('renders the loading skeleton', async () => {
render(<TestComponent {...props} isLoading={true} />);
expect(screen.getByTestId('cases-cell-loading')).toBeInTheDocument();
});
it('renders multiple cases correctly', async () => {
render(
<TestComponent
{...props}
alert={{ ...alert, 'kibana.alert.case_ids': ['test-id', 'test-id-2'] }}
/>
);
expect(screen.getByText('Test case')).toBeInTheDocument();
expect(screen.getByText('Test case 2')).toBeInTheDocument();
});
it('does not render a case that it is in the map but not in the alerts data', async () => {
render(<TestComponent {...props} />);
expect(screen.getByText('Test case')).toBeInTheDocument();
expect(screen.queryByText('Test case 2')).not.toBeInTheDocument();
});
it('does not show any cases when the alert does not have any case ids', async () => {
render(<TestComponent {...props} alert={{ ...alert, 'kibana.alert.case_ids': [] }} />);
expect(screen.queryByText('Test case')).not.toBeInTheDocument();
expect(screen.queryByText('Test case 2')).not.toBeInTheDocument();
});
it('does show the default value when the alert does not have any case ids', async () => {
render(<TestComponent {...props} alert={{ ...alert, 'kibana.alert.case_ids': [] }} />);
expect(screen.getByText('--')).toBeInTheDocument();
});
it('does not show any cases when the alert has invalid case ids', async () => {
render(
<TestComponent {...props} alert={{ ...alert, 'kibana.alert.case_ids': ['not-exist'] }} />
);
expect(screen.queryByTestId('cases-cell-link')).not.toBeInTheDocument();
});
it('does show the default value when the alert has invalid case ids', async () => {
render(
<TestComponent {...props} alert={{ ...alert, 'kibana.alert.case_ids': ['not-exist'] }} />
);
expect(screen.getByText('--')).toBeInTheDocument();
});
it('shows the cases tooltip', async () => {
render(<TestComponent {...props} />);
expect(screen.getByText('Test case')).toBeInTheDocument();
await userEvent.hover(screen.getByText('Test case'));
expect(await screen.findByTestId('cases-components-tooltip')).toBeInTheDocument();
});
it('navigates to the case correctly', async () => {
render(<TestComponent {...props} />);
expect(screen.getByText('Test case')).toBeInTheDocument();
await userEvent.click(screen.getByText('Test case'));
expect(navigateToCaseView).toBeCalledWith({ caseId: 'test-id' });
});
});

View file

@ -1,8 +1,10 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { memo } from 'react';
@ -10,9 +12,10 @@ import { EuiLink, EuiSkeletonText } from '@elastic/eui';
import { Tooltip as CaseTooltip } from '@kbn/cases-components';
import type { CaseTooltipContentProps } from '@kbn/cases-components';
import { ALERT_CASE_IDS } from '@kbn/rule-data-utils';
import { CellComponentProps } from '../types';
import { useCaseViewNavigation } from './use_case_view_navigation';
import { Case } from '../hooks/apis/bulk_get_cases';
import { useAlertsTableContext } from '../contexts/alerts_table_context';
import type { CellComponent } from '../types';
import { useCaseViewNavigation } from '../hooks/use_case_view_navigation';
import type { Case } from '../apis/bulk_get_cases';
const formatCase = (theCase: Case): CaseTooltipContentProps => ({
title: theCase.title,
@ -26,14 +29,17 @@ const formatCase = (theCase: Case): CaseTooltipContentProps => ({
totalComments: theCase.totalComment,
});
const CasesCellComponent: React.FC<CellComponentProps> = (props) => {
export const CasesCell: CellComponent = memo((props) => {
const { isLoading, alert, cases, caseAppId } = props;
const { navigateToCaseView } = useCaseViewNavigation(caseAppId);
const {
services: { application },
} = useAlertsTableContext();
const { navigateToCaseView } = useCaseViewNavigation(application, caseAppId);
const caseIds = (alert && alert[ALERT_CASE_IDS]) ?? [];
const caseIds = (alert && (alert[ALERT_CASE_IDS] as string[])) ?? [];
const validCases = caseIds
.map((id) => cases.get(id))
.map((id) => cases?.get(id))
.filter((theCase): theCase is Case => theCase != null);
return (
@ -53,8 +59,4 @@ const CasesCellComponent: React.FC<CellComponentProps> = (props) => {
: '--'}
</EuiSkeletonText>
);
};
CasesCellComponent.displayName = 'CasesCell';
export const CasesCell = memo(CasesCellComponent);
});

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useEffect } from 'react';
import { screen, render } from '@testing-library/react';
import { EuiDataGridCellPopoverElementProps } from '@elastic/eui/src/components/datagrid/data_grid_types';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { AlertsTableContextProvider } from '../contexts/alerts_table_context';
import { CellPopoverHost } from './cell_popover_host';
import { createPartialObjectMock } from '../utils/test';
import { mockRenderContext } from '../mocks/context.mock';
const props = createPartialObjectMock<EuiDataGridCellPopoverElementProps>({
rowIndex: 0,
DefaultCellPopover: jest.fn().mockReturnValue(<div data-test-subj="defaultCellPopover" />),
});
describe('CellPopoverHost', () => {
it('should render the renderCellPopover when provided', () => {
render(
<AlertsTableContextProvider
value={{
...mockRenderContext,
renderCellPopover: jest.fn(() => <div data-test-subj="renderCellPopover" />),
}}
>
<CellPopoverHost {...props} />
</AlertsTableContextProvider>
);
expect(screen.getByTestId('renderCellPopover')).toBeInTheDocument();
});
it('should catch errors from the custom CellPopover', async () => {
const CustomCellPopover = () => {
useEffect(() => {
throw new Error('test error');
}, []);
return null;
};
render(
<IntlProvider locale="en">
<AlertsTableContextProvider
value={{
...mockRenderContext,
renderCellPopover: CustomCellPopover,
}}
>
<CellPopoverHost {...props} />
</AlertsTableContextProvider>
</IntlProvider>
);
expect(await screen.findByTestId('errorCell')).toBeInTheDocument();
});
it('should render the DefaultCellPopover when renderCellPopover is not provided', () => {
render(
<AlertsTableContextProvider value={mockRenderContext}>
<CellPopoverHost {...props} />
</AlertsTableContextProvider>
);
expect(screen.getByTestId('defaultCellPopover')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiDataGridCellPopoverElementProps } from '@elastic/eui/src/components/datagrid/data_grid_types';
import React from 'react';
import { useAlertsTableContext } from '../contexts/alerts_table_context';
import { ErrorBoundary } from './error_boundary';
import { ErrorCell } from './error_cell';
/**
* Entry point for rendering cell popovers
*
* Wraps the provided `CellPopover` with an `ErrorBoundary` to catch any errors
*/
export const CellPopoverHost = (props: EuiDataGridCellPopoverElementProps) => {
const { rowIndex, DefaultCellPopover } = props;
const renderContext = useAlertsTableContext();
const { pageSize, pageIndex, alerts, renderCellPopover: CellPopover } = renderContext;
const idx = rowIndex - pageSize * pageIndex;
const alert = alerts[idx];
if (alert && CellPopover) {
return (
<ErrorBoundary fallback={ErrorCell}>
<CellPopover {...renderContext} {...props} alert={alert} />
</ErrorBoundary>
);
}
return <DefaultCellPopover {...props} />;
};

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { ComponentProps, FC } from 'react';
import { render, screen } from '@testing-library/react';
import { CellValueHost } from './cell_value_host';
import { createPartialObjectMock } from '../utils/test';
import { ALERT_CASE_IDS, ALERT_MAINTENANCE_WINDOW_IDS, ALERT_STATUS } from '@kbn/rule-data-utils';
import { DefaultCellValue } from './default_cell_value';
jest.mock('./system_cell', () => {
const original = jest.requireActual('./system_cell');
return {
...original,
SystemCell: jest.fn(() => <div data-test-subj="systemCell" />),
};
});
jest.mock('./default_cell_value');
jest.mocked(DefaultCellValue as FC).mockReturnValue(<div data-test-subj="defaultCell" />);
const props = createPartialObjectMock<ComponentProps<typeof CellValueHost>>({
isLoading: false,
alerts: [
{
_id: 'test',
_index: 'alerts',
[ALERT_STATUS]: ['active'],
},
],
oldAlertsData: [],
ecsAlertsData: [],
showAlertStatusWithFlapping: false,
casesConfig: undefined,
rowIndex: 0,
pageIndex: 0,
pageSize: 10,
});
describe('CellValueHost', () => {
it.each([ALERT_STATUS, ALERT_CASE_IDS, ALERT_MAINTENANCE_WINDOW_IDS])(
'should render a SystemCell for cases, maintenance windows, and alert status',
(columnId) => {
render(<CellValueHost {...props} columnId={columnId} />);
expect(screen.getByTestId('systemCell')).toBeInTheDocument();
}
);
it('should render the provided renderCellValue for other fields', () => {
render(
<CellValueHost
{...props}
columnId="otherField"
renderCellValue={jest.fn(() => (
<div data-test-subj="customRenderCellValue" />
))}
/>
);
expect(screen.getByTestId('customRenderCellValue')).toBeInTheDocument();
});
it('should render a DefaultCell for other fields when a custom renderCellValue is not defined', () => {
render(<CellValueHost {...props} columnId="otherField" />);
expect(screen.getByTestId('defaultCell')).toBeInTheDocument();
});
it('should render a loading skeleton when the isLoading prop is true and the alert is not available yet', () => {
render(<CellValueHost {...props} columnId="otherField" isLoading alerts={[]} />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiSkeletonText } from '@elastic/eui';
import React from 'react';
import { GetAlertsTableProp, SystemCellId } from '../types';
import { DefaultCellValue } from './default_cell_value';
import { SystemCell, systemCells } from './system_cell';
import { ErrorBoundary } from './error_boundary';
import { ErrorCell } from './error_cell';
/**
* Entry point for rendering cell values
*
* Renders a SystemCell for cases, maintenance windows, and alert status, the `renderCellValue`
* provided in the `AlertsTableProps` for other fields or the default cell value renderer otherwise.
* When the alerts or the related cases or maintenance windows are loading, a skeleton text is rendered.
*/
export const CellValueHost: GetAlertsTableProp<'renderCellValue'> = (props) => {
const {
columnId,
renderCellValue: CellValue = DefaultCellValue,
isLoading,
alerts,
oldAlertsData,
ecsAlertsData,
cases,
maintenanceWindows,
showAlertStatusWithFlapping,
casesConfig,
rowIndex,
pageIndex,
pageSize,
} = props;
const idx = rowIndex - pageSize * pageIndex;
const alert = alerts[idx];
const legacyAlert = oldAlertsData[idx];
const ecsAlert = ecsAlertsData[idx];
if (isSystemCell(columnId) && alert) {
return (
<SystemCell
{...props}
alert={alert}
columnId={columnId}
isLoading={isLoading}
cases={cases}
maintenanceWindows={maintenanceWindows}
showAlertStatusWithFlapping={showAlertStatusWithFlapping ?? false}
caseAppId={casesConfig?.appId}
/>
);
}
if (alert) {
return (
<ErrorBoundary fallback={ErrorCell}>
<CellValue {...props} alert={alert} legacyAlert={legacyAlert} ecsAlert={ecsAlert} />
</ErrorBoundary>
);
}
if (isLoading) {
return <EuiSkeletonText lines={1} />;
}
return null;
};
const isSystemCell = (columnId: string): columnId is SystemCellId => {
return systemCells.includes(columnId as SystemCellId);
};

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { memo } from 'react';
import { ALERTS_TABLE_CONTROL_COLUMNS_ACTIONS_LABEL } from '../translations';
export const ControlColumnHeaderCell = memo(() => {
return (
<span data-test-subj="expandColumnHeaderLabel">
{ALERTS_TABLE_CONTROL_COLUMNS_ACTIONS_LABEL}
</span>
);
});

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { DefaultAlertActions } from './default_alert_actions';
import { render, screen } from '@testing-library/react';
import { AdditionalContext, AlertActionsProps, RenderContext } from '../types';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { createPartialObjectMock } from '../utils/test';
import { AlertsTableContextProvider } from '../contexts/alerts_table_context';
jest.mock('@kbn/alerts-ui-shared/src/common/hooks/use_load_rule_types_query', () => ({
useLoadRuleTypesQuery: jest.fn(),
}));
jest.mock('./view_rule_details_alert_action', () => {
return {
ViewRuleDetailsAlertAction: () => (
<div data-test-subj="viewRuleDetailsAlertAction">{'ViewRuleDetailsAlertAction'}</div>
),
};
});
jest.mock('./view_alert_details_alert_action', () => {
return {
ViewAlertDetailsAlertAction: () => (
<div data-test-subj="viewAlertDetailsAlertAction">{'ViewAlertDetailsAlertAction'}</div>
),
};
});
jest.mock('./mute_alert_action', () => {
return { MuteAlertAction: () => <div data-test-subj="muteAlertAction">{'MuteAlertAction'}</div> };
});
jest.mock('./mark_as_untracked_alert_action', () => {
return {
MarkAsUntrackedAlertAction: () => (
<div data-test-subj="markAsUntrackedAlertAction">{'MarkAsUntrackedAlertAction'}</div>
),
};
});
const { useLoadRuleTypesQuery } = jest.requireMock(
'@kbn/alerts-ui-shared/src/common/hooks/use_load_rule_types_query'
);
const http = httpServiceMock.createStartContract();
const notifications = notificationServiceMock.createStartContract();
const props = createPartialObjectMock<AlertActionsProps>({
alert: {},
refresh: jest.fn(),
});
const context = createPartialObjectMock<RenderContext<AdditionalContext>>({
services: {
http,
notifications,
},
});
const TestComponent = (_props: AlertActionsProps) => (
<AlertsTableContextProvider value={context}>
<DefaultAlertActions {..._props} />
</AlertsTableContextProvider>
);
describe('DefaultAlertActions', () => {
it('should show "Mute" and "Marked as untracked" option', async () => {
useLoadRuleTypesQuery.mockReturnValue({ authorizedToCreateAnyRules: true });
render(<TestComponent {...props} />);
expect(await screen.findByText('MuteAlertAction')).toBeInTheDocument();
expect(await screen.findByText('MarkAsUntrackedAlertAction')).toBeInTheDocument();
});
it('should hide "Mute" and "Marked as untracked" option', async () => {
useLoadRuleTypesQuery.mockReturnValue({ authorizedToCreateAnyRules: false });
render(<TestComponent {...props} />);
expect(screen.queryByText('MuteAlertAction')).not.toBeInTheDocument();
expect(screen.queryByText('MarkAsUntrackedAlertAction')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { useLoadRuleTypesQuery } from '@kbn/alerts-ui-shared/src/common/hooks';
import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
import { ViewRuleDetailsAlertAction } from './view_rule_details_alert_action';
import type { AdditionalContext, AlertActionsProps } from '../types';
import { ViewAlertDetailsAlertAction } from './view_alert_details_alert_action';
import { MuteAlertAction } from './mute_alert_action';
import { MarkAsUntrackedAlertAction } from './mark_as_untracked_alert_action';
import { useAlertsTableContext } from '../contexts/alerts_table_context';
/**
* Common alerts table row actions
*/
export const DefaultAlertActions = <AC extends AdditionalContext = AdditionalContext>(
props: AlertActionsProps<AC>
) => {
const {
services: {
http,
notifications: { toasts },
},
} = useAlertsTableContext();
const { authorizedToCreateAnyRules } = useLoadRuleTypesQuery({
filteredRuleTypes: [],
http,
toasts,
context: AlertsQueryContext,
});
return (
<>
<ViewRuleDetailsAlertAction {...props} />
<ViewAlertDetailsAlertAction {...props} />
{authorizedToCreateAnyRules && <MarkAsUntrackedAlertAction {...props} />}
{authorizedToCreateAnyRules && <MuteAlertAction {...props} />}
</>
);
};

View file

@ -1,17 +1,27 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { waitFor } from '@testing-library/react';
import { mount } from 'enzyme';
import type { ReactWrapper } from 'enzyme';
import React from 'react';
import { getDefaultAlertFlyout } from './default_alerts_flyout';
import { AlertsTableFlyoutBaseProps } from '../../../..';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import {
AdditionalContext,
AlertsTableFlyoutBaseProps,
FlyoutSectionProps,
RenderContext,
} from '../types';
import { DefaultAlertsFlyoutBody } from './default_alerts_flyout';
import { createPartialObjectMock } from '../utils/test';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { AlertsTableContextProvider } from '../contexts/alerts_table_context';
const columns = [
{
@ -84,11 +94,27 @@ const tabsData = [
{ name: 'Table', subj: 'tableTab' },
];
const context = createPartialObjectMock<RenderContext<AdditionalContext>>({
services: {
http: httpServiceMock.createStartContract(),
fieldFormats: fieldFormatsMock,
},
});
describe('DefaultAlertsFlyout', () => {
let wrapper: ReactWrapper;
beforeAll(async () => {
const { body: FlyoutBody } = getDefaultAlertFlyout(columns, (_columnId, value) => value)();
wrapper = mount(<FlyoutBody alert={alert} isLoading={false} />) as ReactWrapper;
wrapper = mount(
<AlertsTableContextProvider value={context}>
<DefaultAlertsFlyoutBody
{...createPartialObjectMock<FlyoutSectionProps>({
alert,
isLoading: false,
columns,
})}
/>
</AlertsTableContextProvider>
) as ReactWrapper;
await waitFor(() => wrapper.update());
});

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, useMemo, useState } from 'react';
import { EuiDescriptionList, EuiPanel, EuiTabbedContentTab, EuiTitle } from '@elastic/eui';
import { ALERT_RULE_NAME } from '@kbn/rule-data-utils';
import { i18n } from '@kbn/i18n';
import { ScrollableFlyoutTabbedContent, AlertFieldsTable } from '@kbn/alerts-ui-shared';
import { AdditionalContext, FlyoutSectionProps } from '../types';
import { defaultAlertsTableColumns } from '../configuration';
import { DefaultCellValue } from './default_cell_value';
export const DefaultAlertsFlyoutHeader = <AC extends AdditionalContext>({
alert,
}: FlyoutSectionProps<AC>) => {
return (
<EuiTitle size="s">
<h3>{(alert[ALERT_RULE_NAME]?.[0] as string) ?? 'Unknown'}</h3>
</EuiTitle>
);
};
type TabId = 'overview' | 'table';
export const DefaultAlertsFlyoutBody = <AC extends AdditionalContext>(
props: FlyoutSectionProps<AC>
) => {
const { alert, columns } = props;
const overviewTab = useMemo(
() => ({
id: 'overview',
'data-test-subj': 'overviewTab',
name: i18n.translate('xpack.triggersActionsUI.sections.alertsTable.alertsFlyout.overview', {
defaultMessage: 'Overview',
}),
content: (
<EuiPanel hasShadow={false} data-test-subj="overviewTabPanel">
<EuiDescriptionList
listItems={(columns ?? defaultAlertsTableColumns).map((column) => {
const value = alert[column.id]?.[0];
return {
title: (column.displayAsText as string) ?? column.id,
description:
value != null ? (
<DefaultCellValue columnId={column.id} alert={props.alert} />
) : (
'—'
),
};
})}
type="column"
columnWidths={[1, 3]}
/>
</EuiPanel>
),
}),
[alert, columns, props]
);
const tableTab = useMemo(
() => ({
id: 'table',
'data-test-subj': 'tableTab',
name: i18n.translate('xpack.triggersActionsUI.sections.alertsTable.alertsFlyout.table', {
defaultMessage: 'Table',
}),
content: (
<EuiPanel hasShadow={false} data-test-subj="tableTabPanel">
<AlertFieldsTable alert={alert} />
</EuiPanel>
),
}),
[alert]
);
const tabs = useMemo(() => [overviewTab, tableTab], [overviewTab, tableTab]);
const [selectedTabId, setSelectedTabId] = useState<TabId>('overview');
const handleTabClick = useCallback(
(tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as TabId),
[]
);
const selectedTab = useMemo(
() => tabs.find((tab) => tab.id === selectedTabId) ?? tabs[0],
[tabs, selectedTabId]
);
return (
<ScrollableFlyoutTabbedContent
tabs={tabs}
selectedTab={selectedTab}
onTabClick={handleTabClick}
expand
data-test-subj="defaultAlertFlyoutTabs"
/>
);
};

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import type { Alert } from '@kbn/alerting-types';
import { DefaultCell } from './default_cell';
import { CellComponentProps } from '../types';
import { getCasesMapMock } from '../mocks/cases.mock';
import { getMaintenanceWindowsMapMock } from '../mocks/maintenance_windows.mock';
import { createPartialObjectMock } from '../utils/test';
const casesMap = getCasesMapMock();
const maintenanceWindowsMap = getMaintenanceWindowsMapMock();
const alert: Alert = {
_id: 'alert-id',
_index: 'alert-index',
'kibana.alert.status': ['active'],
};
const props = createPartialObjectMock<CellComponentProps>({
isLoading: false,
alert,
cases: casesMap,
maintenanceWindows: maintenanceWindowsMap,
columnId: 'kibana.alert.status',
showAlertStatusWithFlapping: false,
});
describe('DefaultCell', () => {
it('shows the value', async () => {
render(<DefaultCell {...props} />);
expect(screen.getByText('active')).toBeInTheDocument();
});
it('shows empty tag if the value is empty', async () => {
render(<DefaultCell {...props} alert={{ ...alert, 'kibana.alert.status': [] }} />);
expect(screen.getByText('--')).toBeInTheDocument();
});
it('shows multiple values', async () => {
render(
<DefaultCell
{...props}
alert={{ ...alert, 'kibana.alert.status': ['active', 'recovered'] }}
/>
);
expect(screen.getByText('active, recovered')).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show more