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) => {