diff --git a/src/platform/packages/shared/response-ops/rule_form/src/edit_rule_form.tsx b/src/platform/packages/shared/response-ops/rule_form/src/edit_rule_form.tsx index 5e53930d40e05..7b6c69a031c99 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/edit_rule_form.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/edit_rule_form.tsx @@ -26,7 +26,7 @@ import { } from './rule_form_errors'; import { RULE_EDIT_ERROR_TEXT, RULE_EDIT_SUCCESS_TEXT } from './translations'; import { getAvailableRuleTypes, parseRuleCircuitBreakerErrorMessage } from './utils'; -import { DEFAULT_VALID_CONSUMERS, getDefaultFormData } from './constants'; +import { DEFAULT_VALID_CONSUMERS, RuleFormStepId, getDefaultFormData } from './constants'; export interface EditRuleFormProps { id: string; @@ -38,6 +38,7 @@ export interface EditRuleFormProps { onSubmit?: (ruleId: string) => void; onChangeMetaData?: (metadata?: RuleTypeMetaData) => void; initialMetadata?: RuleTypeMetaData; + initialEditStep?: RuleFormStepId; } export const EditRuleForm = (props: EditRuleFormProps) => { @@ -51,6 +52,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => { isFlyout, onChangeMetaData, initialMetadata, + initialEditStep, } = props; const { http, notifications, docLinks, ruleTypeRegistry, application, fieldsMetadata, ...deps } = plugins; @@ -229,6 +231,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => { onSave={onSave} onCancel={onCancel} onChangeMetaData={onChangeMetaData} + initialEditStep={initialEditStep} /> ); diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.test.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.test.tsx index 2dee8c39d0910..9967ad6005d9c 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.test.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.test.tsx @@ -16,6 +16,7 @@ import { RULE_FORM_PAGE_RULE_DETAILS_TITLE_SHORT, } from '../translations'; import { RuleFormData } from '../types'; +import { RuleFormStepId } from '../constants'; jest.mock('../rule_definition', () => ({ RuleDefinition: () =>
, @@ -118,6 +119,44 @@ describe('ruleFlyout', () => { expect(await screen.findByTestId('ruleFlyoutFooterNextStepButton')).toBeInTheDocument(); }); + test('omitting `initialStep` causes default behavior with step 1 selected', () => { + const { getByText } = render(); + + expect(getByText('Current step is 1')); + expect(getByText('Step 2 is incomplete')); + expect(getByText('Step 3 is incomplete')); + }); + + test('setting `initialStep` to `RuleFormStepId.DEFINITION` will make step 1 the current step', () => { + const { getByText } = render( + + ); + + expect(getByText('Current step is 1')); + expect(getByText('Step 2 is incomplete')); + expect(getByText('Step 3 is incomplete')); + }); + + test('setting `initialStep` to `RuleFormStepId.ACTION` will make step 1 the current step', () => { + const { getByText } = render( + + ); + + expect(getByText('Step 1 is complete')); + expect(getByText('Current step is 2')); + expect(getByText('Step 3 is incomplete')); + }); + + test('setting `initialStep` to `RuleFormStepId.DETAILS` will make step 1 the current step', () => { + const { getByText } = render( + + ); + + expect(getByText('Step 1 is complete')); + expect(getByText('Step 2 is incomplete')); + expect(getByText('Current step is 3')); + }); + test('should call onSave when save button is pressed', async () => { render(); diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.tsx index 64407f60f1c7b..ead6556bb29e8 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.tsx @@ -23,6 +23,7 @@ interface RuleFlyoutProps { onCancel?: () => void; onSave: (formData: RuleFormData) => void; onChangeMetaData?: (metadata?: RuleTypeMetaData) => void; + initialEditStep?: RuleFormStepId; } // This component is only responsible for the CONTENT of the EuiFlyout. See `flyout/rule_form_flyout.tsx` for the @@ -38,8 +39,9 @@ export const RuleFlyout = ({ // we're displaying the confirmation modal for closing the flyout. onCancel: onClose = () => {}, onChangeMetaData = () => {}, + initialEditStep, }: RuleFlyoutProps) => { - const [initialStep, setInitialStep] = useState(undefined); + const [initialStep, setInitialStep] = useState(initialEditStep); const [isConfirmCloseModalVisible, setIsConfirmCloseModalVisible] = useState(false); const { diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx index 6b107da591283..e5f99415cd1e0 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx @@ -19,6 +19,7 @@ import { RULE_FORM_ROUTE_PARAMS_ERROR_TITLE, } from './translations'; import { RuleFormData, RuleFormPlugins, RuleTypeMetaData } from './types'; +import { RuleFormStepId } from './constants'; const queryClient = new QueryClient(); @@ -41,6 +42,7 @@ export interface RuleFormProps>; initialMetadata?: MetaData; + initialEditStep?: RuleFormStepId; } export const RuleForm = ( @@ -65,6 +67,7 @@ export const RuleForm = ( showMustacheAutocompleteSwitch, initialValues, initialMetadata, + initialEditStep, } = props; const { @@ -122,6 +125,7 @@ export const RuleForm = ( showMustacheAutocompleteSwitch={showMustacheAutocompleteSwitch} connectorFeatureId={connectorFeatureId} initialMetadata={initialMetadata} + initialEditStep={initialEditStep} /> ); } @@ -175,25 +179,26 @@ export const RuleForm = ( docLinks, ruleTypeRegistry, actionTypeRegistry, + fieldsMetadata, contentManagement, + onChangeMetaData, id, ruleTypeId, - validConsumers, - multiConsumerSelection, onCancel, onSubmit, - onChangeMetaData, isFlyout, showMustacheAutocompleteSwitch, connectorFeatureId, initialMetadata, + initialEditStep, consumer, + multiConsumerSelection, hideInterval, + validConsumers, filteredRuleTypes, shouldUseRuleProducer, canShowConsumerSelection, initialValues, - fieldsMetadata, ]); return ( diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx index 09481ab1f36e4..a369d0a0ad81b 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/alert_details.tsx @@ -18,8 +18,8 @@ import { EuiTabbedContentTab, useEuiTheme, EuiFlexGroup, - EuiMarkdownFormat, EuiNotificationBadge, + EuiIcon, } from '@elastic/eui'; import { AlertStatus, @@ -39,6 +39,7 @@ import { usePageReady } from '@kbn/ebt-tools'; import { RelatedAlerts } from './components/related_alerts/related_alerts'; import { AlertDetailsSource } from './types'; import { SourceBar } from './components'; +import { InvestigationGuide } from './components/investigation_guide'; import { StatusBar } from './components/status_bar'; import { observabilityFeatureId } from '../../../common'; import { useKibana } from '../../utils/kibana_react'; @@ -119,7 +120,7 @@ export function AlertDetails() { const userCasesPermissions = canUseCases([observabilityFeatureId]); const ruleId = alertDetail?.formatted.fields[ALERT_RULE_UUID]; const { rule, refetch } = useFetchRule({ - ruleId, + ruleId: ruleId || '', }); const onSuccessAddSuggestedDashboard = useCallback(async () => { @@ -323,24 +324,26 @@ export function AlertDetails() { { id: 'investigation_guide', name: ( - + <> + + {rule?.artifacts?.investigation_guide?.blob && ( + + + + )} + ), 'data-test-subj': 'investigationGuideTab', - disabled: !rule?.artifacts?.investigation_guide?.blob, content: ( - <> - - - {rule?.artifacts?.investigation_guide?.blob ?? ''} - - + ), }, { @@ -402,6 +405,8 @@ export function AlertDetails() { alertStatus={alertStatus} onUntrackAlert={onUntrackAlert} onUpdate={onUpdate} + rule={rule} + refetch={refetch} /> , ], diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/AlertDetailsRuleFormFlyout.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/AlertDetailsRuleFormFlyout.tsx new file mode 100644 index 0000000000000..62b740f2a221e --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/AlertDetailsRuleFormFlyout.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { RuleFormFlyout } from '@kbn/response-ops-rule-form/flyout'; +import { RuleFormStepId } from '@kbn/response-ops-rule-form/src/constants'; +import type { Rule } from '@kbn/triggers-actions-ui-plugin/public'; +import { useKibana } from '../../../utils/kibana_react'; + +export interface AlertDetailsRuleFormFlyoutBaseProps { + onUpdate?: () => void; + refetch: () => void; + rule?: Rule; +} + +interface Props extends AlertDetailsRuleFormFlyoutBaseProps { + initialEditStep?: RuleFormStepId; + isRuleFormFlyoutOpen: boolean; + setIsRuleFormFlyoutOpen: React.Dispatch; + rule: Rule; +} + +export function AlertDetailsRuleFormFlyout({ + initialEditStep, + onUpdate, + refetch, + isRuleFormFlyoutOpen, + setIsRuleFormFlyoutOpen, + rule, +}: Props) { + const { services } = useKibana(); + if (!isRuleFormFlyoutOpen) return null; + const { + triggersActionsUi: { ruleTypeRegistry, actionTypeRegistry }, + } = services; + return ( + { + setIsRuleFormFlyoutOpen(false); + }} + onSubmit={() => { + onUpdate?.(); + refetch(); + setIsRuleFormFlyoutOpen(false); + }} + initialEditStep={initialEditStep} + /> + ); +} diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/header_actions.test.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/header_actions.test.tsx index 4b61258dbd058..33739c8e90cc6 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/header_actions.test.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/header_actions.test.tsx @@ -104,6 +104,12 @@ describe('Header Actions', () => { alertIndex={'alert-index'} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} onUntrackAlert={mockOnUntrackAlert} + refetch={jest.fn()} + // @ts-expect-error partial implementation for testing + rule={{ + id: mockRuleId, + name: mockRuleName, + }} /> ); @@ -129,6 +135,12 @@ describe('Header Actions', () => { alertIndex={'alert-index'} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} onUntrackAlert={mockOnUntrackAlert} + refetch={jest.fn()} + // @ts-expect-error partial implementation for testing + rule={{ + id: mockRuleId, + name: mockRuleName, + }} /> ); @@ -141,6 +153,7 @@ describe('Header Actions', () => { alert={alertWithGroupsAndTags} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} onUntrackAlert={mockOnUntrackAlert} + refetch={jest.fn()} /> ); expect(queryByTestId('alert-details-header-actions-menu-button')).toBeTruthy(); @@ -153,6 +166,12 @@ describe('Header Actions', () => { alert={alertWithGroupsAndTags} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} onUntrackAlert={mockOnUntrackAlert} + refetch={jest.fn()} + // @ts-expect-error partial implementation for testing + rule={{ + id: mockRuleId, + name: mockRuleName, + }} /> ); @@ -166,6 +185,12 @@ describe('Header Actions', () => { alert={alertWithGroupsAndTags} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} onUntrackAlert={mockOnUntrackAlert} + refetch={jest.fn()} + // @ts-expect-error partial implementation for testing + rule={{ + id: mockRuleId, + name: mockRuleName, + }} /> ); @@ -180,6 +205,12 @@ describe('Header Actions', () => { alert={alertWithGroupsAndTags} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} onUntrackAlert={mockOnUntrackAlert} + refetch={jest.fn()} + // @ts-expect-error partial implementation for testing + rule={{ + id: mockRuleId, + name: mockRuleName, + }} /> ); @@ -193,6 +224,12 @@ describe('Header Actions', () => { alert={alertWithGroupsAndTags} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} onUntrackAlert={mockOnUntrackAlert} + refetch={jest.fn()} + // @ts-expect-error partial implementation for testing + rule={{ + id: mockRuleId, + name: mockRuleName, + }} /> ); @@ -218,6 +255,7 @@ describe('Header Actions', () => { alert={alertWithGroupsAndTags} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} onUntrackAlert={mockOnUntrackAlert} + refetch={jest.fn()} /> ); @@ -231,6 +269,7 @@ describe('Header Actions', () => { alert={untrackedAlert} alertStatus={untrackedAlert.fields[ALERT_STATUS] as AlertStatus} onUntrackAlert={mockOnUntrackAlert} + refetch={jest.fn()} /> ); @@ -244,6 +283,7 @@ describe('Header Actions', () => { alert={alertWithGroupsAndTags} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} onUntrackAlert={mockOnUntrackAlert} + refetch={jest.fn()} /> ); fireEvent.click(await findByTestId('alert-details-header-actions-menu-button')); diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/header_actions.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/header_actions.tsx index e2883c30d14b6..569248e447845 100644 --- a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/header_actions.tsx +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/header_actions.tsx @@ -8,7 +8,6 @@ import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { noop } from 'lodash'; -import { RuleFormFlyout } from '@kbn/response-ops-rule-form/flyout'; import { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public/types'; import { AttachmentType } from '@kbn/cases-plugin/common'; import { @@ -29,17 +28,19 @@ import { } from '@kbn/rule-data-utils'; import { useKibana } from '../../../utils/kibana_react'; -import { useFetchRule } from '../../../hooks/use_fetch_rule'; import type { TopAlert } from '../../../typings/alerts'; import { paths } from '../../../../common/locators/paths'; import { useBulkUntrackAlerts } from '../hooks/use_bulk_untrack_alerts'; +import { + AlertDetailsRuleFormFlyout, + type AlertDetailsRuleFormFlyoutBaseProps, +} from './AlertDetailsRuleFormFlyout'; -export interface HeaderActionsProps { +export interface HeaderActionsProps extends AlertDetailsRuleFormFlyoutBaseProps { alert: TopAlert | null; alertIndex?: string; alertStatus?: AlertStatus; onUntrackAlert: () => void; - onUpdate?: () => void; } export function HeaderActions({ @@ -48,26 +49,19 @@ export function HeaderActions({ alertStatus, onUntrackAlert, onUpdate, + rule, + refetch, }: HeaderActionsProps) { const { services } = useKibana(); const { cases: { hooks: { useCasesAddToExistingCaseModal }, }, - triggersActionsUi: { - ruleTypeRegistry, - actionTypeRegistry, - getRuleSnoozeModal: RuleSnoozeModal, - }, + triggersActionsUi: { getRuleSnoozeModal: RuleSnoozeModal }, http, } = services; - const { rule, refetch } = useFetchRule({ - ruleId: alert?.fields[ALERT_RULE_UUID] || '', - }); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [ruleConditionsFlyoutOpen, setRuleConditionsFlyoutOpen] = useState(false); const [snoozeModalOpen, setSnoozeModalOpen] = useState(false); const selectCaseModal = useCasesAddToExistingCaseModal(); @@ -84,6 +78,8 @@ export function HeaderActions({ } }, [alert, untrackAlerts, onUntrackAlert]); + const [alertDetailsRuleFormFlyoutOpen, setAlertDetailsRuleFormFlyoutOpen] = useState(false); + const handleTogglePopover = () => setIsPopoverOpen(!isPopoverOpen); const handleClosePopover = () => setIsPopoverOpen(false); @@ -107,11 +103,6 @@ export function HeaderActions({ selectCaseModal.open({ getAttachments: () => attachments }); }; - const handleEditRuleDetails = () => { - setIsPopoverOpen(false); - setRuleConditionsFlyoutOpen(true); - }; - const handleOpenSnoozeModal = () => { setIsPopoverOpen(false); setSnoozeModalOpen(true); @@ -175,7 +166,10 @@ export function HeaderActions({ size="s" color="text" iconType="pencil" - onClick={handleEditRuleDetails} + onClick={() => { + setIsPopoverOpen(false); + setAlertDetailsRuleFormFlyoutOpen(true); + }} disabled={!alert?.fields[ALERT_RULE_UUID] || !rule} data-test-subj="edit-rule-button" > @@ -225,20 +219,15 @@ export function HeaderActions({ - {rule && ruleConditionsFlyoutOpen ? ( - { - setRuleConditionsFlyoutOpen(false); - }} - onSubmit={() => { - setRuleConditionsFlyoutOpen(false); - onUpdate?.(); - refetch(); - }} + {rule && ( + - ) : null} + )} {rule && snoozeModalOpen ? ( { + return { + // we mock the response-ops flyout because we aren't testing it here + RuleFormFlyout: () =>
Mock Flyout
, + }; +}); + +describe('InvestigationGuide', () => { + beforeEach(() => { + jest.spyOn(kibana, 'useKibana').mockReturnValue({ + services: { + triggersActionsUi: { + // @ts-expect-error partial implementation for mocking + ruleTypeRegistry: { + get: jest.fn(), + }, + // @ts-expect-error partial implementation for mocking + actionTypeRegistry: { + get: jest.fn(), + }, + }, + }, + }); + jest.clearAllMocks(); + }); + + it('provides an empty state that will open the rule form flyout', async () => { + const mockRule = { id: 'mock' }; + const { getByRole, getByText } = render( + {}} + refetch={() => {}} + // @ts-expect-error internal hook call is mocked, do not need real values + rule={mockRule} + /> + ); + + // grab add guide button for functionality testing + const addGuideButton = getByRole('button', { name: 'Add guide' }); + expect(addGuideButton).toBeInTheDocument(); + + // verify that clicking the add guide button opens the flyout + await act(() => fireEvent.click(addGuideButton)); + + expect(getByText('Mock Flyout')).toBeInTheDocument(); + }); + + it('renders the investigation guide when one is provided', async () => { + // provide actual markdown and test it's getting rendered properly + const mockMarkdown = + '## This is an investigation guide\n\nCall **The team** to resolve _any issues_.\n'; + const mockRule = { id: 'mock' }; + + const { getByRole } = render( + {}} + refetch={() => {}} + // @ts-expect-error internal hook call is mocked, do not need real values + rule={mockRule} + blob={mockMarkdown} + /> + ); + + // test that the component is rendering markdown + expect(getByRole('heading', { name: 'This is an investigation guide' })); + expect(getByRole('strong')).toHaveTextContent('The team'); + expect(getByRole('emphasis')).toHaveTextContent('any issues'); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/investigation_guide.tsx b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/investigation_guide.tsx new file mode 100644 index 0000000000000..acaaddbce6b93 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/pages/alert_details/components/investigation_guide.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiEmptyPrompt, EuiButton, EuiSpacer, EuiMarkdownFormat } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { RuleFormStepId } from '@kbn/response-ops-rule-form/src/constants'; +import React, { useState } from 'react'; +import { + AlertDetailsRuleFormFlyout, + type AlertDetailsRuleFormFlyoutBaseProps, +} from './AlertDetailsRuleFormFlyout'; + +interface InvestigationGuideProps extends AlertDetailsRuleFormFlyoutBaseProps { + blob?: string; +} + +export function InvestigationGuide({ blob, onUpdate, refetch, rule }: InvestigationGuideProps) { + const [alertDetailsRuleFormFlyoutOpen, setAlertDetailsRuleFormFlyoutOpen] = useState(false); + return blob ? ( + <> + + + {blob} + + + ) : ( + <> + + + + } + titleSize="m" + body={ +

+ +

+ } + actions={ + setAlertDetailsRuleFormFlyoutOpen(true)} + fill + > + + + } + /> + {!!rule && ( + + )} + + ); +}