diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/alerts_data_grid.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/alerts_data_grid.test.tsx
index b43d0eace82b0..532b5d5e7c817 100644
--- a/src/platform/packages/shared/response-ops/alerts-table/components/alerts_data_grid.test.tsx
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/alerts_data_grid.test.tsx
@@ -17,6 +17,10 @@ import type { AlertsDataGridProps, BulkActionsState } from '../types';
import type { AdditionalContext, RenderContext } from '../types';
import type { EuiDataGridColumnCellAction } from '@elastic/eui';
import { EuiButton, EuiButtonIcon, EuiFlexItem } from '@elastic/eui';
+import { QueryClient, QueryClientProvider } from '@kbn/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';
import { bulkActionsReducer } from '../reducers/bulk_actions_reducer';
import { getJsDomPerformanceFix } from '../utils/test';
import { useCaseViewNavigation } from '../hooks/use_case_view_navigation';
@@ -35,12 +39,11 @@ import {
FIELD_BROWSER_CUSTOM_CREATE_BTN_TEST_ID,
FIELD_BROWSER_TEST_ID,
} from '../constants';
-import { QueryClient, QueryClientProvider } from '@kbn/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';
+import { useIndividualTagsActionContext } from '../contexts/individual_tags_action_context';
+import { useTagsAction } from './tags/use_tags_action';
jest.mock('../hooks/use_case_view_navigation');
+jest.mock('./tags/use_tags_action');
const cellActionOnClickMockedFn = jest.fn();
const mockOnChangeVisibleColumns = jest.fn();
@@ -133,6 +136,17 @@ describe('AlertsDataGrid', () => {
beforeEach(() => {
jest.clearAllMocks();
+ // Reset the tags action mock to default state
+ const mockUseTagsAction = jest.mocked(useTagsAction);
+ mockUseTagsAction.mockReset();
+ mockUseTagsAction.mockImplementation(() => ({
+ isFlyoutOpen: false,
+ selectedAlerts: [],
+ openFlyout: jest.fn(),
+ onClose: jest.fn(),
+ onSaveTags: jest.fn(),
+ getAction: jest.fn(),
+ }));
});
describe('Alerts table UI', () => {
@@ -585,5 +599,125 @@ describe('AlertsDataGrid', () => {
expect(container.querySelector('.euiDataGrid__virtualized')).toBeTruthy();
});
});
+
+ describe('Individual tags flyout', () => {
+ const mockUseTagsAction = jest.mocked(useTagsAction);
+ const mockAlert = {
+ _id: 'alert-1',
+ _index: 'test-index',
+ version: 'v1',
+ title: 'Test Alert',
+ 'kibana.alert.workflow_tags': ['tag1', 'tag2'],
+ } as any;
+
+ beforeEach(() => {
+ // Reset the mock before each test
+ mockUseTagsAction.mockReset();
+ // Default mock: flyout closed
+ mockUseTagsAction.mockImplementation(() => ({
+ isFlyoutOpen: false,
+ selectedAlerts: [],
+ openFlyout: jest.fn(),
+ onClose: jest.fn(),
+ onSaveTags: jest.fn(),
+ getAction: jest.fn(),
+ }));
+ });
+
+ it('should not render individual tags flyout when closed', async () => {
+ render();
+
+ // By default, the flyout should be closed
+ expect(screen.queryByTestId('alerts-edit-tags-flyout')).not.toBeInTheDocument();
+ });
+
+ it('should render individual tags flyout when opened', async () => {
+ const mockOnClose = jest.fn();
+ const mockOnSaveTags = jest.fn();
+
+ // Set up the mock implementation before rendering
+ mockUseTagsAction.mockImplementation(() => ({
+ isFlyoutOpen: true,
+ selectedAlerts: [mockAlert],
+ openFlyout: jest.fn(),
+ onClose: mockOnClose,
+ onSaveTags: mockOnSaveTags,
+ getAction: jest.fn(),
+ }));
+
+ render();
+
+ expect(await screen.findByTestId('alerts-edit-tags-flyout')).toBeInTheDocument();
+ expect(screen.getByTestId('alerts-edit-tags-flyout-title')).toBeInTheDocument();
+ });
+
+ it('should call onClose when cancel button is clicked', async () => {
+ const mockOnClose = jest.fn();
+ const mockOnSaveTags = jest.fn();
+
+ mockUseTagsAction.mockImplementation(() => ({
+ isFlyoutOpen: true,
+ selectedAlerts: [mockAlert],
+ openFlyout: jest.fn(),
+ onClose: mockOnClose,
+ onSaveTags: mockOnSaveTags,
+ getAction: jest.fn(),
+ }));
+
+ render();
+
+ const cancelButton = await screen.findByTestId('alerts-edit-tags-flyout-cancel');
+ await userEvent.click(cancelButton);
+
+ // The onClose should be called
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('should provide IndividualTagsActionContext to child components', async () => {
+ const TestChildComponent = () => {
+ try {
+ const context = useIndividualTagsActionContext();
+ return (
+
+ {context.isFlyoutOpen ? 'Flyout Open' : 'Flyout Closed'}
+
+ );
+ } catch (e) {
+ return Context Not Available
;
+ }
+ };
+
+ render(
+ ,
+ }}
+ />
+ );
+
+ // The context should be available (flyout closed by default)
+ expect(await screen.findByTestId('context-test')).toHaveTextContent('Flyout Closed');
+ });
+
+ it('should render individual tags flyout with correct alert data', async () => {
+ mockUseTagsAction.mockImplementation(() => ({
+ isFlyoutOpen: true,
+ selectedAlerts: [mockAlert],
+ openFlyout: jest.fn(),
+ onClose: jest.fn(),
+ onSaveTags: jest.fn(),
+ getAction: jest.fn(),
+ }));
+
+ render();
+
+ const flyout = await screen.findByTestId('alerts-edit-tags-flyout');
+ expect(flyout).toBeInTheDocument();
+
+ // Verify the flyout title is present
+ expect(screen.getByTestId('alerts-edit-tags-flyout-title')).toBeInTheDocument();
+ });
+ });
});
});
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/alerts_data_grid.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/alerts_data_grid.tsx
index 145af02e49f70..b11e2417f04cc 100644
--- a/src/platform/packages/shared/response-ops/alerts-table/components/alerts_data_grid.tsx
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/alerts_data_grid.tsx
@@ -31,6 +31,9 @@ import { useSorting } from '../hooks/use_sorting';
import { CellPopoverHost } from './cell_popover_host';
import { NonVirtualizedGridBody } from './non_virtualized_grid_body';
import type { AlertDetailFlyout as AlertDetailFlyoutType } from './alert_detail_flyout';
+import { EditTagsFlyout } from './tags/edit_tags_flyout';
+import { IndividualTagsActionContextProvider } from '../contexts/individual_tags_action_context';
+import { useTagsAction } from './tags/use_tags_action';
const AlertDetailFlyout = lazy(
() => import('./alert_detail_flyout')
@@ -111,6 +114,7 @@ export const AlertsDataGrid = typedMemo(
bulkActions,
setIsBulkActionsLoading,
clearSelection,
+ bulkEditTagsFlyoutState,
} = useBulkActions({
ruleTypeIds,
query,
@@ -326,46 +330,82 @@ export const AlertsDataGrid = typedMemo(
: // Overriding the simplified type here to avoid cyclic problems with generics
(AlertDetailFlyout as NonNullable);
+ const selectedAlerts = useMemo(
+ () =>
+ Array.from(bulkActionsState.rowSelection.keys())
+ .map((i) => alerts[i])
+ .filter(Boolean),
+ [alerts, bulkActionsState.rowSelection]
+ );
+
+ const individualTagsFlyout = useTagsAction({
+ onActionSuccess: () => {
+ refresh();
+ },
+ onActionError: () => {
+ refresh();
+ },
+ isDisabled: false,
+ });
+
+ const { selectedAlerts: selectedAlertsFromRowAction } = individualTagsFlyout;
+
return (
-
-
-
- {expandedAlertIndex != null && ExpandedAlertView && (
-
+
+
+
+
+ {expandedAlertIndex != null && ExpandedAlertView && (
+
+ )}
+
+ {bulkEditTagsFlyoutState.isFlyoutOpen && selectedAlerts.length > 0 && (
+
+ )}
+ {individualTagsFlyout.isFlyoutOpen && selectedAlertsFromRowAction?.length > 0 && (
+
+ )}
+ {alertsCount > 0 && (
+
)}
-
- {alertsCount > 0 && (
-
- )}
-
-
+
+
+
);
}
);
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/default_alert_actions.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/default_alert_actions.test.tsx
index f9271d0293c83..24f338266b2b3 100644
--- a/src/platform/packages/shared/response-ops/alerts-table/components/default_alert_actions.test.tsx
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/default_alert_actions.test.tsx
@@ -13,8 +13,11 @@ import { render, screen } from '@testing-library/react';
import type { 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 { createPartialObjectMock, testQueryClientConfig } from '../utils/test';
import { AlertsTableContextProvider } from '../contexts/alerts_table_context';
+import { QueryClient, QueryClientProvider } from '@kbn/react-query';
+import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
+import { ALERT_RULE_TYPE_ID } from '@kbn/rule-data-utils';
jest.mock('@kbn/alerts-ui-shared/src/common/hooks/use_get_rule_types_permissions');
@@ -25,6 +28,7 @@ jest.mock('./view_rule_details_alert_action', () => {
),
};
});
+
jest.mock('./view_alert_details_alert_action', () => {
return {
ViewAlertDetailsAlertAction: () => (
@@ -32,9 +36,11 @@ jest.mock('./view_alert_details_alert_action', () => {
),
};
});
+
jest.mock('./mute_alert_action', () => {
return { MuteAlertAction: () => {'MuteAlertAction'}
};
});
+
jest.mock('./mark_as_untracked_alert_action', () => {
return {
MarkAsUntrackedAlertAction: () => (
@@ -43,6 +49,27 @@ jest.mock('./mark_as_untracked_alert_action', () => {
};
});
+jest.mock('./edit_tags_action', () => {
+ return {
+ EditTagsAction: () => {'EditTagsAction'}
,
+ };
+});
+
+jest.mock('../contexts/individual_tags_action_context', () => {
+ const actual = jest.requireActual('../contexts/individual_tags_action_context');
+ return {
+ ...actual,
+ useIndividualTagsActionContext: () => ({
+ isFlyoutOpen: false,
+ selectedAlerts: [],
+ openFlyout: jest.fn(),
+ onClose: jest.fn(),
+ onSaveTags: jest.fn(),
+ getAction: jest.fn(),
+ }),
+ };
+});
+
const { useGetRuleTypesPermissions } = jest.requireMock(
'@kbn/alerts-ui-shared/src/common/hooks/use_get_rule_types_permissions'
);
@@ -61,28 +88,134 @@ const context = createPartialObjectMock>({
},
});
+const queryClient = new QueryClient(testQueryClientConfig);
+
const TestComponent = (_props: AlertActionsProps) => (
-
- {..._props} />
-
+
+
+ {..._props} />
+
+
);
describe('DefaultAlertActions', () => {
- it('should show "Mute" and "Marked as untracked" option', async () => {
- useGetRuleTypesPermissions.mockReturnValue({ authorizedToCreateAnyRules: true });
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('with authorization to create rules', () => {
+ beforeEach(() => {
+ useGetRuleTypesPermissions.mockReturnValue({ authorizedToCreateAnyRules: true });
+ });
+
+ describe('for non-security rule types', () => {
+ const nonSecurityProps = createPartialObjectMock({
+ alert: {
+ [ALERT_RULE_TYPE_ID]: 'apm.anomaly' as any,
+ },
+ refresh: jest.fn(),
+ });
+ const noRuleTypeProps = createPartialObjectMock({
+ alert: {},
+ refresh: jest.fn(),
+ });
- render();
+ it.each([nonSecurityProps, noRuleTypeProps])(
+ 'should show all modify options for rule type %s',
+ async (standardProps) => {
+ render();
- expect(await screen.findByText('MuteAlertAction')).toBeInTheDocument();
- expect(await screen.findByText('MarkAsUntrackedAlertAction')).toBeInTheDocument();
+ expect(screen.queryByText('MuteAlertAction')).toBeInTheDocument();
+ expect(screen.queryByText('MarkAsUntrackedAlertAction')).toBeInTheDocument();
+ expect(screen.queryByText('EditTagsAction')).toBeInTheDocument();
+ }
+ );
+ });
+
+ describe('for security rule types', () => {
+ it.each(['siem.queryRule', 'siem.esqlRuleType', 'attack-discovery', 'siem.mlRule'])(
+ 'should hide all modify options for rule type %s',
+ async (ruleTypeId) => {
+ const securityProps = createPartialObjectMock({
+ alert: {
+ [ALERT_RULE_TYPE_ID]: ruleTypeId as any,
+ },
+ refresh: jest.fn(),
+ });
+
+ render();
+
+ expect(screen.queryByText('MuteAlertAction')).not.toBeInTheDocument();
+ expect(screen.queryByText('MarkAsUntrackedAlertAction')).not.toBeInTheDocument();
+ expect(screen.queryByText('EditTagsAction')).not.toBeInTheDocument();
+ }
+ );
+ });
+
+ describe('view-only actions', () => {
+ it('should always show "View rule details" and "View alert details" for all rule types', async () => {
+ render();
+
+ expect(await screen.findByText('ViewRuleDetailsAlertAction')).toBeInTheDocument();
+ expect(await screen.findByText('ViewAlertDetailsAlertAction')).toBeInTheDocument();
+ });
+
+ it('should show view actions for security rules even when modify options are hidden', async () => {
+ const siemProps = createPartialObjectMock({
+ alert: {
+ [ALERT_RULE_TYPE_ID]: 'siem.queryRule' as any,
+ },
+ refresh: jest.fn(),
+ });
+
+ render();
+
+ expect(await screen.findByText('ViewRuleDetailsAlertAction')).toBeInTheDocument();
+ expect(await screen.findByText('ViewAlertDetailsAlertAction')).toBeInTheDocument();
+ });
+ });
});
- it('should hide "Mute" and "Marked as untracked" option', async () => {
- useGetRuleTypesPermissions.mockReturnValue({ authorizedToCreateAnyRules: false });
+ describe('without authorization to create rules', () => {
+ beforeEach(() => {
+ useGetRuleTypesPermissions.mockReturnValue({ authorizedToCreateAnyRules: false });
+ });
+
+ it('should hide "Mute", "Marked as untracked", and "Edit tags" options for non-security rules', async () => {
+ const nonSecurityProps = createPartialObjectMock({
+ alert: {
+ [ALERT_RULE_TYPE_ID]: 'apm.anomaly' as any,
+ },
+ refresh: jest.fn(),
+ });
+
+ render();
+
+ expect(screen.queryByText('MuteAlertAction')).not.toBeInTheDocument();
+ expect(screen.queryByText('MarkAsUntrackedAlertAction')).not.toBeInTheDocument();
+ expect(screen.queryByText('EditTagsAction')).not.toBeInTheDocument();
+ });
+
+ it('should hide all modify options for security rules', async () => {
+ const siemProps = createPartialObjectMock({
+ alert: {
+ [ALERT_RULE_TYPE_ID]: 'siem.queryRule' as any,
+ },
+ refresh: jest.fn(),
+ });
+
+ render();
+
+ expect(screen.queryByText('MuteAlertAction')).not.toBeInTheDocument();
+ expect(screen.queryByText('MarkAsUntrackedAlertAction')).not.toBeInTheDocument();
+ expect(screen.queryByText('EditTagsAction')).not.toBeInTheDocument();
+ });
- render();
+ it('should still show view actions', async () => {
+ render();
- expect(screen.queryByText('MuteAlertAction')).not.toBeInTheDocument();
- expect(screen.queryByText('MarkAsUntrackedAlertAction')).not.toBeInTheDocument();
+ expect(await screen.findByText('ViewRuleDetailsAlertAction')).toBeInTheDocument();
+ expect(await screen.findByText('ViewAlertDetailsAlertAction')).toBeInTheDocument();
+ });
});
});
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/default_alert_actions.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/default_alert_actions.tsx
index bae4d6ed400e1..dfceae0681448 100644
--- a/src/platform/packages/shared/response-ops/alerts-table/components/default_alert_actions.tsx
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/default_alert_actions.tsx
@@ -10,12 +10,14 @@
import React from 'react';
import { useGetRuleTypesPermissions } from '@kbn/alerts-ui-shared/src/common/hooks';
import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
+import { ALERT_RULE_TYPE_ID, isSiemRuleType } from '@kbn/rule-data-utils';
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';
+import { EditTagsAction } from './edit_tags_action';
/**
* Common alerts table row actions
@@ -36,12 +38,18 @@ export const DefaultAlertActions =
- {authorizedToCreateAnyRules && }
- {authorizedToCreateAnyRules && }
+ {showModifyOption && }
+ {showModifyOption && }
+ {showModifyOption && }
>
);
};
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/edit_tags_action.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/edit_tags_action.test.tsx
new file mode 100644
index 0000000000000..2a3c0cdee055c
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/edit_tags_action.test.tsx
@@ -0,0 +1,92 @@
+/*
+ * 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 { EditTagsAction } from './edit_tags_action';
+import { IndividualTagsActionContextProvider } from '../contexts/individual_tags_action_context';
+import { createPartialObjectMock } from '../utils/test';
+import type { AlertActionsProps } from '../types';
+
+describe('EditTagsAction', () => {
+ const mockOpenFlyout = jest.fn();
+ const mockOnActionExecuted = jest.fn();
+ const mockAlert = {
+ _id: 'test-alert-id',
+ _index: 'test-index',
+ };
+
+ const defaultProps = createPartialObjectMock({
+ alert: mockAlert as any,
+ refresh: jest.fn(),
+ onActionExecuted: mockOnActionExecuted,
+ });
+
+ const renderComponent = (props = defaultProps) => {
+ return render(
+
+
+
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render the edit tags menu item', () => {
+ renderComponent();
+
+ expect(screen.getByTestId('editTags')).toBeInTheDocument();
+ expect(screen.getByText('Edit tags')).toBeInTheDocument();
+ });
+
+ it('should call openFlyout with the alert when clicked', async () => {
+ renderComponent();
+
+ const menuItem = screen.getByTestId('editTags');
+ await userEvent.click(menuItem);
+
+ expect(mockOpenFlyout).toHaveBeenCalledTimes(1);
+ expect(mockOpenFlyout).toHaveBeenCalledWith([mockAlert]);
+ });
+
+ it('should call onActionExecuted when clicked', async () => {
+ renderComponent();
+
+ const menuItem = screen.getByTestId('editTags');
+ await userEvent.click(menuItem);
+
+ expect(mockOnActionExecuted).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not throw when onActionExecuted is not provided', async () => {
+ const propsWithoutCallback = createPartialObjectMock({
+ alert: mockAlert as any,
+ refresh: jest.fn(),
+ });
+
+ renderComponent(propsWithoutCallback);
+
+ const menuItem = screen.getByTestId('editTags');
+ await userEvent.click(menuItem);
+
+ expect(mockOpenFlyout).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/edit_tags_action.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/edit_tags_action.tsx
new file mode 100644
index 0000000000000..02cf739d364a1
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/edit_tags_action.tsx
@@ -0,0 +1,42 @@
+/*
+ * 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 } from 'react';
+import { i18n } from '@kbn/i18n';
+import { EuiContextMenuItem } from '@elastic/eui';
+import type { AdditionalContext, AlertActionsProps } from '../types';
+import { typedMemo } from '../utils/react';
+import { useIndividualTagsActionContext } from '../contexts/individual_tags_action_context';
+
+export const EditTagsAction = typedMemo(
+ ({
+ alert,
+ onActionExecuted,
+ }: AlertActionsProps) => {
+ const { openFlyout } = useIndividualTagsActionContext();
+
+ const handleOpenFlyout = useCallback(() => {
+ openFlyout([alert]);
+ onActionExecuted?.(); // this will close the popover containing this action
+ }, [alert, openFlyout, onActionExecuted]);
+
+ return (
+
+ {i18n.translate('xpack.responseOpsAlertsTable.actions.editTags', {
+ defaultMessage: 'Edit tags',
+ })}
+
+ );
+ }
+);
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_flyout.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_flyout.test.tsx
new file mode 100644
index 0000000000000..2d035e73fdaa1
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_flyout.test.tsx
@@ -0,0 +1,82 @@
+/*
+ * 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 userEvent from '@testing-library/user-event';
+import { render, waitFor, screen } from '@testing-library/react';
+import type { Alert } from '@kbn/alerting-types';
+import { EditTagsFlyout } from './edit_tags_flyout';
+
+describe('EditTagsFlyout', () => {
+ const mockAlert = {
+ _id: 'alert-1',
+ version: 'v1',
+ _index: 'test-index',
+ title: 'Test Alert',
+ 'kibana.alert.workflow_tags': ['coke', 'pepsi'],
+ } as unknown as Alert;
+
+ const props = {
+ selectedAlerts: [mockAlert],
+ onClose: jest.fn(),
+ onSaveTags: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly', async () => {
+ render();
+
+ expect(await screen.findByTestId('alerts-edit-tags-flyout')).toBeInTheDocument();
+ expect(await screen.findByTestId('alerts-edit-tags-flyout-title')).toBeInTheDocument();
+ expect(await screen.findByTestId('alerts-edit-tags-flyout-cancel')).toBeInTheDocument();
+ expect(await screen.findByTestId('alerts-edit-tags-flyout-submit')).toBeInTheDocument();
+ });
+
+ it('calls onClose when pressing the cancel button', async () => {
+ render();
+
+ await userEvent.click(await screen.findByTestId('alerts-edit-tags-flyout-cancel'));
+
+ await waitFor(() => {
+ expect(props.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onSaveTags when pressing the save selection button', async () => {
+ render();
+
+ expect(await screen.findByText('coke')).toBeInTheDocument();
+
+ await userEvent.click(await screen.findByText('coke'));
+ await userEvent.click(await screen.findByTestId('alerts-edit-tags-flyout-submit'));
+
+ await waitFor(() => {
+ expect(props.onSaveTags).toHaveBeenCalledWith({
+ selectedItems: ['pepsi'],
+ unSelectedItems: ['coke'],
+ });
+ });
+ });
+
+ it('shows the number of total selected alerts in the title', async () => {
+ const mockAlert2 = {
+ ...mockAlert,
+ _id: 'alert-2',
+ title: 'Test Alert 2',
+ 'kibana.alert.workflow_tags': ['one', 'three'],
+ } as unknown as Alert;
+
+ render();
+
+ expect(await screen.findByText('Selected alerts: 2')).toBeInTheDocument();
+ });
+});
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_flyout.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_flyout.tsx
new file mode 100644
index 0000000000000..8c0394da86e06
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_flyout.tsx
@@ -0,0 +1,127 @@
+/*
+ * 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 { css } from '@emotion/react';
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutFooter,
+ EuiFlyoutHeader,
+ euiFullHeight,
+ EuiText,
+ EuiTitle,
+} from '@elastic/eui';
+import { ALERT_WORKFLOW_TAGS } from '@kbn/rule-data-utils';
+
+import type { Alert } from '@kbn/alerting-types';
+import { EditTagsSelectable } from './edit_tags_selectable';
+import * as i18n from './translations';
+import type { ItemsSelectionState } from './items/types';
+import { useFocusButtonTrap } from './use_focus_button';
+
+interface Props {
+ selectedAlerts: Alert[];
+ onClose: () => void;
+ onSaveTags: (args: ItemsSelectionState) => void;
+ focusButtonRef?: React.Ref;
+}
+
+const FlyoutBodyCss = css`
+ ${euiFullHeight()}
+
+ .euiFlyoutBody__overflowContent {
+ ${euiFullHeight()}
+ }
+`;
+
+const EditTagsFlyoutComponent: React.FC = ({
+ selectedAlerts,
+ onClose,
+ onSaveTags,
+ focusButtonRef,
+}) => {
+ const tags = useMemo(() => {
+ const tagSet = new Set();
+ for (const alert of selectedAlerts) {
+ const alertTags = alert[ALERT_WORKFLOW_TAGS] as string[] | undefined;
+ if (alertTags && Array.isArray(alertTags)) {
+ alertTags.forEach((tag) => tagSet.add(tag));
+ }
+ }
+ return Array.from(tagSet).sort();
+ }, [selectedAlerts]);
+
+ const isLoading = false;
+
+ const [tagsSelection, setTagsSelection] = useState({
+ selectedItems: [],
+ unSelectedItems: [],
+ });
+
+ const onSave = useCallback(() => onSaveTags(tagsSelection), [onSaveTags, tagsSelection]);
+ const focusTrapProps = useFocusButtonTrap(focusButtonRef);
+
+ const headerSubtitle = i18n.SELECTED_ALERTS(selectedAlerts.length);
+
+ return (
+
+
+
+ {i18n.EDIT_TAGS}
+
+
+ {headerSubtitle}
+
+
+
+
+
+
+
+
+
+ {i18n.CANCEL}
+
+
+
+
+ {i18n.SAVE_SELECTION}
+
+
+
+
+
+ );
+};
+
+EditTagsFlyoutComponent.displayName = 'EditTagsFlyout';
+
+export const EditTagsFlyout = React.memo(EditTagsFlyoutComponent);
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_selectable.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_selectable.test.tsx
new file mode 100644
index 0000000000000..39baa942e6ddb
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_selectable.test.tsx
@@ -0,0 +1,281 @@
+/*
+ * 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 } from '@testing-library/react';
+import { EditTagsSelectable } from './edit_tags_selectable';
+import userEvent from '@testing-library/user-event';
+import { waitFor, screen } from '@testing-library/react';
+import type { Alert } from '@kbn/alerting-types';
+
+describe('EditTagsSelectable', () => {
+ const mockAlert: Alert = {
+ 'kibana.alert.workflow_tags': ['coke', 'pepsi'],
+ } as Alert;
+
+ const props = {
+ selectedAlerts: [mockAlert],
+ isLoading: false,
+ tags: ['one', 'two', 'coke', 'pepsi'],
+ onChangeTags: jest.fn(),
+ };
+
+ const propsMultipleAlerts = {
+ selectedAlerts: [
+ { 'kibana.alert.workflow_tags': ['coke', 'pepsi', 'one'] } as Alert,
+ { 'kibana.alert.workflow_tags': ['one', 'three'] } as Alert,
+ ],
+ isLoading: false,
+ tags: ['one', 'two', 'three', 'coke', 'pepsi'],
+ onChangeTags: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly', async () => {
+ render();
+
+ expect(screen.getByTestId('alerts-actions-tags-edit-selectable')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
+ expect(screen.getByText(`Total tags: ${props.tags.length}`)).toBeInTheDocument();
+ expect(screen.getByText('Selected: 2')).toBeInTheDocument();
+ expect(screen.getByText('Select all')).toBeInTheDocument();
+ expect(screen.getByText('Select none')).toBeInTheDocument();
+
+ for (const tag of props.tags) {
+ expect(screen.getByText(tag)).toBeInTheDocument();
+ }
+ });
+
+ it('renders the selected tags label correctly', async () => {
+ render();
+
+ expect(screen.getByText('Total tags: 5')).toBeInTheDocument();
+ expect(screen.getByText('Selected: 4')).toBeInTheDocument();
+
+ for (const tag of props.tags) {
+ expect(screen.getByText(tag)).toBeInTheDocument();
+ }
+ });
+
+ it('renders the tags icons correctly', async () => {
+ render();
+
+ for (const [tag, icon] of [
+ ['one', 'check'],
+ ['two', 'empty'],
+ ['three', 'asterisk'],
+ ['coke', 'asterisk'],
+ ['pepsi', 'asterisk'],
+ ]) {
+ const iconDataTestSubj = `alerts-actions-tags-edit-selectable-tag-${tag}-icon-${icon}`;
+ expect(screen.getByTestId(iconDataTestSubj)).toBeInTheDocument();
+ }
+ });
+
+ it('selects and unselects correctly tags with one alert', async () => {
+ render();
+
+ for (const tag of props.tags) {
+ await userEvent.click(screen.getByText(tag));
+ }
+
+ expect(props.onChangeTags).toBeCalledTimes(props.tags.length);
+ expect(props.onChangeTags).nthCalledWith(props.tags.length, {
+ selectedItems: ['one', 'two'],
+ unSelectedItems: ['coke', 'pepsi'],
+ });
+ });
+
+ it('selects and unselects correctly tags with multiple alerts', async () => {
+ render();
+
+ for (const tag of propsMultipleAlerts.tags) {
+ await userEvent.click(screen.getByText(tag));
+ }
+
+ expect(propsMultipleAlerts.onChangeTags).toBeCalledTimes(propsMultipleAlerts.tags.length);
+ expect(propsMultipleAlerts.onChangeTags).nthCalledWith(propsMultipleAlerts.tags.length, {
+ selectedItems: ['two', 'three', 'coke', 'pepsi'],
+ unSelectedItems: ['one'],
+ });
+ });
+
+ it('renders the icons correctly after selecting and deselecting tags', async () => {
+ render();
+
+ for (const tag of propsMultipleAlerts.tags) {
+ await userEvent.click(screen.getByText(tag));
+ }
+
+ for (const [tag, icon] of [
+ ['one', 'empty'],
+ ['two', 'check'],
+ ['three', 'check'],
+ ['coke', 'check'],
+ ['pepsi', 'check'],
+ ]) {
+ const iconDataTestSubj = `alerts-actions-tags-edit-selectable-tag-${tag}-icon-${icon}`;
+ expect(screen.getByTestId(iconDataTestSubj)).toBeInTheDocument();
+ }
+
+ expect(propsMultipleAlerts.onChangeTags).toBeCalledTimes(propsMultipleAlerts.tags.length);
+ expect(propsMultipleAlerts.onChangeTags).nthCalledWith(propsMultipleAlerts.tags.length, {
+ selectedItems: ['two', 'three', 'coke', 'pepsi'],
+ unSelectedItems: ['one'],
+ });
+ });
+
+ it('adds a new tag correctly', async () => {
+ render();
+
+ await userEvent.type(screen.getByPlaceholderText('Search'), 'not-exist', { delay: 1 });
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('alerts-actions-tags-edit-selectable-add-new-tag')
+ ).toBeInTheDocument();
+ });
+
+ const addNewTagButton = screen.getByTestId('alerts-actions-tags-edit-selectable-add-new-tag');
+
+ await userEvent.click(addNewTagButton);
+
+ expect(props.onChangeTags).toBeCalledTimes(1);
+ expect(props.onChangeTags).nthCalledWith(1, {
+ selectedItems: ['not-exist', 'coke', 'pepsi'],
+ unSelectedItems: [],
+ });
+ });
+
+ it('selects all tags correctly', async () => {
+ render();
+
+ expect(screen.getByText('Select all')).toBeInTheDocument();
+ await userEvent.click(screen.getByText('Select all'));
+
+ expect(propsMultipleAlerts.onChangeTags).toBeCalledTimes(1);
+ expect(propsMultipleAlerts.onChangeTags).nthCalledWith(1, {
+ selectedItems: propsMultipleAlerts.tags,
+ unSelectedItems: [],
+ });
+ });
+
+ it('unselects all tags correctly', async () => {
+ render();
+
+ expect(screen.getByText('Select all')).toBeInTheDocument();
+ await userEvent.click(screen.getByText('Select none'));
+
+ expect(propsMultipleAlerts.onChangeTags).toBeCalledTimes(1);
+ expect(propsMultipleAlerts.onChangeTags).nthCalledWith(1, {
+ selectedItems: [],
+ unSelectedItems: ['one', 'three', 'coke', 'pepsi'],
+ });
+ });
+
+ it('unselects correctly with the new item presented', async () => {
+ render();
+
+ await userEvent.type(screen.getByPlaceholderText('Search'), 'on', { delay: 1 });
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('alerts-actions-tags-edit-selectable-add-new-tag')
+ ).toBeInTheDocument();
+ });
+
+ const iconDataTestSubj = 'alerts-actions-tags-edit-selectable-tag-one-icon-check';
+ expect(screen.getByTestId(iconDataTestSubj)).toBeInTheDocument();
+
+ await userEvent.click(screen.getByTestId(iconDataTestSubj));
+
+ expect(propsMultipleAlerts.onChangeTags).toBeCalledTimes(1);
+ expect(propsMultipleAlerts.onChangeTags).nthCalledWith(1, {
+ selectedItems: [],
+ unSelectedItems: ['one'],
+ });
+ });
+
+ it('adds a partial match correctly and does not show the no match label', async () => {
+ render();
+
+ /**
+ * Tag with label "one" exist. Searching for "on" will show both the
+ * "add new tag" item and the "one" tag
+ */
+ await userEvent.type(screen.getByPlaceholderText('Search'), 'on', { delay: 1 });
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('alerts-actions-tags-edit-selectable-add-new-tag')
+ ).toBeInTheDocument();
+ });
+
+ expect(
+ screen.queryByTestId('alerts-actions-tags-edit-selectable-no-match-label')
+ ).not.toBeInTheDocument();
+
+ const addNewTagButton = screen.getByTestId('alerts-actions-tags-edit-selectable-add-new-tag');
+
+ await userEvent.click(addNewTagButton);
+
+ expect(props.onChangeTags).toBeCalledTimes(1);
+ expect(props.onChangeTags).nthCalledWith(1, {
+ selectedItems: ['on', 'coke', 'pepsi'],
+ unSelectedItems: [],
+ });
+ });
+
+ it('do not show the new item option on exact match', async () => {
+ render();
+
+ await userEvent.type(screen.getByPlaceholderText('Search'), 'one', { delay: 1 });
+
+ expect(
+ screen.queryByTestId('alerts-actions-tags-edit-selectable-add-new-tag')
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not show the no match label when the initial tags are empty', async () => {
+ render();
+
+ expect(
+ screen.queryByTestId('alerts-actions-tags-edit-selectable-no-match-label')
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows the no match label when there is no match', async () => {
+ render();
+
+ await userEvent.type(screen.getByPlaceholderText('Search'), 'not-exist', { delay: 1 });
+
+ expect(
+ screen.getByTestId('alerts-actions-tags-edit-selectable-no-match-label')
+ ).toBeInTheDocument();
+ });
+
+ it('shows the no match label and the add new item when there is space in the search term', async () => {
+ render();
+
+ await userEvent.type(screen.getByPlaceholderText('Search'), 'test tag', { delay: 1 });
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('alerts-actions-tags-edit-selectable-add-new-tag')
+ ).toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByTestId('alerts-actions-tags-edit-selectable-no-match-label')
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_selectable.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_selectable.tsx
new file mode 100644
index 0000000000000..54714846b36c6
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/tags/edit_tags_selectable.tsx
@@ -0,0 +1,223 @@
+/*
+ * 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 {
+ EuiSelectable,
+ EuiSpacer,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiText,
+ EuiButtonEmpty,
+ EuiHorizontalRule,
+ EuiIcon,
+ EuiHighlight,
+ useEuiTheme,
+} from '@elastic/eui';
+import { ALERT_WORKFLOW_TAGS } from '@kbn/rule-data-utils';
+
+import { isEmpty } from 'lodash';
+import type { Alert } from '@kbn/alerting-types';
+import * as i18n from './translations';
+import { useItemsState } from './items/use_items_state';
+import type { ItemSelectableOption, ItemsSelectionState } from './items/types';
+
+interface Props {
+ selectedAlerts: Alert[];
+ tags: string[];
+ isLoading: boolean;
+ onChangeTags: (args: ItemsSelectionState) => void;
+}
+
+const hasExactMatch = (searchValue: string, options: ItemSelectableOption[]) => {
+ return options.some((option) => option.key === searchValue);
+};
+
+const hasPartialMatch = (searchValue: string, options: ItemSelectableOption[]) => {
+ return options.some((option) => option.key?.includes(searchValue));
+};
+
+const itemToSelectableOption = (item: {
+ key: string;
+ data: Record;
+}): ItemSelectableOption => {
+ return {
+ key: item.key,
+ label: item.key,
+ 'data-test-subj': `alerts-actions-tags-edit-selectable-tag-${item.key}`,
+ } as ItemSelectableOption;
+};
+
+const EditTagsSelectableComponent: React.FC = ({
+ selectedAlerts,
+ tags,
+ isLoading,
+ onChangeTags,
+}) => {
+ const { state, options, totalSelectedItems, onChange, onSelectAll, onSelectNone } = useItemsState(
+ {
+ items: tags,
+ selectedAlerts,
+ itemToSelectableOption,
+ fieldSelector: (alert) => alert[ALERT_WORKFLOW_TAGS] as string[],
+ onChangeItems: onChangeTags,
+ }
+ );
+
+ const [searchValue, setSearchValue] = useState('');
+ const { euiTheme } = useEuiTheme();
+
+ const renderOption = useCallback(
+ (option: ItemSelectableOption, search: string) => {
+ const dataTestSubj = option.newItem
+ ? 'alerts-actions-tags-edit-selectable-add-new-tag-icon'
+ : `alerts-actions-tags-edit-selectable-tag-${option.label}-icon-${option.itemIcon}`;
+
+ return (
+ <>
+
+ {option.label}
+ >
+ );
+ },
+ [euiTheme]
+ );
+
+ /**
+ * While the user searches we need to add the ability
+ * to add the search term as a new tag. The no matches message
+ * is not enough because a search term can partial match to some tags
+ * but the user will still need to add the search term as tag.
+ * For that reason, we always add a "fake" option ("add new tag" option) which will serve as a
+ * the button with which the user can add a new tag. We do not show
+ * the "add new tag" option if there is an exact match.
+ */
+ const optionsWithAddNewTagOption = useMemo(() => {
+ if (!isEmpty(searchValue) && !hasExactMatch(searchValue, options)) {
+ return [
+ {
+ key: searchValue,
+ label: i18n.ADD_TAG_CUSTOM_OPTION_LABEL(searchValue),
+ 'data-test-subj': 'alerts-actions-tags-edit-selectable-add-new-tag',
+ data: { itemIcon: 'empty', newItem: true },
+ },
+ ...options,
+ ] as ItemSelectableOption[];
+ }
+
+ return options;
+ }, [options, searchValue]);
+
+ const showNoMatchText = useMemo(
+ () => !hasPartialMatch(searchValue, options) && Object.keys(state.items).length > 0,
+ [options, searchValue, state.items]
+ );
+
+ return (
+
+ {(list, search) => (
+ <>
+ {search}
+
+
+
+
+ {i18n.TOTAL_TAGS(tags.length)}
+
+
+
+
+ {i18n.SELECTED_TAGS(totalSelectedItems)}
+
+
+
+
+
+
+ {i18n.SELECT_ALL}
+
+
+
+
+ {i18n.SELECT_NONE}
+
+
+
+
+
+
+ {showNoMatchText ? (
+
+ {i18n.NO_SEARCH_MATCH}
+
+ ) : null}
+ {list}
+ >
+ )}
+
+ );
+};
+
+EditTagsSelectableComponent.displayName = 'EditTagsSelectable';
+
+export const EditTagsSelectable = React.memo(EditTagsSelectableComponent);
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/tags/items/types.ts b/src/platform/packages/shared/response-ops/alerts-table/components/tags/items/types.ts
new file mode 100644
index 0000000000000..e0879297d392a
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/tags/items/types.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { EuiSelectableOption, IconType } from '@elastic/eui';
+
+export interface UseActionProps {
+ onAction?: () => void;
+ onActionSuccess?: () => void;
+ onActionError?: () => void;
+ isDisabled: boolean;
+}
+
+export interface ItemsSelectionState {
+ selectedItems: string[];
+ unSelectedItems: string[];
+}
+
+export type ItemSelectableOption = EuiSelectableOption<
+ T & { key: string; itemIcon: IconType; newItem?: boolean }
+>;
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/tags/items/use_items_state.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/tags/items/use_items_state.test.tsx
new file mode 100644
index 0000000000000..db851bbfb7ee8
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/tags/items/use_items_state.test.tsx
@@ -0,0 +1,565 @@
+/*
+ * 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, act } from '@testing-library/react';
+import { useItemsState } from './use_items_state';
+import type { Alert } from '@kbn/alerting-types';
+import type { ItemSelectableOption } from './types';
+
+describe('useItemsState', () => {
+ const mockAlert: Alert = {
+ 'kibana.alert.workflow_tags': ['one', 'two'],
+ } as Alert;
+
+ const onChangeItems = jest.fn();
+ const fieldSelector = jest.fn();
+ const itemToSelectableOption = jest
+ .fn()
+ .mockImplementation((item) => ({ key: item.key, label: item.key, data: item.data }));
+
+ const props = {
+ items: ['one', 'two', 'three', 'four'],
+ selectedAlerts: [mockAlert, mockAlert],
+ fieldSelector,
+ itemToSelectableOption,
+ onChangeItems,
+ };
+
+ beforeEach(() => {
+ fieldSelector.mockReturnValueOnce(['one', 'two']);
+ fieldSelector.mockReturnValueOnce(['one', 'three']);
+ jest.clearAllMocks();
+ });
+
+ it('inits the state correctly', async () => {
+ const { result } = renderHook(() => useItemsState(props));
+
+ expect(result.current.state).toMatchInlineSnapshot(`
+ Object {
+ "itemCounterMap": Map {
+ "one" => 2,
+ "two" => 1,
+ "three" => 1,
+ },
+ "items": Object {
+ "four": Object {
+ "data": Object {},
+ "dirty": false,
+ "icon": "empty",
+ "itemState": "unchecked",
+ "key": "four",
+ },
+ "one": Object {
+ "data": Object {},
+ "dirty": true,
+ "icon": "check",
+ "itemState": "checked",
+ "key": "one",
+ },
+ "three": Object {
+ "data": Object {},
+ "dirty": false,
+ "icon": "asterisk",
+ "itemState": "partial",
+ "key": "three",
+ },
+ "two": Object {
+ "data": Object {},
+ "dirty": false,
+ "icon": "asterisk",
+ "itemState": "partial",
+ "key": "two",
+ },
+ },
+ }
+ `);
+ });
+
+ it('inits the options correctly', async () => {
+ const { result } = renderHook(() => useItemsState(props));
+
+ expect(result.current.options).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "checked": "on",
+ "data": Object {
+ "itemIcon": "check",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-one",
+ "key": "one",
+ "label": "one",
+ },
+ Object {
+ "data": Object {
+ "itemIcon": "asterisk",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-two",
+ "key": "two",
+ "label": "two",
+ },
+ Object {
+ "data": Object {
+ "itemIcon": "asterisk",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-three",
+ "key": "three",
+ "label": "three",
+ },
+ Object {
+ "data": Object {
+ "itemIcon": "empty",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-four",
+ "key": "four",
+ "label": "four",
+ },
+ ]
+ `);
+ });
+
+ it('inits the totalSelectedItems correctly', async () => {
+ const { result } = renderHook(() => useItemsState(props));
+
+ expect(result.current.totalSelectedItems).toBe(3);
+ });
+
+ it('selects all items correctly', async () => {
+ const { result } = renderHook(() => useItemsState(props));
+
+ act(() => {
+ result.current.onSelectAll();
+ });
+
+ expect(result.current.totalSelectedItems).toBe(4);
+
+ expect(result.current.state).toMatchInlineSnapshot(`
+ Object {
+ "itemCounterMap": Map {
+ "one" => 2,
+ "two" => 1,
+ "three" => 1,
+ },
+ "items": Object {
+ "four": Object {
+ "data": Object {},
+ "dirty": true,
+ "icon": "check",
+ "itemState": "checked",
+ "key": "four",
+ },
+ "one": Object {
+ "data": Object {},
+ "dirty": true,
+ "icon": "check",
+ "itemState": "checked",
+ "key": "one",
+ },
+ "three": Object {
+ "data": Object {},
+ "dirty": true,
+ "icon": "check",
+ "itemState": "checked",
+ "key": "three",
+ },
+ "two": Object {
+ "data": Object {},
+ "dirty": true,
+ "icon": "check",
+ "itemState": "checked",
+ "key": "two",
+ },
+ },
+ }
+ `);
+
+ expect(result.current.options).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "checked": "on",
+ "data": Object {
+ "itemIcon": "check",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-one",
+ "key": "one",
+ "label": "one",
+ },
+ Object {
+ "checked": "on",
+ "data": Object {
+ "itemIcon": "check",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-two",
+ "key": "two",
+ "label": "two",
+ },
+ Object {
+ "checked": "on",
+ "data": Object {
+ "itemIcon": "check",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-three",
+ "key": "three",
+ "label": "three",
+ },
+ Object {
+ "checked": "on",
+ "data": Object {
+ "itemIcon": "check",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-four",
+ "key": "four",
+ "label": "four",
+ },
+ ]
+ `);
+
+ expect(onChangeItems).toHaveBeenCalledWith({
+ selectedItems: ['one', 'two', 'three', 'four'],
+ unSelectedItems: [],
+ });
+ });
+
+ it('unselects all items correctly', async () => {
+ const { result } = renderHook(() => useItemsState(props));
+
+ act(() => {
+ result.current.onSelectNone();
+ });
+
+ expect(result.current.totalSelectedItems).toBe(0);
+
+ expect(result.current.state).toMatchInlineSnapshot(`
+ Object {
+ "itemCounterMap": Map {
+ "one" => 2,
+ "two" => 1,
+ "three" => 1,
+ },
+ "items": Object {
+ "four": Object {
+ "data": Object {},
+ "dirty": false,
+ "icon": "empty",
+ "itemState": "unchecked",
+ "key": "four",
+ },
+ "one": Object {
+ "data": Object {},
+ "dirty": true,
+ "icon": "empty",
+ "itemState": "unchecked",
+ "key": "one",
+ },
+ "three": Object {
+ "data": Object {},
+ "dirty": true,
+ "icon": "empty",
+ "itemState": "unchecked",
+ "key": "three",
+ },
+ "two": Object {
+ "data": Object {},
+ "dirty": true,
+ "icon": "empty",
+ "itemState": "unchecked",
+ "key": "two",
+ },
+ },
+ }
+ `);
+
+ expect(result.current.options).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "data": Object {
+ "itemIcon": "empty",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-one",
+ "key": "one",
+ "label": "one",
+ },
+ Object {
+ "data": Object {
+ "itemIcon": "empty",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-two",
+ "key": "two",
+ "label": "two",
+ },
+ Object {
+ "data": Object {
+ "itemIcon": "empty",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-three",
+ "key": "three",
+ "label": "three",
+ },
+ Object {
+ "data": Object {
+ "itemIcon": "empty",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-four",
+ "key": "four",
+ "label": "four",
+ },
+ ]
+ `);
+
+ expect(onChangeItems).toHaveBeenCalledWith({
+ selectedItems: [],
+ unSelectedItems: ['one', 'two', 'three'],
+ });
+ });
+
+ it('selects and unselects correctly', async () => {
+ const { result } = renderHook(() => useItemsState(props));
+
+ const newOptions = [
+ { key: 'one', label: 'one' },
+ { checked: 'on', key: 'two', label: 'two' },
+ { checked: 'on', key: 'four', label: 'four' },
+ ] as ItemSelectableOption[];
+
+ act(() => {
+ result.current.onChange(newOptions);
+ });
+
+ expect(result.current.totalSelectedItems).toBe(3);
+
+ expect(result.current.state).toMatchInlineSnapshot(`
+ Object {
+ "itemCounterMap": Map {
+ "one" => 2,
+ "two" => 1,
+ "three" => 1,
+ },
+ "items": Object {
+ "four": Object {
+ "data": Object {},
+ "dirty": true,
+ "icon": "check",
+ "itemState": "checked",
+ "key": "four",
+ },
+ "one": Object {
+ "data": Object {},
+ "dirty": true,
+ "icon": "empty",
+ "itemState": "unchecked",
+ "key": "one",
+ },
+ "three": Object {
+ "data": Object {},
+ "dirty": false,
+ "icon": "asterisk",
+ "itemState": "partial",
+ "key": "three",
+ },
+ "two": Object {
+ "data": Object {},
+ "dirty": true,
+ "icon": "check",
+ "itemState": "checked",
+ "key": "two",
+ },
+ },
+ }
+ `);
+
+ expect(result.current.options).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "data": Object {
+ "itemIcon": "empty",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-one",
+ "key": "one",
+ "label": "one",
+ },
+ Object {
+ "checked": "on",
+ "data": Object {
+ "itemIcon": "check",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-two",
+ "key": "two",
+ "label": "two",
+ },
+ Object {
+ "data": Object {
+ "itemIcon": "asterisk",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-three",
+ "key": "three",
+ "label": "three",
+ },
+ Object {
+ "checked": "on",
+ "data": Object {
+ "itemIcon": "check",
+ },
+ "data-test-subj": "alerts-actions-items-edit-selectable-item-four",
+ "key": "four",
+ "label": "four",
+ },
+ ]
+ `);
+ });
+
+ it('changes the label of the new item correctly', async () => {
+ const { result } = renderHook(() => useItemsState(props));
+
+ const newOptions = [
+ { key: 'one', label: 'my whatever label', data: { newItem: true } },
+ { checked: 'on', key: 'two', label: 'two' },
+ { checked: 'on', key: 'four', label: 'four' },
+ ] as ItemSelectableOption[];
+
+ act(() => {
+ result.current.onChange(newOptions);
+ });
+
+ const itemOne = result.current.options.find((item) => item.key === 'one')!;
+
+ expect(itemOne.label).toBe('one');
+ });
+
+ it('keeps the data of the option', async () => {
+ const { result } = renderHook(() => useItemsState(props));
+
+ const newOptions = [
+ { key: 'one', label: 'one', data: { foo: 'bar' } },
+ { key: 'two', label: 'two', checked: 'on', data: { baz: 'qux' } },
+ { key: 'three', label: 'three', checked: 'on' },
+ ] as ItemSelectableOption[];
+
+ act(() => {
+ result.current.onChange(newOptions);
+ });
+
+ const itemOne = result.current.state.items.one;
+ const itemOneOption = result.current.options.find((item) => item.key === 'one')!;
+
+ const itemTwo = result.current.state.items.two;
+ const itemTwoOption = result.current.options.find((item) => item.key === 'two')!;
+
+ expect(itemOne.data).toEqual({ foo: 'bar' });
+ expect(itemOneOption.data).toEqual({ foo: 'bar', itemIcon: 'empty' });
+
+ expect(itemTwo.data).toEqual({ baz: 'qux' });
+ expect(itemTwoOption.data).toEqual({ baz: 'qux', itemIcon: 'check' });
+ });
+
+ it('does not add the new item as unselected', async () => {
+ const { result } = renderHook(() => useItemsState(props));
+
+ const newOptions = [
+ { key: 'one', label: 'one', data: { newItem: true } },
+ { key: 'two', label: 'two', checked: 'on' },
+ ] as ItemSelectableOption[];
+
+ act(() => {
+ result.current.onChange(newOptions);
+ });
+
+ expect(onChangeItems).toBeCalledWith({
+ selectedItems: ['two'],
+ unSelectedItems: [],
+ });
+ });
+
+ it('does not add non dirty items as unselected', async () => {
+ const { result } = renderHook(() => useItemsState(props));
+
+ const newOptions = [{ key: 'two', label: 'two', checked: 'on' }] as ItemSelectableOption[];
+
+ act(() => {
+ result.current.onChange(newOptions);
+ });
+
+ /**
+ * Item four initial state has dirty=false
+ * It should not be part of the unSelectedItems
+ */
+ expect(onChangeItems).toBeCalledWith({
+ selectedItems: ['two'],
+ unSelectedItems: [],
+ });
+ });
+
+ it('calls itemToSelectableOption correctly', async () => {
+ renderHook(() => useItemsState(props));
+
+ expect(itemToSelectableOption).toHaveBeenNthCalledWith(1, {
+ data: {},
+ key: 'one',
+ });
+
+ expect(itemToSelectableOption).toHaveBeenNthCalledWith(2, {
+ data: {},
+ key: 'two',
+ });
+
+ expect(itemToSelectableOption).toHaveBeenNthCalledWith(3, {
+ data: {},
+ key: 'three',
+ });
+ });
+
+ it('calls itemToSelectableOption with data correctly', async () => {
+ const { result } = renderHook(() => useItemsState(props));
+
+ const newOptions = [
+ { key: 'one', label: 'one', data: { foo: 'bar' } },
+ { key: 'two', label: 'two', checked: 'on', data: { baz: 'qux' } },
+ { key: 'three', label: 'three', checked: 'on' },
+ ] as ItemSelectableOption[];
+
+ act(() => {
+ result.current.onChange(newOptions);
+ });
+
+ expect(itemToSelectableOption).toHaveBeenCalledWith({ key: 'one', data: { foo: 'bar' } });
+ expect(itemToSelectableOption).toHaveBeenCalledWith({ key: 'two', data: { baz: 'qux' } });
+ });
+
+ it('defaults the label to key if not returned by itemToSelectableOption', async () => {
+ itemToSelectableOption.mockImplementation((item) => ({
+ key: item.key,
+ }));
+
+ const { result } = renderHook(() => useItemsState(props));
+
+ for (const option of result.current.options) {
+ expect(option.label).toBe(option.label);
+ }
+ });
+
+ it('prevents itemToSelectableOption to override itemIcon', async () => {
+ itemToSelectableOption.mockImplementation((item) => ({
+ key: item.key,
+ data: { itemIcon: 'my-icon' },
+ }));
+
+ const validIcons = ['check', 'asterisk', 'empty'];
+
+ const { result } = renderHook(() => useItemsState(props));
+
+ for (const option of result.current.options) {
+ const hasValidIcon = validIcons.some((icon) => icon === option.data?.itemIcon);
+ expect(hasValidIcon).toBe(true);
+ }
+ });
+
+ it('calls fieldSelector correctly', async () => {
+ renderHook(() => useItemsState(props));
+
+ expect(fieldSelector).toHaveBeenCalledWith(mockAlert);
+ });
+});
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/tags/items/use_items_state.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/tags/items/use_items_state.tsx
new file mode 100644
index 0000000000000..216ee7950d93c
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/tags/items/use_items_state.tsx
@@ -0,0 +1,357 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { EuiSelectableOption, IconType } from '@elastic/eui';
+import { assertNever } from '@elastic/eui';
+import { useCallback, useReducer, useMemo } from 'react';
+import type { Alert } from '@kbn/alerting-types';
+import type { ItemSelectableOption, ItemsSelectionState } from './types';
+
+interface UseItemsStateProps {
+ items: string[];
+ selectedAlerts: Alert[];
+ itemToSelectableOption: (item: Payload[number]) => EuiSelectableOption;
+ fieldSelector: (alert: Alert) => string[];
+ onChangeItems: (args: ItemsSelectionState) => void;
+}
+
+enum ItemState {
+ CHECKED = 'checked',
+ PARTIAL = 'partial',
+ UNCHECKED = 'unchecked',
+}
+
+export enum Actions {
+ CHECK_ITEM,
+ UNCHECK_ITEM,
+ SET_NEW_STATE,
+}
+
+enum ICONS {
+ CHECKED = 'check',
+ PARTIAL = 'asterisk',
+ UNCHECKED = 'empty',
+}
+
+type Payload = Array>;
+type Action =
+ | { type: Actions.CHECK_ITEM; payload: Payload }
+ | { type: Actions.UNCHECK_ITEM; payload: Payload }
+ | { type: Actions.SET_NEW_STATE; payload: State };
+
+interface Item {
+ key: string;
+ itemState: ItemState;
+ dirty: boolean;
+ icon: IconType;
+ data: Record;
+}
+
+interface State {
+ items: Record;
+ itemCounterMap: Map;
+}
+
+const stateToIconMap: Record = {
+ [ItemState.CHECKED]: ICONS.CHECKED,
+ [ItemState.PARTIAL]: ICONS.PARTIAL,
+ [ItemState.UNCHECKED]: ICONS.UNCHECKED,
+};
+
+/**
+ * The EuiSelectable has two states values for its items: checked="on" for checked items
+ * and check=undefined for unchecked items. Given that our use alert needs
+ * to track items that are part in some alerts and not part in some others we need
+ * to keep our own state and sync it with the EuiSelectable. Our state is always
+ * the source of true.
+ *
+ * In our state, a item can be in one of the following states: checked, partial, and unchecked.
+ * A checked item is a item that is either common in all alerts or has been
+ * checked by the user. A partial item is a item that is available is some of the
+ * selected alerts and not available in others. A user can not make a item partial.
+ * A unchecked item is a item that is either unselected by the user or is not available
+ * in all selected alerts.
+ *
+ * State transitions:
+ *
+ * partial --> checked
+ * checked --> unchecked
+ * unchecked --> checked
+ *
+ * A dirty item is a item that the user clicked. Because the EuiSelectable
+ * returns all items (items) on each user interaction we need to distinguish items
+ * that the user unselected from items that are not common between all selected alerts
+ * and the user did not interact with them. Marking items as dirty help us to do that.
+ * A user to unselect a item needs to fist checked a partial or an unselected item and make it
+ * selected (and dirty). This guarantees that unchecked items will always become dirty at some
+ * point in the past.
+ *
+ * On mount (initial state) the component gets all available items.
+ * The items that are common in all selected alerts are marked as checked
+ * and dirty in our state and checked in EuiSelectable state.
+ * The ones that are not common in any of the selected items are
+ * marked as unchecked and not dirty in our state and unchecked in EuiSelectable state.
+ * The items that are common in some of the alerts are marked as partial and not dirty
+ * in our state and unchecked in EuiSelectable state.
+ *
+ * When a user interacts with a item the following happens:
+ * a) If the item is unchecked the EuiSelectable marks it as checked and
+ * we change the state of the item as checked and dirty.
+ * b) If the item is partial the EuiSelectable marks it as checked and
+ * we change the state of the item as checked and dirty.
+ * c) If the item is checked the EuiSelectable marks it as unchecked and
+ * we change the state of the item as unchecked and dirty.
+ */
+
+const itemsReducer: React.Reducer = (state: State, action): State => {
+ switch (action.type) {
+ case Actions.CHECK_ITEM:
+ const selectedItems: State['items'] = {};
+
+ for (const item of action.payload) {
+ selectedItems[item.key] = {
+ key: item.key,
+ itemState: ItemState.CHECKED,
+ dirty: true,
+ icon: ICONS.CHECKED,
+ data: item.data,
+ };
+ }
+
+ return { ...state, items: { ...state.items, ...selectedItems } };
+
+ case Actions.UNCHECK_ITEM:
+ const unSelectedItems: State['items'] = {};
+
+ for (const item of action.payload) {
+ unSelectedItems[item.key] = {
+ key: item.key,
+ itemState: ItemState.UNCHECKED,
+ dirty: true,
+ icon: ICONS.UNCHECKED,
+ data: item.data,
+ };
+ }
+
+ return { ...state, items: { ...state.items, ...unSelectedItems } };
+
+ case Actions.SET_NEW_STATE:
+ return { ...action.payload };
+
+ default:
+ assertNever(action);
+ }
+};
+
+const getInitialItemsState = ({
+ items,
+ selectedAlerts,
+ fieldSelector,
+}: {
+ items: string[];
+ selectedAlerts: Alert[];
+ fieldSelector: UseItemsStateProps['fieldSelector'];
+}): State => {
+ const itemCounterMap = createItemsCounterMapping({ selectedAlerts, fieldSelector });
+ const totalAlerts = selectedAlerts.length;
+ const itemsRecord: State['items'] = {};
+ const state = { items: itemsRecord, itemCounterMap };
+
+ for (const item of items) {
+ const itemsCounter = itemCounterMap.get(item) ?? 0;
+ const isCheckedItem = itemsCounter === totalAlerts;
+ const isPartialItem = itemsCounter < totalAlerts && itemsCounter !== 0;
+ const itemState = isCheckedItem
+ ? ItemState.CHECKED
+ : isPartialItem
+ ? ItemState.PARTIAL
+ : ItemState.UNCHECKED;
+
+ const icon = getSelectionIcon(itemState);
+
+ itemsRecord[item] = { key: item, itemState, dirty: isCheckedItem, icon, data: {} };
+ }
+
+ return state;
+};
+
+const createItemsCounterMapping = ({
+ selectedAlerts,
+ fieldSelector,
+}: {
+ selectedAlerts: Alert[];
+ fieldSelector: UseItemsStateProps['fieldSelector'];
+}) => {
+ const counterMap = new Map();
+
+ for (const alert of selectedAlerts) {
+ const items = fieldSelector(alert);
+
+ if (!items) continue;
+
+ for (const item of items) {
+ counterMap.set(item, (counterMap.get(item) ?? 0) + 1);
+ }
+ }
+
+ return counterMap;
+};
+
+const getSelectionIcon = (itemState: ItemState): ICONS => {
+ return stateToIconMap[itemState];
+};
+
+export const getSelectedAndUnselectedItems = (
+ newOptions: ItemSelectableOption[],
+ items: State['items']
+) => {
+ const selectedItems: Payload = [];
+ const unSelectedItems: Payload = [];
+
+ for (const option of newOptions) {
+ if (option.checked === 'on') {
+ selectedItems.push({ key: option.key, data: option.data ?? {} });
+ }
+
+ /**
+ * User can only select the "Add new item" item. Because a new item do not have a state yet
+ * we need to ensure that state access is done only by options with state.
+ */
+ if (!option.data?.newItem && !option.checked && items[option.key] && items[option.key].dirty) {
+ unSelectedItems.push({ key: option.key, data: option.data ?? {} });
+ }
+ }
+
+ return { selectedItems, unSelectedItems };
+};
+
+const getKeysFromPayload = (items: Payload): string[] => items.map((item) => item.key);
+
+const stateToPayload = (items: State['items']): Payload =>
+ Object.keys(items).map((key) => ({ key, data: items[key].data }));
+
+export const useItemsState = ({
+ items,
+ selectedAlerts,
+ fieldSelector,
+ itemToSelectableOption,
+ onChangeItems,
+}: UseItemsStateProps) => {
+ /**
+ * If react query refetch on the background and fetches new items the component will
+ * rerender but it will not change the state. getInitialItemsState will run only on
+ * mount. This is a desired behaviour because it prevents the list of items for changing
+ * while the user interacts with the selectable.
+ */
+
+ const [state, dispatch] = useReducer(
+ itemsReducer,
+ { items, selectedAlerts, fieldSelector },
+ getInitialItemsState
+ );
+
+ const stateToOptions = useCallback((): ItemSelectableOption[] => {
+ const itemsKeys = Object.keys(state.items);
+
+ return itemsKeys.map((key): EuiSelectableOption => {
+ const convertedItem = itemToSelectableOption({ key, data: state.items[key].data });
+
+ return {
+ key,
+ ...(state.items[key].itemState === ItemState.CHECKED ? { checked: 'on' } : {}),
+ 'data-test-subj': `alerts-actions-items-edit-selectable-item-${key}`,
+ ...convertedItem,
+ label: convertedItem.label ?? key,
+ data: { ...convertedItem?.data, itemIcon: state.items[key].icon },
+ };
+ }) as ItemSelectableOption[];
+ }, [state.items, itemToSelectableOption]);
+
+ const onChange = useCallback(
+ (newOptions: ItemSelectableOption[]) => {
+ /**
+ * In this function the user has selected and deselected some items. If the user
+ * pressed the "add new item" option it means that needs to add the new item to the list.
+ * Because the label of the "add new item" item is "Add ${searchValue} as a item" we need to
+ * change the label to the same as the item the user entered. The key will always be the
+ * search term (aka the new label).
+ */
+ const normalizeOptions = newOptions.map((option) => {
+ if (option.data?.newItem) {
+ return {
+ ...option,
+ label: option.key ?? '',
+ };
+ }
+
+ return option;
+ });
+
+ const { selectedItems, unSelectedItems } = getSelectedAndUnselectedItems(
+ normalizeOptions,
+ state.items
+ );
+
+ dispatch({ type: Actions.CHECK_ITEM, payload: selectedItems });
+ dispatch({ type: Actions.UNCHECK_ITEM, payload: unSelectedItems });
+ onChangeItems({
+ selectedItems: getKeysFromPayload(selectedItems),
+ unSelectedItems: getKeysFromPayload(unSelectedItems),
+ });
+ },
+ [onChangeItems, state.items]
+ );
+
+ const onSelectAll = useCallback(() => {
+ dispatch({ type: Actions.CHECK_ITEM, payload: stateToPayload(state.items) });
+ onChangeItems({ selectedItems: Object.keys(state.items), unSelectedItems: [] });
+ }, [onChangeItems, state.items]);
+
+ const onSelectNone = useCallback(() => {
+ const unSelectedItems: Payload = [];
+
+ for (const [id, item] of Object.entries(state.items)) {
+ if (item.itemState === ItemState.CHECKED || item.itemState === ItemState.PARTIAL) {
+ unSelectedItems.push({ key: id, data: item.data });
+ }
+ }
+
+ dispatch({ type: Actions.UNCHECK_ITEM, payload: unSelectedItems });
+ onChangeItems({ selectedItems: [], unSelectedItems: getKeysFromPayload(unSelectedItems) });
+ }, [state.items, onChangeItems]);
+
+ const options: ItemSelectableOption[] = useMemo(() => stateToOptions(), [stateToOptions]);
+
+ const totalSelectedItems = Object.values(state.items).filter(
+ (item) => item.itemState === ItemState.CHECKED || item.itemState === ItemState.PARTIAL
+ ).length;
+
+ const resetItems = useCallback(
+ (newItems: string[]) => {
+ const newState = getInitialItemsState({ items: newItems, selectedAlerts, fieldSelector });
+ dispatch({ type: Actions.SET_NEW_STATE, payload: newState });
+ },
+ [fieldSelector, selectedAlerts]
+ );
+
+ return useMemo(
+ () => ({
+ state,
+ options,
+ totalSelectedItems,
+ onChange,
+ onSelectAll,
+ onSelectNone,
+ resetItems,
+ }),
+ [onChange, onSelectAll, onSelectNone, options, resetItems, state, totalSelectedItems]
+ );
+};
+
+export type UseItemsState = ReturnType;
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/tags/translations.ts b/src/platform/packages/shared/response-ops/alerts-table/components/tags/translations.ts
new file mode 100644
index 0000000000000..7f04f75ae4c49
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/tags/translations.ts
@@ -0,0 +1,81 @@
+/*
+ * 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 EDITED_ALERTS = (totalAlerts: number) =>
+ i18n.translate('platform.responseOps.alertsTable.editedAlerts', {
+ values: { totalAlerts },
+ defaultMessage: 'Edited {totalAlerts, plural, =1 {alert} other {{totalAlerts} alerts}}',
+ });
+
+export const SELECTED_ALERTS = (totalAlerts: number) =>
+ i18n.translate('platform.responseOps.alertsTable.tags.headerSubtitle', {
+ values: { totalAlerts },
+ defaultMessage: 'Selected alerts: {totalAlerts}',
+ });
+
+export const SAVE_SELECTION = i18n.translate(
+ 'platform.responseOps.alertsTable.tags.saveSelection',
+ {
+ defaultMessage: 'Save selection',
+ }
+);
+
+export const SEARCH_PLACEHOLDER = i18n.translate(
+ 'platform.responseOps.alertsTable.tags.searchPlaceholder',
+ {
+ defaultMessage: 'Search',
+ }
+);
+
+export const CANCEL = i18n.translate('platform.responseOps.alertsTable.tags.cancel', {
+ defaultMessage: 'Cancel',
+});
+
+export const ADD_TAG_CUSTOM_OPTION_LABEL = (searchValue: string) =>
+ i18n.translate('platform.responseOps.alertsTable.configure.addTagCustomOptionLabel', {
+ defaultMessage: 'Add {searchValue} as a tag',
+ values: { searchValue },
+ });
+
+export const EDIT_TAGS = i18n.translate('platform.responseOps.alertsTable.tags.edit', {
+ defaultMessage: 'Edit tags',
+});
+
+export const TOTAL_TAGS = (totalTags: number) =>
+ i18n.translate('platform.responseOps.alertsTable.tags.totalTags', {
+ defaultMessage: 'Total tags: {totalTags}',
+ values: { totalTags },
+ });
+
+export const SELECT_ALL = i18n.translate('platform.responseOps.alertsTable.tags.selectAll', {
+ defaultMessage: 'Select all',
+});
+
+export const SELECT_NONE = i18n.translate('platform.responseOps.alertsTable.tags.selectNone', {
+ defaultMessage: 'Select none',
+});
+
+export const SELECTED_TAGS = (selectedTags: number) =>
+ i18n.translate('platform.responseOps.alertsTable.tags.selectedTags', {
+ defaultMessage: 'Selected: {selectedTags}',
+ values: { selectedTags },
+ });
+
+export const NO_TAGS_AVAILABLE = i18n.translate(
+ 'platform.responseOps.alertsTable.tags.noTagsAvailable',
+ {
+ defaultMessage: 'No tags available. To add a tag, enter it in the query bar',
+ }
+);
+
+export const NO_SEARCH_MATCH = i18n.translate('platform.responseOps.alertsTable.tags.noTagsMatch', {
+ defaultMessage: 'No tags match your search',
+});
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/tags/use_focus_button/index.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/tags/use_focus_button/index.tsx
new file mode 100644
index 0000000000000..5ca75265d68fc
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/tags/use_focus_button/index.tsx
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { EuiFocusTrapProps } from '@elastic/eui';
+import { useMemo } from 'react';
+
+export const useFocusButtonTrap = (
+ focusButtonRef?: React.Ref
+) => {
+ const focusTrapProps: Pick = useMemo(
+ () => ({
+ returnFocus() {
+ if (focusButtonRef && 'current' in focusButtonRef && focusButtonRef.current) {
+ focusButtonRef.current.focus();
+ return false;
+ }
+ return true;
+ },
+ }),
+ [focusButtonRef]
+ );
+
+ return focusTrapProps;
+};
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/tags/use_tags_action.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/tags/use_tags_action.test.tsx
new file mode 100644
index 0000000000000..24d0ea15dcc42
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/tags/use_tags_action.test.tsx
@@ -0,0 +1,248 @@
+/*
+ * 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 type { PropsWithChildren } from 'react';
+import { act, waitFor, renderHook } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@kbn/react-query';
+import { useTagsAction } from './use_tags_action';
+import type { Alert } from '@kbn/alerting-types';
+import { httpServiceMock } from '@kbn/core-http-browser-mocks';
+import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
+import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
+import { testQueryClientConfig } from '@kbn/alerts-ui-shared/src/common/test_utils/test_query_client_config';
+
+jest.mock('../../contexts/alerts_table_context', () => {
+ const actual = jest.requireActual('../../contexts/alerts_table_context');
+ return {
+ ...actual,
+ useAlertsTableContext: jest.fn(),
+ };
+});
+
+const { useAlertsTableContext } = jest.requireMock('../../contexts/alerts_table_context');
+
+const queryClient = new QueryClient(testQueryClientConfig);
+
+const wrapper = ({ children }: PropsWithChildren) => {
+ return (
+
+ {children}
+
+ );
+};
+
+describe('useTagsAction', () => {
+ const mockAlert = {
+ _id: 'alert-1',
+ _index: 'test-index',
+ ALERT_WORKFLOW_TAGS: ['coke', 'pepsi'],
+ } as unknown as Alert;
+
+ const onActionSuccess = jest.fn();
+ const onActionError = jest.fn();
+ const http = httpServiceMock.createStartContract();
+ const notifications = notificationServiceMock.createStartContract();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ http.post.mockResolvedValue({});
+ useAlertsTableContext.mockReturnValue({
+ services: {
+ http,
+ notifications,
+ },
+ });
+ });
+
+ it('renders an action', async () => {
+ const { result } = renderHook(
+ () =>
+ useTagsAction({
+ onActionSuccess,
+ onActionError,
+ isDisabled: false,
+ }),
+ { wrapper }
+ );
+
+ expect(result.current.getAction([mockAlert])).toMatchInlineSnapshot(`
+ Object {
+ "data-test-subj": "alerts-bulk-action-tags",
+ "disabled": false,
+ "icon": ,
+ "key": "alerts-bulk-action-tags",
+ "name": "Edit tags",
+ "onClick": [Function],
+ }
+ `);
+ });
+
+ it('processes the tags correctly', async () => {
+ const { result } = renderHook(
+ () => useTagsAction({ onActionSuccess, onActionError, isDisabled: false }),
+ { wrapper }
+ );
+
+ const action = result.current.getAction([mockAlert]);
+
+ act(() => {
+ action.onClick();
+ });
+
+ expect(result.current.isFlyoutOpen).toBe(true);
+
+ act(() => {
+ result.current.onSaveTags({ selectedItems: ['one'], unSelectedItems: ['pepsi'] });
+ });
+
+ await waitFor(() => {
+ expect(result.current.isFlyoutOpen).toBe(false);
+ });
+
+ await waitFor(() => {
+ expect(http.post).toHaveBeenCalled();
+ });
+
+ expect(http.post).toHaveBeenCalledWith('/internal/rac/alerts/tags', {
+ body: JSON.stringify({
+ index: '.alerts-observability*,.alerts-stack*',
+ alertIds: ['alert-1'],
+ add: ['one'],
+ remove: ['pepsi'],
+ }),
+ });
+
+ await waitFor(() => {
+ expect(onActionSuccess).toHaveBeenCalledTimes(1);
+ });
+ expect(onActionError).not.toHaveBeenCalled();
+ });
+
+ it('opens and closes the flyout correctly', async () => {
+ const { result } = renderHook(
+ () => useTagsAction({ onActionSuccess, onActionError, isDisabled: false }),
+ { wrapper }
+ );
+
+ const action = result.current.getAction([mockAlert]);
+
+ // Initially closed
+ expect(result.current.isFlyoutOpen).toBe(false);
+
+ // Open the flyout
+ act(() => {
+ action.onClick();
+ });
+
+ expect(result.current.isFlyoutOpen).toBe(true);
+
+ // Close the flyout
+ act(() => {
+ result.current.onClose();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isFlyoutOpen).toBe(false);
+ });
+
+ // Callbacks should not be called when just closing the flyout
+ expect(onActionSuccess).not.toHaveBeenCalled();
+ expect(onActionError).not.toHaveBeenCalled();
+ });
+
+ it('handles multiple alerts', async () => {
+ const { result } = renderHook(
+ () => useTagsAction({ onActionSuccess, onActionError, isDisabled: false }),
+ { wrapper }
+ );
+
+ const mockAlert2 = {
+ ...mockAlert,
+ _id: 'alert-2',
+ ALERT_WORKFLOW_TAGS: ['one', 'three'],
+ } as unknown as Alert;
+
+ const action = result.current.getAction([mockAlert, mockAlert2]);
+
+ act(() => {
+ action.onClick();
+ });
+
+ expect(result.current.isFlyoutOpen).toBe(true);
+
+ act(() => {
+ result.current.onSaveTags({ selectedItems: ['one', 'two'], unSelectedItems: ['pepsi'] });
+ });
+
+ await waitFor(() => {
+ expect(result.current.isFlyoutOpen).toBe(false);
+ });
+
+ await waitFor(() => {
+ expect(http.post).toHaveBeenCalled();
+ });
+
+ expect(http.post).toHaveBeenCalledWith('/internal/rac/alerts/tags', {
+ body: JSON.stringify({
+ index: '.alerts-observability*,.alerts-stack*',
+ alertIds: ['alert-1', 'alert-2'],
+ add: ['one', 'two'],
+ remove: ['pepsi'],
+ }),
+ });
+
+ await waitFor(() => {
+ expect(onActionSuccess).toHaveBeenCalledTimes(1);
+ });
+ expect(onActionError).not.toHaveBeenCalled();
+ });
+
+ it('calls onActionError when the API request fails', async () => {
+ http.post.mockRejectedValue(new Error('API Error'));
+
+ const { result } = renderHook(
+ () => useTagsAction({ onActionSuccess, onActionError, isDisabled: false }),
+ { wrapper }
+ );
+
+ const action = result.current.getAction([mockAlert]);
+
+ act(() => {
+ action.onClick();
+ });
+
+ expect(result.current.isFlyoutOpen).toBe(true);
+
+ act(() => {
+ result.current.onSaveTags({ selectedItems: ['one'], unSelectedItems: ['pepsi'] });
+ });
+
+ await waitFor(() => {
+ expect(http.post).toHaveBeenCalled();
+ });
+
+ expect(http.post).toHaveBeenCalledWith('/internal/rac/alerts/tags', {
+ body: JSON.stringify({
+ index: '.alerts-observability*,.alerts-stack*',
+ alertIds: ['alert-1'],
+ add: ['one'],
+ remove: ['pepsi'],
+ }),
+ });
+
+ await waitFor(() => {
+ expect(onActionError).toHaveBeenCalledTimes(1);
+ });
+ expect(onActionSuccess).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/platform/packages/shared/response-ops/alerts-table/components/tags/use_tags_action.tsx b/src/platform/packages/shared/response-ops/alerts-table/components/tags/use_tags_action.tsx
new file mode 100644
index 0000000000000..1fba9bc4c0e38
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/components/tags/use_tags_action.tsx
@@ -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 { EuiIcon } from '@elastic/eui';
+import React, { useCallback, useState } from 'react';
+import type { Alert } from '@kbn/alerting-types';
+import type { ItemsSelectionState, UseActionProps } from './items/types';
+import * as i18n from './translations';
+import { useBulkUpdateAlertTags } from '../../hooks/use_bulk_update_alert_tags';
+import { useAlertsTableContext } from '../../contexts/alerts_table_context';
+
+const OBS_STACK_ALERTS_INDEX = '.alerts-observability*,.alerts-stack*';
+
+export interface TagsActionState {
+ isFlyoutOpen: boolean;
+ onClose: () => void;
+ openFlyout: (alerts: Alert[]) => void;
+ onSaveTags: (tagsSelection: ItemsSelectionState) => Promise;
+ selectedAlerts: Alert[];
+ getAction: (alerts: Alert[]) => {
+ name: string;
+ onClick: () => void;
+ disabled: boolean;
+ 'data-test-subj': string;
+ icon: React.ReactNode;
+ key: string;
+ };
+}
+
+export const useTagsAction = ({
+ onActionSuccess,
+ onActionError,
+ isDisabled,
+}: UseActionProps): TagsActionState => {
+ const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
+ const onClose = useCallback(() => setIsFlyoutOpen(false), []);
+ const [selectedAlerts, setSelectedAlerts] = useState([]);
+ const {
+ services: { http, notifications },
+ } = useAlertsTableContext();
+
+ const { mutateAsync: bulkUpdateAlertTags } = useBulkUpdateAlertTags({ http, notifications });
+
+ const openFlyout = useCallback((alerts: Alert[]) => {
+ setIsFlyoutOpen(true);
+ setSelectedAlerts(alerts);
+ }, []);
+
+ const onSaveItems = useCallback(
+ async (tagsSelection: ItemsSelectionState) => {
+ try {
+ await bulkUpdateAlertTags({
+ index: OBS_STACK_ALERTS_INDEX,
+ alertIds: selectedAlerts.map((alert) => alert._id),
+ add: tagsSelection.selectedItems?.length ? tagsSelection.selectedItems : undefined,
+ remove: tagsSelection.unSelectedItems?.length ? tagsSelection.unSelectedItems : undefined,
+ });
+
+ onActionSuccess?.();
+ } catch {
+ onActionError?.();
+ } finally {
+ onClose();
+ }
+ },
+ [bulkUpdateAlertTags, onClose, onActionSuccess, onActionError, selectedAlerts]
+ );
+
+ const getAction = (alerts: Alert[]) => {
+ return {
+ name: i18n.EDIT_TAGS,
+ onClick: () => openFlyout(alerts),
+ disabled: isDisabled,
+ 'data-test-subj': 'alerts-bulk-action-tags',
+ icon: ,
+ key: 'alerts-bulk-action-tags',
+ };
+ };
+
+ return { getAction, openFlyout, isFlyoutOpen, onClose, onSaveTags: onSaveItems, selectedAlerts };
+};
+
+export type UseTagsAction = ReturnType;
diff --git a/src/platform/packages/shared/response-ops/alerts-table/constants.ts b/src/platform/packages/shared/response-ops/alerts-table/constants.ts
index 53e31a74032d7..190f2f2127cec 100644
--- a/src/platform/packages/shared/response-ops/alerts-table/constants.ts
+++ b/src/platform/packages/shared/response-ops/alerts-table/constants.ts
@@ -125,6 +125,7 @@ export const mutationKeys = {
root: 'alertsTable',
bulkUntrackAlerts: () => [mutationKeys.root, 'bulkUntrackAlerts'] as const,
bulkUntrackAlertsByQuery: () => [mutationKeys.root, 'bulkUntrackAlertsByQuery'] as const,
+ bulkUpdateAlertTags: () => [mutationKeys.root, 'bulkUpdateAlertTags'] as const,
};
export const INTERNAL_BASE_ALERTING_API_PATH = '/internal/alerting' as const;
diff --git a/src/platform/packages/shared/response-ops/alerts-table/contexts/individual_tags_action_context.tsx b/src/platform/packages/shared/response-ops/alerts-table/contexts/individual_tags_action_context.tsx
new file mode 100644
index 0000000000000..50881afe7b213
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/contexts/individual_tags_action_context.tsx
@@ -0,0 +1,40 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { PropsWithChildren } from 'react';
+import React, { createContext, useContext } from 'react';
+import { typedMemo } from '../utils/react';
+import type { TagsActionState } from '../components/tags/use_tags_action';
+
+const IndividualTagsActionContext = createContext(null);
+
+export const IndividualTagsActionContextProvider = typedMemo(
+ ({
+ children,
+ value,
+ }: PropsWithChildren<{
+ value: TagsActionState;
+ }>) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+export const useIndividualTagsActionContext = (): TagsActionState => {
+ const context = useContext(IndividualTagsActionContext);
+ if (!context) {
+ throw new Error(
+ 'useIndividualTagsActionContext must be used within IndividualTagsActionContextProvider'
+ );
+ }
+ return context;
+};
diff --git a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.test.tsx
index 4cfe26ae7d0cd..3cd0e5869d9ac 100644
--- a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.test.tsx
+++ b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.test.tsx
@@ -24,15 +24,18 @@ import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
jest.mock('../apis/bulk_get_cases');
jest.mock('../contexts/alerts_table_context');
+const mockCasesService = createCasesServiceMock();
+const http = httpServiceMock.createStartContract();
+const notifications = notificationServiceMock.createStartContract();
jest.mocked(useAlertsTableContext).mockReturnValue(
createPartialObjectMock>({
bulkActionsStore: [{}, jest.fn()],
+ services: {
+ http,
+ notifications,
+ },
})
);
-
-const mockCasesService = createCasesServiceMock();
-const http = httpServiceMock.createStartContract();
-const notifications = notificationServiceMock.createStartContract();
const application = applicationServiceMock.createStartContract();
application.capabilities = { ...application.capabilities, infrastructure: { show: true } };
const queryClient = new QueryClient(testQueryClientConfig);
@@ -467,7 +470,7 @@ describe('bulk action hooks', () => {
.mockReturnValue({ create: true, read: true });
});
- it('appends the case and untrack bulk actions', async () => {
+ it('appends the internal bulk actions correctly for non siem rule types', async () => {
const { result } = renderHook(
() =>
useBulkActions({
@@ -514,6 +517,14 @@ describe('bulk action hooks', () => {
"label": "Mark as untracked",
"onClick": [Function],
},
+ Object {
+ "data-test-subj": "edit-tags",
+ "disableOnQuery": true,
+ "disabledLabel": "Edit tags",
+ "key": "edit-tags",
+ "label": "Edit tags",
+ "onClick": [Function],
+ },
],
},
]
diff --git a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.ts b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.ts
index a333d1a949eae..484428b1ee2ef 100644
--- a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.ts
+++ b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_actions.ts
@@ -21,6 +21,7 @@ import type {
BulkActionsState,
BulkActionsReducerAction,
TimelineItem,
+ BulkEditTagsFlyoutState,
} from '../types';
import { BulkActionsVerbs } from '../types';
import type { CasesService, PublicAlertsDataGridProps } from '../types';
@@ -28,11 +29,13 @@ import {
ADD_TO_EXISTING_CASE,
ADD_TO_NEW_CASE,
ALERTS_ALREADY_ATTACHED_TO_CASE,
+ EDIT_TAGS,
MARK_AS_UNTRACKED,
NO_ALERTS_ADDED_TO_CASE,
} from '../translations';
import { useBulkUntrackAlerts } from './use_bulk_untrack_alerts';
import { useBulkUntrackAlertsByQuery } from './use_bulk_untrack_alerts_by_query';
+import { useTagsAction } from '../components/tags/use_tags_action';
interface BulkActionsProps {
ruleTypeIds?: string[];
@@ -55,6 +58,7 @@ export interface UseBulkActions {
setIsBulkActionsLoading: (isLoading: boolean) => void;
clearSelection: () => void;
updateBulkActionsState: React.Dispatch;
+ bulkEditTagsFlyoutState: BulkEditTagsFlyoutState;
}
type UseBulkAddToCaseActionsProps = Pick<
@@ -71,6 +75,29 @@ type UseBulkUntrackActionsProps = Pick<
isAllSelected: boolean;
};
+type UseBulkTagsActionsProps = Pick &
+ Pick;
+
+const noCapabilitiesForAction = (capabilities: ApplicationStart['capabilities']) => {
+ const hasApmPermission = capabilities?.apm?.['alerting:show'];
+ const hasInfrastructurePermission = capabilities?.infrastructure?.show;
+ const hasLogsPermission = capabilities?.logs?.show;
+ const hasUptimePermission = capabilities?.uptime?.show;
+ const hasSloPermission = capabilities?.slo?.show;
+ const hasObservabilityPermission = capabilities?.observability?.show;
+
+ const conditions = [
+ hasApmPermission,
+ hasInfrastructurePermission,
+ hasLogsPermission,
+ hasUptimePermission,
+ hasSloPermission,
+ hasObservabilityPermission,
+ ];
+
+ return conditions.every((condition) => !condition);
+};
+
const filterAlertsAlreadyAttachedToCase = (alerts: TimelineItem[], caseId: string) =>
alerts.filter(
(alert) =>
@@ -222,12 +249,6 @@ export const useBulkUntrackActions = ({
notifications,
});
- const hasApmPermission = application?.capabilities.apm?.['alerting:show'];
- const hasInfrastructurePermission = application?.capabilities.infrastructure?.show;
- const hasLogsPermission = application?.capabilities.logs?.show;
- const hasUptimePermission = application?.capabilities.uptime?.show;
- const hasSloPermission = application?.capabilities.slo?.show;
- const hasObservabilityPermission = application?.capabilities.observability?.show;
const onClick = useCallback(
async (alerts?: TimelineItem[]) => {
if (!alerts) return;
@@ -258,16 +279,7 @@ export const useBulkUntrackActions = ({
return useMemo(() => {
// Check if at least one Observability feature is enabled
- if (!application?.capabilities) return [];
- if (
- !hasApmPermission &&
- !hasInfrastructurePermission &&
- !hasLogsPermission &&
- !hasUptimePermission &&
- !hasSloPermission &&
- !hasObservabilityPermission
- )
- return [];
+ if (noCapabilitiesForAction(application?.capabilities)) return [];
return [
{
label: MARK_AS_UNTRACKED,
@@ -278,16 +290,27 @@ export const useBulkUntrackActions = ({
onClick,
},
];
- }, [
- application?.capabilities,
- hasApmPermission,
- hasInfrastructurePermission,
- hasLogsPermission,
- hasUptimePermission,
- hasSloPermission,
- hasObservabilityPermission,
- onClick,
- ]);
+ }, [application?.capabilities, onClick]);
+};
+
+export const useBulkTagsActions = ({ refresh, clearSelection }: UseBulkTagsActionsProps) => {
+ const onActionSuccess = useCallback(() => {
+ refresh();
+ clearSelection();
+ }, [clearSelection, refresh]);
+
+ const onActionError = useCallback(() => {
+ refresh();
+ clearSelection();
+ }, [clearSelection, refresh]);
+
+ const tagsAction = useTagsAction({
+ onActionSuccess,
+ onActionError,
+ isDisabled: false,
+ });
+
+ return { tagsAction };
};
const EMPTY_BULK_ACTIONS_CONFIG: BulkActionsPanelConfig[] = [];
@@ -337,10 +360,43 @@ export function useBulkActions({
http,
notifications,
});
+ const { tagsAction } = useBulkTagsActions({
+ refresh,
+ clearSelection,
+ });
+
+ const tagsBulkActions = useMemo(() => {
+ return noCapabilitiesForAction(application?.capabilities)
+ ? []
+ : [
+ {
+ label: EDIT_TAGS,
+ key: 'edit-tags',
+ disableOnQuery: true,
+ disabledLabel: EDIT_TAGS,
+ 'data-test-subj': 'edit-tags',
+ onClick: (alerts?: TimelineItem[]) => {
+ if (!alerts) return;
+ const alertsForFlyout = alerts.map((alert) => {
+ return {
+ _id: alert._id,
+ _index: alert._index as string,
+ };
+ });
+ const action = tagsAction.getAction(alertsForFlyout);
+ action.onClick();
+ },
+ },
+ ];
+ }, [tagsAction, application?.capabilities]);
const initialItems = useMemo(() => {
- return [...caseBulkActions, ...(ruleTypeIds?.some(isSiemRuleType) ? [] : untrackBulkActions)];
- }, [caseBulkActions, ruleTypeIds, untrackBulkActions]);
+ return [
+ ...caseBulkActions,
+ ...(ruleTypeIds?.some(isSiemRuleType) ? [] : untrackBulkActions),
+ ...(ruleTypeIds?.some(isSiemRuleType) ? [] : tagsBulkActions),
+ ];
+ }, [caseBulkActions, ruleTypeIds, untrackBulkActions, tagsBulkActions]);
const bulkActions = useMemo(() => {
if (hideBulkActions) {
@@ -364,6 +420,14 @@ export function useBulkActions({
});
}, [alertsCount, updateBulkActionsState]);
+ const bulkEditTagsFlyoutState = useMemo(() => {
+ return {
+ isFlyoutOpen: tagsAction.isFlyoutOpen,
+ onClose: tagsAction.onClose,
+ onSaveTags: tagsAction.onSaveTags,
+ };
+ }, [tagsAction]);
+
return useMemo(() => {
return {
isBulkActionsColumnActive,
@@ -372,6 +436,7 @@ export function useBulkActions({
setIsBulkActionsLoading,
clearSelection,
updateBulkActionsState,
+ bulkEditTagsFlyoutState,
};
}, [
bulkActions,
@@ -380,5 +445,6 @@ export function useBulkActions({
isBulkActionsColumnActive,
setIsBulkActionsLoading,
updateBulkActionsState,
+ bulkEditTagsFlyoutState,
]);
}
diff --git a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_update_alert_tags.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_update_alert_tags.test.tsx
new file mode 100644
index 0000000000000..e8f6aefc49643
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_update_alert_tags.test.tsx
@@ -0,0 +1,253 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import type { PropsWithChildren } from 'react';
+import React from 'react';
+import { renderHook, waitFor } from '@testing-library/react';
+import { httpServiceMock } from '@kbn/core-http-browser-mocks';
+import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
+import { QueryClient, QueryClientProvider } from '@kbn/react-query';
+import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
+import { useBulkUpdateAlertTags } from './use_bulk_update_alert_tags';
+import { testQueryClientConfig } from '../utils/test';
+
+const http = httpServiceMock.createStartContract();
+const notifications = notificationServiceMock.createStartContract();
+
+const queryClient = new QueryClient(testQueryClientConfig);
+
+const wrapper = ({ children }: PropsWithChildren) => {
+ return (
+
+ {children}
+
+ );
+};
+
+describe('useBulkUpdateAlertTags', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ queryClient.clear();
+ });
+
+ const onSuccess = jest.fn();
+ const onError = jest.fn();
+
+ it('should call the API with correct parameters when adding tags', async () => {
+ http.post.mockResolvedValue('success');
+
+ const { result } = renderHook(
+ () => useBulkUpdateAlertTags({ http, notifications, onSuccess, onError }),
+ { wrapper }
+ );
+
+ result.current.mutate({
+ index: '.alerts-test',
+ alertIds: ['alert-1', 'alert-2'],
+ add: ['tag1', 'tag2'],
+ });
+
+ await waitFor(() => expect(http.post).toHaveBeenCalled());
+
+ expect(http.post).toHaveBeenCalledWith('/internal/rac/alerts/tags', {
+ body: JSON.stringify({
+ index: '.alerts-test',
+ alertIds: ['alert-1', 'alert-2'],
+ add: ['tag1', 'tag2'],
+ }),
+ });
+ });
+
+ it('should call the API with correct parameters when removing tags', async () => {
+ http.post.mockResolvedValue('success');
+
+ const { result } = renderHook(
+ () => useBulkUpdateAlertTags({ http, notifications, onSuccess, onError }),
+ { wrapper }
+ );
+
+ result.current.mutate({
+ index: '.alerts-test',
+ alertIds: ['alert-1', 'alert-2'],
+ remove: ['tag1', 'tag2'],
+ });
+
+ await waitFor(() => expect(http.post).toHaveBeenCalled());
+
+ expect(http.post).toHaveBeenCalledWith('/internal/rac/alerts/tags', {
+ body: JSON.stringify({
+ index: '.alerts-test',
+ alertIds: ['alert-1', 'alert-2'],
+ remove: ['tag1', 'tag2'],
+ }),
+ });
+ });
+
+ it('should call the API with correct parameters when adding and removing tags', async () => {
+ http.post.mockResolvedValue('success');
+
+ const { result } = renderHook(
+ () => useBulkUpdateAlertTags({ http, notifications, onSuccess, onError }),
+ { wrapper }
+ );
+
+ result.current.mutate({
+ index: '.alerts-test',
+ alertIds: ['alert-1', 'alert-2', 'alert-3'],
+ add: ['new-tag'],
+ remove: ['old-tag'],
+ });
+
+ await waitFor(() => expect(http.post).toHaveBeenCalled());
+
+ expect(http.post).toHaveBeenCalledWith('/internal/rac/alerts/tags', {
+ body: JSON.stringify({
+ index: '.alerts-test',
+ alertIds: ['alert-1', 'alert-2', 'alert-3'],
+ add: ['new-tag'],
+ remove: ['old-tag'],
+ }),
+ });
+ });
+
+ it('should not include add/remove fields when they are undefined', async () => {
+ http.post.mockResolvedValue('success');
+
+ const { result } = renderHook(
+ () => useBulkUpdateAlertTags({ http, notifications, onSuccess, onError }),
+ { wrapper }
+ );
+
+ result.current.mutate({
+ index: '.alerts-test',
+ alertIds: ['alert-1'],
+ });
+
+ await waitFor(() => expect(http.post).toHaveBeenCalled());
+
+ expect(http.post).toHaveBeenCalledWith('/internal/rac/alerts/tags', {
+ body: JSON.stringify({
+ index: '.alerts-test',
+ alertIds: ['alert-1'],
+ }),
+ });
+ });
+
+ it('should display success toast with alert message', async () => {
+ http.post.mockResolvedValue('success');
+
+ const { result } = renderHook(
+ () => useBulkUpdateAlertTags({ http, notifications, onSuccess, onError }),
+ { wrapper }
+ );
+
+ result.current.mutate({
+ index: '.alerts-test',
+ alertIds: ['alert-1'],
+ add: ['tag1'],
+ });
+
+ await waitFor(() => expect(notifications.toasts.addSuccess).toHaveBeenCalled());
+
+ expect(notifications.toasts.addSuccess).toHaveBeenCalledWith('Updated tags for alert');
+ });
+
+ it('should call onSuccess callback on successful mutation', async () => {
+ http.post.mockResolvedValue('success');
+
+ const { result } = renderHook(
+ () => useBulkUpdateAlertTags({ http, notifications, onSuccess, onError }),
+ { wrapper }
+ );
+
+ result.current.mutate({
+ index: '.alerts-test',
+ alertIds: ['alert-1'],
+ add: ['tag1'],
+ });
+
+ await waitFor(() => expect(onSuccess).toHaveBeenCalled());
+
+ expect(onSuccess).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not call onSuccess callback when onSuccess is undefined', async () => {
+ http.post.mockResolvedValue('success');
+
+ const { result } = renderHook(() => useBulkUpdateAlertTags({ http, notifications }), {
+ wrapper,
+ });
+
+ result.current.mutate({
+ index: '.alerts-test',
+ alertIds: ['alert-1'],
+ add: ['tag1'],
+ });
+
+ await waitFor(() => expect(notifications.toasts.addSuccess).toHaveBeenCalled());
+
+ expect(onSuccess).not.toHaveBeenCalled();
+ });
+
+ it('should display error toast with alert message on failure', async () => {
+ http.post.mockRejectedValue(new Error('API Error'));
+
+ const { result } = renderHook(
+ () => useBulkUpdateAlertTags({ http, notifications, onSuccess, onError }),
+ { wrapper }
+ );
+
+ result.current.mutate({
+ index: '.alerts-test',
+ alertIds: ['alert-1'],
+ add: ['tag1'],
+ });
+
+ await waitFor(() => expect(notifications.toasts.addDanger).toHaveBeenCalled());
+
+ expect(notifications.toasts.addDanger).toHaveBeenCalledWith('Failed to update tags for alert');
+ });
+
+ it('should call onError callback on failed mutation', async () => {
+ http.post.mockRejectedValue(new Error('API Error'));
+
+ const { result } = renderHook(
+ () => useBulkUpdateAlertTags({ http, notifications, onSuccess, onError }),
+ { wrapper }
+ );
+
+ result.current.mutate({
+ index: '.alerts-test',
+ alertIds: ['alert-1'],
+ add: ['tag1'],
+ });
+
+ await waitFor(() => expect(onError).toHaveBeenCalled());
+
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not call onError callback when onError is undefined', async () => {
+ http.post.mockRejectedValue(new Error('API Error'));
+
+ const { result } = renderHook(() => useBulkUpdateAlertTags({ http, notifications }), {
+ wrapper,
+ });
+
+ result.current.mutate({
+ index: '.alerts-test',
+ alertIds: ['alert-1'],
+ add: ['tag1'],
+ });
+
+ await waitFor(() => expect(notifications.toasts.addDanger).toHaveBeenCalled());
+
+ expect(onError).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_update_alert_tags.tsx b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_update_alert_tags.tsx
new file mode 100644
index 0000000000000..134730bdb7d07
--- /dev/null
+++ b/src/platform/packages/shared/response-ops/alerts-table/hooks/use_bulk_update_alert_tags.tsx
@@ -0,0 +1,79 @@
+/*
+ * 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 { useMutation } from '@kbn/react-query';
+import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
+import { BASE_RAC_ALERTS_API_PATH } from '@kbn/alerts-ui-shared/src/common/constants';
+import type { HttpStart } from '@kbn/core-http-browser';
+import type { NotificationsStart } from '@kbn/core-notifications-browser';
+import { mutationKeys } from '../constants';
+
+export interface UseBulkUpdateAlertTagsParams {
+ http: HttpStart;
+ notifications: NotificationsStart;
+ onSuccess?: () => void;
+ onError?: () => void;
+}
+
+export const useBulkUpdateAlertTags = ({
+ http,
+ notifications: { toasts },
+ onSuccess,
+ onError,
+}: UseBulkUpdateAlertTagsParams) => {
+ return useMutation<
+ string,
+ string,
+ { index: string; alertIds: string[]; add?: string[]; remove?: string[] }
+ >(
+ mutationKeys.bulkUpdateAlertTags(),
+ ({ index, alertIds, add, remove }) => {
+ try {
+ const body = JSON.stringify({
+ index,
+ alertIds,
+ ...(add ? { add } : {}),
+ ...(remove ? { remove } : {}),
+ });
+ return http.post(`${BASE_RAC_ALERTS_API_PATH}/tags`, { body });
+ } catch (e) {
+ throw new Error(`Unable to update tags: ${e.message}`);
+ }
+ },
+ {
+ context: AlertsQueryContext,
+ onSuccess: (_, params) => {
+ toasts.addSuccess(
+ i18n.translate(
+ 'xpack.triggersActionsUI.rules.updateTagsConfirmationModal.successNotification.descriptionText',
+ {
+ defaultMessage: 'Updated tags for {uuidsCount, plural, one {alert} other {alerts}}',
+ values: { uuidsCount: params.alertIds.length },
+ }
+ )
+ );
+ onSuccess?.();
+ },
+ onError: (_err, params) => {
+ toasts.addDanger(
+ i18n.translate(
+ 'xpack.triggersActionsUI.rules.updateTagsConfirmationModal.errorNotification.descriptionText',
+ {
+ defaultMessage:
+ 'Failed to update tags for {uuidsCount, plural, one {alert} other {alerts}}',
+ values: { uuidsCount: params.alertIds.length },
+ }
+ )
+ );
+ onError?.();
+ },
+ }
+ );
+};
diff --git a/src/platform/packages/shared/response-ops/alerts-table/reducers/bulk_actions_reducer.test.tsx b/src/platform/packages/shared/response-ops/alerts-table/reducers/bulk_actions_reducer.test.tsx
index 92682533cbf51..eb9a544e17bb1 100644
--- a/src/platform/packages/shared/response-ops/alerts-table/reducers/bulk_actions_reducer.test.tsx
+++ b/src/platform/packages/shared/response-ops/alerts-table/reducers/bulk_actions_reducer.test.tsx
@@ -28,6 +28,9 @@ import { AlertsTableContextProvider } from '../contexts/alerts_table_context';
import { getJsDomPerformanceFix, testQueryClientConfig } from '../utils/test';
import { QueryClient, QueryClientProvider } from '@kbn/react-query';
import { AlertsQueryContext } from '@kbn/alerts-ui-shared/src/common/contexts/alerts_query_context';
+import { useTagsAction } from '../components/tags/use_tags_action';
+
+jest.mock('../components/tags/use_tags_action');
const columns = [
{
@@ -59,6 +62,21 @@ afterAll(() => {
});
describe('AlertsDataGrid bulk actions', () => {
+ const mockUseTagsAction = jest.mocked(useTagsAction);
+
+ beforeEach(() => {
+ // Reset and set up the mock for tags action
+ mockUseTagsAction.mockReset();
+ mockUseTagsAction.mockImplementation(() => ({
+ isFlyoutOpen: false,
+ selectedAlerts: [],
+ openFlyout: jest.fn(),
+ onClose: jest.fn(),
+ onSaveTags: jest.fn(),
+ getAction: jest.fn(),
+ }));
+ });
+
const alerts: Alert[] = [
{
_id: 'alert0',
@@ -154,7 +172,7 @@ describe('AlertsDataGrid bulk actions', () => {
bulkActionsReducer,
initialBulkActionsState || createDefaultBulkActionsState()
);
- const renderContext = useMemo(
+ const renderContext: RenderContext = useMemo(
() => ({
...baseRenderContext,
bulkActionsStore,
@@ -166,7 +184,6 @@ describe('AlertsDataGrid bulk actions', () => {
return (
- {/* @ts-expect-error upgrade typescript v5.9.3 */}
diff --git a/src/platform/packages/shared/response-ops/alerts-table/translations.ts b/src/platform/packages/shared/response-ops/alerts-table/translations.ts
index 3dc2c405c587c..ff099112bb605 100644
--- a/src/platform/packages/shared/response-ops/alerts-table/translations.ts
+++ b/src/platform/packages/shared/response-ops/alerts-table/translations.ts
@@ -271,6 +271,10 @@ export const MARK_AS_UNTRACKED = i18n.translate(
}
);
+export const EDIT_TAGS = i18n.translate('xpack.responseOpsAlertsTable.actions.editTags', {
+ defaultMessage: 'Edit tags',
+});
+
export const MUTE = i18n.translate('xpack.responseOpsAlertsTable.actions.mute', {
defaultMessage: 'Mute',
});
diff --git a/src/platform/packages/shared/response-ops/alerts-table/types.ts b/src/platform/packages/shared/response-ops/alerts-table/types.ts
index 8473ed13fd2c4..b025a93ec088a 100644
--- a/src/platform/packages/shared/response-ops/alerts-table/types.ts
+++ b/src/platform/packages/shared/response-ops/alerts-table/types.ts
@@ -57,6 +57,7 @@ import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import type { EuiDataGridCellValueElementProps } from '@elastic/eui/src/components/datagrid/data_grid_types';
import type { EuiContextMenuPanelId } from '@elastic/eui/src/components/context_menu/context_menu';
import type { Case } from './apis/bulk_get_cases';
+import type { ItemsSelectionState } from './components/tags/items/types';
export interface Consumer {
id: AlertConsumers;
@@ -688,6 +689,12 @@ export interface BulkActionsState {
updatedAt: number;
}
+export interface BulkEditTagsFlyoutState {
+ isFlyoutOpen: boolean;
+ onClose: () => void;
+ onSaveTags: (itemsSelection: ItemsSelectionState) => void;
+}
+
export interface AlertsTableFlyoutBaseProps {
alert: Alert;
isLoading: boolean;
diff --git a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts
index 8680bad4a470f..bb3af03ee3fee 100644
--- a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts
+++ b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts
@@ -128,7 +128,7 @@ export default function createAlertsAsDataInstallResourcesTest({ getService }: F
expect(executionUuid).not.to.be(undefined);
// Query for alerts
- const alertDocsRun1 = await queryForAlertDocs();
+ const alertDocsRun1 = await queryForAlertDocs(ruleId);
// Get alert state from task document
let state: any = await getTaskState(ruleId);
@@ -219,7 +219,7 @@ export default function createAlertsAsDataInstallResourcesTest({ getService }: F
expect(executionUuid).not.to.be(undefined);
// Query for alerts
- const alertDocsRun2 = await queryForAlertDocs();
+ const alertDocsRun2 = await queryForAlertDocs(ruleId);
// Get alert state from task document
state = await getTaskState(ruleId);
@@ -377,7 +377,7 @@ export default function createAlertsAsDataInstallResourcesTest({ getService }: F
expect(executionUuid).not.to.be(undefined);
// Query for alerts
- const alertDocsRun3 = await queryForAlertDocs();
+ const alertDocsRun3 = await queryForAlertDocs(ruleId);
// Get alert state from task document
state = await getTaskState(ruleId);
@@ -535,10 +535,24 @@ export default function createAlertsAsDataInstallResourcesTest({ getService }: F
}
}
- async function queryForAlertDocs(): Promise>> {
+ async function queryForAlertDocs(ruleId?: string): Promise>> {
+ const query: any = ruleId
+ ? {
+ bool: {
+ must: [
+ {
+ term: {
+ 'kibana.alert.rule.uuid': ruleId,
+ },
+ },
+ ],
+ },
+ }
+ : { match_all: {} };
+
const searchResult = await es.search({
index: alertsAsDataIndex,
- query: { match_all: {} },
+ query,
});
return searchResult.hits.hits as Array>;
}
diff --git a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/bulk_disable.ts b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/bulk_disable.ts
index 12af10965d734..07fe0b6d7cc1c 100644
--- a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/bulk_disable.ts
+++ b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/bulk_disable.ts
@@ -45,12 +45,27 @@ export default function createDisableRuleTests({ getService }: FtrProviderContex
return createdRule.id;
};
- const getAlerts = async () => {
+ const getAlerts = async (ruleIds?: string[]) => {
+ const query: any =
+ ruleIds && ruleIds.length > 0
+ ? {
+ bool: {
+ must: [
+ {
+ terms: {
+ 'kibana.alert.rule.uuid': ruleIds,
+ },
+ },
+ ],
+ },
+ }
+ : { match_all: {} };
+
const {
hits: { hits: alerts },
} = await es.search({
index: alertAsDataIndex,
- query: { match_all: {} },
+ query,
});
return alerts;
@@ -73,7 +88,7 @@ export default function createDisableRuleTests({ getService }: FtrProviderContex
const createdRule2 = await createRule();
await retry.try(async () => {
- const alerts = await getAlerts();
+ const alerts = await getAlerts([createdRule1, createdRule2]);
expect(alerts.length).eql(4);
alerts.forEach((activeAlert: any) => {
@@ -90,7 +105,7 @@ export default function createDisableRuleTests({ getService }: FtrProviderContex
})
.expect(200);
- const alerts = await getAlerts();
+ const alerts = await getAlerts([createdRule1, createdRule2]);
expect(alerts.length).eql(4);
alerts.forEach((untrackedAlert: any) => {
@@ -103,7 +118,7 @@ export default function createDisableRuleTests({ getService }: FtrProviderContex
const createdRule2 = await createRule();
await retry.try(async () => {
- const alerts = await getAlerts();
+ const alerts = await getAlerts([createdRule1, createdRule2]);
expect(alerts.length).eql(4);
alerts.forEach((activeAlert: any) => {
@@ -120,7 +135,7 @@ export default function createDisableRuleTests({ getService }: FtrProviderContex
})
.expect(200);
- const alerts = await getAlerts();
+ const alerts = await getAlerts([createdRule1, createdRule2]);
expect(alerts.length).eql(4);
alerts.forEach((activeAlert: any) => {