[Alert Details] Add investigation guide empty state (#223974)

## Summary

Resolves #222051.

This PR makes a richer empty state UX for the Investigation Guide
feature we added to the Alert Details page.

Before, when a rule did not have an investigation guide, the tab on the
alert details page dedicated to the feature was disabled. Now, the tab
is always enabled. When an investigation guide is present on a rule, a
badge will display to indicate the tab contains content.

If the user clicks into the tab when the rule does not have an
investigation guide, they will see an empty state with a call to action
to create a guide. If the user decides to click the empty state button,
it will open the Rule Edit flyout. I have added additional functionality
that allows the flyout to take an `initialStep` prop, so we pre-set the
flyout to the `Details` step which contains the text field the user can
use to create their guide.

The copy, iconography, and layout of the tab heading are all in draft
state pending some design feedback. I will also add some tests to the
code that I have added.


![20250613162001](https://github.com/user-attachments/assets/5310e371-ebcb-4d42-acbc-86816817e042)

## Reviewing this PR

_Note to technical writers:_ You can see the copy added for the empty
state
[here](https://github.com/elastic/kibana/pull/223974/files#diff-71b439414e4974e2decb0f25c136f52ccea4b49ebe393af68dfc5fd184d56e1cR37).

Here's a screenshot as well:

<img width="375" alt="image"
src="https://github.com/user-attachments/assets/491d87ac-b473-484e-82cd-45a1bd197c61"
/>

### Technical review

1. Create a rule that will generate alerts; _do not_ define an
Investigation Guide for it
1. Trigger an alert, and go to the details page
1. You should see the Investigation guide tab is available, whereas on
`main` it would be disabled
1. Open the Investigation Guide tab, you should see the empty state with
its CTA
1. Click this button, the rule flyout should open in Edit mode, and the
Details step should be pre-selected
1. Define an investigation guide, this can be any text. Save the rule.
1. Once you have saved the rule you should see the rule data update in
the page. The empty state will be gone and the investigation guide you
defined will be there instead.
This commit is contained in:
Justin Kambic 2025-06-23 15:19:17 -04:00 committed by GitHub
parent 78b2013468
commit dd2e7cb5f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 359 additions and 56 deletions

View file

@ -26,7 +26,7 @@ import {
} from './rule_form_errors'; } from './rule_form_errors';
import { RULE_EDIT_ERROR_TEXT, RULE_EDIT_SUCCESS_TEXT } from './translations'; import { RULE_EDIT_ERROR_TEXT, RULE_EDIT_SUCCESS_TEXT } from './translations';
import { getAvailableRuleTypes, parseRuleCircuitBreakerErrorMessage } from './utils'; import { getAvailableRuleTypes, parseRuleCircuitBreakerErrorMessage } from './utils';
import { DEFAULT_VALID_CONSUMERS, getDefaultFormData } from './constants'; import { DEFAULT_VALID_CONSUMERS, RuleFormStepId, getDefaultFormData } from './constants';
export interface EditRuleFormProps { export interface EditRuleFormProps {
id: string; id: string;
@ -38,6 +38,7 @@ export interface EditRuleFormProps {
onSubmit?: (ruleId: string) => void; onSubmit?: (ruleId: string) => void;
onChangeMetaData?: (metadata?: RuleTypeMetaData) => void; onChangeMetaData?: (metadata?: RuleTypeMetaData) => void;
initialMetadata?: RuleTypeMetaData; initialMetadata?: RuleTypeMetaData;
initialEditStep?: RuleFormStepId;
} }
export const EditRuleForm = (props: EditRuleFormProps) => { export const EditRuleForm = (props: EditRuleFormProps) => {
@ -51,6 +52,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => {
isFlyout, isFlyout,
onChangeMetaData, onChangeMetaData,
initialMetadata, initialMetadata,
initialEditStep,
} = props; } = props;
const { http, notifications, docLinks, ruleTypeRegistry, application, fieldsMetadata, ...deps } = const { http, notifications, docLinks, ruleTypeRegistry, application, fieldsMetadata, ...deps } =
plugins; plugins;
@ -229,6 +231,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => {
onSave={onSave} onSave={onSave}
onCancel={onCancel} onCancel={onCancel}
onChangeMetaData={onChangeMetaData} onChangeMetaData={onChangeMetaData}
initialEditStep={initialEditStep}
/> />
</RuleFormStateProvider> </RuleFormStateProvider>
); );

View file

@ -16,6 +16,7 @@ import {
RULE_FORM_PAGE_RULE_DETAILS_TITLE_SHORT, RULE_FORM_PAGE_RULE_DETAILS_TITLE_SHORT,
} from '../translations'; } from '../translations';
import { RuleFormData } from '../types'; import { RuleFormData } from '../types';
import { RuleFormStepId } from '../constants';
jest.mock('../rule_definition', () => ({ jest.mock('../rule_definition', () => ({
RuleDefinition: () => <div />, RuleDefinition: () => <div />,
@ -118,6 +119,44 @@ describe('ruleFlyout', () => {
expect(await screen.findByTestId('ruleFlyoutFooterNextStepButton')).toBeInTheDocument(); expect(await screen.findByTestId('ruleFlyoutFooterNextStepButton')).toBeInTheDocument();
}); });
test('omitting `initialStep` causes default behavior with step 1 selected', () => {
const { getByText } = render(<RuleFlyout onCancel={onCancel} onSave={onSave} />);
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(
<RuleFlyout onCancel={onCancel} onSave={onSave} initialEditStep={RuleFormStepId.DEFINITION} />
);
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(
<RuleFlyout onCancel={onCancel} onSave={onSave} initialEditStep={RuleFormStepId.ACTIONS} />
);
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(
<RuleFlyout onCancel={onCancel} onSave={onSave} initialEditStep={RuleFormStepId.DETAILS} />
);
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 () => { test('should call onSave when save button is pressed', async () => {
render(<RuleFlyout onCancel={onCancel} onSave={onSave} />); render(<RuleFlyout onCancel={onCancel} onSave={onSave} />);

View file

@ -23,6 +23,7 @@ interface RuleFlyoutProps {
onCancel?: () => void; onCancel?: () => void;
onSave: (formData: RuleFormData) => void; onSave: (formData: RuleFormData) => void;
onChangeMetaData?: (metadata?: RuleTypeMetaData) => 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 // 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. // we're displaying the confirmation modal for closing the flyout.
onCancel: onClose = () => {}, onCancel: onClose = () => {},
onChangeMetaData = () => {}, onChangeMetaData = () => {},
initialEditStep,
}: RuleFlyoutProps) => { }: RuleFlyoutProps) => {
const [initialStep, setInitialStep] = useState<RuleFormStepId | undefined>(undefined); const [initialStep, setInitialStep] = useState<RuleFormStepId | undefined>(initialEditStep);
const [isConfirmCloseModalVisible, setIsConfirmCloseModalVisible] = useState(false); const [isConfirmCloseModalVisible, setIsConfirmCloseModalVisible] = useState(false);
const { const {

View file

@ -19,6 +19,7 @@ import {
RULE_FORM_ROUTE_PARAMS_ERROR_TITLE, RULE_FORM_ROUTE_PARAMS_ERROR_TITLE,
} from './translations'; } from './translations';
import { RuleFormData, RuleFormPlugins, RuleTypeMetaData } from './types'; import { RuleFormData, RuleFormPlugins, RuleTypeMetaData } from './types';
import { RuleFormStepId } from './constants';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -41,6 +42,7 @@ export interface RuleFormProps<MetaData extends RuleTypeMetaData = RuleTypeMetaD
showMustacheAutocompleteSwitch?: boolean; showMustacheAutocompleteSwitch?: boolean;
initialValues?: Partial<Omit<RuleFormData, 'ruleTypeId'>>; initialValues?: Partial<Omit<RuleFormData, 'ruleTypeId'>>;
initialMetadata?: MetaData; initialMetadata?: MetaData;
initialEditStep?: RuleFormStepId;
} }
export const RuleForm = <MetaData extends RuleTypeMetaData = RuleTypeMetaData>( export const RuleForm = <MetaData extends RuleTypeMetaData = RuleTypeMetaData>(
@ -65,6 +67,7 @@ export const RuleForm = <MetaData extends RuleTypeMetaData = RuleTypeMetaData>(
showMustacheAutocompleteSwitch, showMustacheAutocompleteSwitch,
initialValues, initialValues,
initialMetadata, initialMetadata,
initialEditStep,
} = props; } = props;
const { const {
@ -122,6 +125,7 @@ export const RuleForm = <MetaData extends RuleTypeMetaData = RuleTypeMetaData>(
showMustacheAutocompleteSwitch={showMustacheAutocompleteSwitch} showMustacheAutocompleteSwitch={showMustacheAutocompleteSwitch}
connectorFeatureId={connectorFeatureId} connectorFeatureId={connectorFeatureId}
initialMetadata={initialMetadata} initialMetadata={initialMetadata}
initialEditStep={initialEditStep}
/> />
); );
} }
@ -175,25 +179,26 @@ export const RuleForm = <MetaData extends RuleTypeMetaData = RuleTypeMetaData>(
docLinks, docLinks,
ruleTypeRegistry, ruleTypeRegistry,
actionTypeRegistry, actionTypeRegistry,
fieldsMetadata,
contentManagement, contentManagement,
onChangeMetaData,
id, id,
ruleTypeId, ruleTypeId,
validConsumers,
multiConsumerSelection,
onCancel, onCancel,
onSubmit, onSubmit,
onChangeMetaData,
isFlyout, isFlyout,
showMustacheAutocompleteSwitch, showMustacheAutocompleteSwitch,
connectorFeatureId, connectorFeatureId,
initialMetadata, initialMetadata,
initialEditStep,
consumer, consumer,
multiConsumerSelection,
hideInterval, hideInterval,
validConsumers,
filteredRuleTypes, filteredRuleTypes,
shouldUseRuleProducer, shouldUseRuleProducer,
canShowConsumerSelection, canShowConsumerSelection,
initialValues, initialValues,
fieldsMetadata,
]); ]);
return ( return (

View file

@ -18,8 +18,8 @@ import {
EuiTabbedContentTab, EuiTabbedContentTab,
useEuiTheme, useEuiTheme,
EuiFlexGroup, EuiFlexGroup,
EuiMarkdownFormat,
EuiNotificationBadge, EuiNotificationBadge,
EuiIcon,
} from '@elastic/eui'; } from '@elastic/eui';
import { import {
AlertStatus, AlertStatus,
@ -39,6 +39,7 @@ import { usePageReady } from '@kbn/ebt-tools';
import { RelatedAlerts } from './components/related_alerts/related_alerts'; import { RelatedAlerts } from './components/related_alerts/related_alerts';
import { AlertDetailsSource } from './types'; import { AlertDetailsSource } from './types';
import { SourceBar } from './components'; import { SourceBar } from './components';
import { InvestigationGuide } from './components/investigation_guide';
import { StatusBar } from './components/status_bar'; import { StatusBar } from './components/status_bar';
import { observabilityFeatureId } from '../../../common'; import { observabilityFeatureId } from '../../../common';
import { useKibana } from '../../utils/kibana_react'; import { useKibana } from '../../utils/kibana_react';
@ -119,7 +120,7 @@ export function AlertDetails() {
const userCasesPermissions = canUseCases([observabilityFeatureId]); const userCasesPermissions = canUseCases([observabilityFeatureId]);
const ruleId = alertDetail?.formatted.fields[ALERT_RULE_UUID]; const ruleId = alertDetail?.formatted.fields[ALERT_RULE_UUID];
const { rule, refetch } = useFetchRule({ const { rule, refetch } = useFetchRule({
ruleId, ruleId: ruleId || '',
}); });
const onSuccessAddSuggestedDashboard = useCallback(async () => { const onSuccessAddSuggestedDashboard = useCallback(async () => {
@ -322,24 +323,26 @@ export function AlertDetails() {
{ {
id: 'investigation_guide', id: 'investigation_guide',
name: ( name: (
<>
<FormattedMessage <FormattedMessage
id="xpack.observability.alertDetails.tab.investigationGuideLabel" id="xpack.observability.alertDetails.tab.investigationGuideLabel"
defaultMessage="Investigation guide" defaultMessage="Investigation guide"
/> />
{rule?.artifacts?.investigation_guide?.blob && (
<EuiNotificationBadge color="success" css={{ marginLeft: '5px' }}>
<EuiIcon type="dot" size="s" />
</EuiNotificationBadge>
)}
</>
), ),
'data-test-subj': 'investigationGuideTab', 'data-test-subj': 'investigationGuideTab',
disabled: !rule?.artifacts?.investigation_guide?.blob,
content: ( content: (
<> <InvestigationGuide
<EuiSpacer size="m" /> blob={rule?.artifacts?.investigation_guide?.blob}
<EuiMarkdownFormat onUpdate={onUpdate}
css={css` refetch={refetch}
word-wrap: break-word; rule={rule}
`} />
>
{rule?.artifacts?.investigation_guide?.blob ?? ''}
</EuiMarkdownFormat>
</>
), ),
}, },
{ {
@ -401,6 +404,8 @@ export function AlertDetails() {
alertStatus={alertStatus} alertStatus={alertStatus}
onUntrackAlert={onUntrackAlert} onUntrackAlert={onUntrackAlert}
onUpdate={onUpdate} onUpdate={onUpdate}
rule={rule}
refetch={refetch}
/> />
</CasesContext>, </CasesContext>,
], ],

View file

@ -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<boolean>;
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 (
<RuleFormFlyout
plugins={{ ...services, ruleTypeRegistry, actionTypeRegistry }}
id={rule.id}
onCancel={() => {
setIsRuleFormFlyoutOpen(false);
}}
onSubmit={() => {
onUpdate?.();
refetch();
setIsRuleFormFlyoutOpen(false);
}}
initialEditStep={initialEditStep}
/>
);
}

View file

@ -104,6 +104,12 @@ describe('Header Actions', () => {
alertIndex={'alert-index'} alertIndex={'alert-index'}
alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus}
onUntrackAlert={mockOnUntrackAlert} 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'} alertIndex={'alert-index'}
alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus}
onUntrackAlert={mockOnUntrackAlert} 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} alert={alertWithGroupsAndTags}
alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus}
onUntrackAlert={mockOnUntrackAlert} onUntrackAlert={mockOnUntrackAlert}
refetch={jest.fn()}
/> />
); );
expect(queryByTestId('alert-details-header-actions-menu-button')).toBeTruthy(); expect(queryByTestId('alert-details-header-actions-menu-button')).toBeTruthy();
@ -153,6 +166,12 @@ describe('Header Actions', () => {
alert={alertWithGroupsAndTags} alert={alertWithGroupsAndTags}
alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus}
onUntrackAlert={mockOnUntrackAlert} 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} alert={alertWithGroupsAndTags}
alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus}
onUntrackAlert={mockOnUntrackAlert} 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} alert={alertWithGroupsAndTags}
alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus}
onUntrackAlert={mockOnUntrackAlert} 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} alert={alertWithGroupsAndTags}
alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus}
onUntrackAlert={mockOnUntrackAlert} 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} alert={alertWithGroupsAndTags}
alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus}
onUntrackAlert={mockOnUntrackAlert} onUntrackAlert={mockOnUntrackAlert}
refetch={jest.fn()}
/> />
); );
@ -231,6 +269,7 @@ describe('Header Actions', () => {
alert={untrackedAlert} alert={untrackedAlert}
alertStatus={untrackedAlert.fields[ALERT_STATUS] as AlertStatus} alertStatus={untrackedAlert.fields[ALERT_STATUS] as AlertStatus}
onUntrackAlert={mockOnUntrackAlert} onUntrackAlert={mockOnUntrackAlert}
refetch={jest.fn()}
/> />
); );
@ -244,6 +283,7 @@ describe('Header Actions', () => {
alert={alertWithGroupsAndTags} alert={alertWithGroupsAndTags}
alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus} alertStatus={alertWithGroupsAndTags.fields[ALERT_STATUS] as AlertStatus}
onUntrackAlert={mockOnUntrackAlert} onUntrackAlert={mockOnUntrackAlert}
refetch={jest.fn()}
/> />
); );
fireEvent.click(await findByTestId('alert-details-header-actions-menu-button')); fireEvent.click(await findByTestId('alert-details-header-actions-menu-button'));

View file

@ -8,7 +8,6 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { noop } from 'lodash'; import { noop } from 'lodash';
import { RuleFormFlyout } from '@kbn/response-ops-rule-form/flyout';
import { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public/types'; import { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public/types';
import { AttachmentType } from '@kbn/cases-plugin/common'; import { AttachmentType } from '@kbn/cases-plugin/common';
import { import {
@ -29,17 +28,19 @@ import {
} from '@kbn/rule-data-utils'; } from '@kbn/rule-data-utils';
import { useKibana } from '../../../utils/kibana_react'; import { useKibana } from '../../../utils/kibana_react';
import { useFetchRule } from '../../../hooks/use_fetch_rule';
import type { TopAlert } from '../../../typings/alerts'; import type { TopAlert } from '../../../typings/alerts';
import { paths } from '../../../../common/locators/paths'; import { paths } from '../../../../common/locators/paths';
import { useBulkUntrackAlerts } from '../hooks/use_bulk_untrack_alerts'; 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; alert: TopAlert | null;
alertIndex?: string; alertIndex?: string;
alertStatus?: AlertStatus; alertStatus?: AlertStatus;
onUntrackAlert: () => void; onUntrackAlert: () => void;
onUpdate?: () => void;
} }
export function HeaderActions({ export function HeaderActions({
@ -48,26 +49,19 @@ export function HeaderActions({
alertStatus, alertStatus,
onUntrackAlert, onUntrackAlert,
onUpdate, onUpdate,
rule,
refetch,
}: HeaderActionsProps) { }: HeaderActionsProps) {
const { services } = useKibana(); const { services } = useKibana();
const { const {
cases: { cases: {
hooks: { useCasesAddToExistingCaseModal }, hooks: { useCasesAddToExistingCaseModal },
}, },
triggersActionsUi: { triggersActionsUi: { getRuleSnoozeModal: RuleSnoozeModal },
ruleTypeRegistry,
actionTypeRegistry,
getRuleSnoozeModal: RuleSnoozeModal,
},
http, http,
} = services; } = services;
const { rule, refetch } = useFetchRule({
ruleId: alert?.fields[ALERT_RULE_UUID] || '',
});
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false); const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [ruleConditionsFlyoutOpen, setRuleConditionsFlyoutOpen] = useState<boolean>(false);
const [snoozeModalOpen, setSnoozeModalOpen] = useState<boolean>(false); const [snoozeModalOpen, setSnoozeModalOpen] = useState<boolean>(false);
const selectCaseModal = useCasesAddToExistingCaseModal(); const selectCaseModal = useCasesAddToExistingCaseModal();
@ -84,6 +78,8 @@ export function HeaderActions({
} }
}, [alert, untrackAlerts, onUntrackAlert]); }, [alert, untrackAlerts, onUntrackAlert]);
const [alertDetailsRuleFormFlyoutOpen, setAlertDetailsRuleFormFlyoutOpen] = useState(false);
const handleTogglePopover = () => setIsPopoverOpen(!isPopoverOpen); const handleTogglePopover = () => setIsPopoverOpen(!isPopoverOpen);
const handleClosePopover = () => setIsPopoverOpen(false); const handleClosePopover = () => setIsPopoverOpen(false);
@ -107,11 +103,6 @@ export function HeaderActions({
selectCaseModal.open({ getAttachments: () => attachments }); selectCaseModal.open({ getAttachments: () => attachments });
}; };
const handleEditRuleDetails = () => {
setIsPopoverOpen(false);
setRuleConditionsFlyoutOpen(true);
};
const handleOpenSnoozeModal = () => { const handleOpenSnoozeModal = () => {
setIsPopoverOpen(false); setIsPopoverOpen(false);
setSnoozeModalOpen(true); setSnoozeModalOpen(true);
@ -175,7 +166,10 @@ export function HeaderActions({
size="s" size="s"
color="text" color="text"
iconType="pencil" iconType="pencil"
onClick={handleEditRuleDetails} onClick={() => {
setIsPopoverOpen(false);
setAlertDetailsRuleFormFlyoutOpen(true);
}}
disabled={!alert?.fields[ALERT_RULE_UUID] || !rule} disabled={!alert?.fields[ALERT_RULE_UUID] || !rule}
data-test-subj="edit-rule-button" data-test-subj="edit-rule-button"
> >
@ -225,20 +219,15 @@ export function HeaderActions({
</EuiPopover> </EuiPopover>
</EuiFlexItem> </EuiFlexItem>
</EuiFlexGroup> </EuiFlexGroup>
{rule && ruleConditionsFlyoutOpen ? ( {rule && (
<RuleFormFlyout <AlertDetailsRuleFormFlyout
plugins={{ ...services, ruleTypeRegistry, actionTypeRegistry }} isRuleFormFlyoutOpen={alertDetailsRuleFormFlyoutOpen}
id={rule.id} setIsRuleFormFlyoutOpen={setAlertDetailsRuleFormFlyoutOpen}
onCancel={() => { onUpdate={onUpdate}
setRuleConditionsFlyoutOpen(false); refetch={refetch}
}} rule={rule}
onSubmit={() => {
setRuleConditionsFlyoutOpen(false);
onUpdate?.();
refetch();
}}
/> />
) : null} )}
{rule && snoozeModalOpen ? ( {rule && snoozeModalOpen ? (
<RuleSnoozeModal <RuleSnoozeModal

View file

@ -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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { InvestigationGuide } from './investigation_guide';
import { render } from '../../../utils/test_helper';
import * as kibana from '../../../utils/kibana_react';
import { act, fireEvent } from '@testing-library/react';
jest.mock('@kbn/response-ops-rule-form/flyout', () => {
return {
// we mock the response-ops flyout because we aren't testing it here
RuleFormFlyout: () => <div>Mock Flyout</div>,
};
});
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(
<InvestigationGuide
onUpdate={() => {}}
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(
<InvestigationGuide
onUpdate={() => {}}
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');
});
});

View file

@ -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 ? (
<>
<EuiSpacer size="m" />
<EuiMarkdownFormat
css={css`
word-wrap: break-word;
`}
>
{blob}
</EuiMarkdownFormat>
</>
) : (
<>
<EuiEmptyPrompt
iconType="logoObservability"
iconColor="default"
title={
<h3>
<FormattedMessage
id="xpack.observability.alertDetails.investigationGide.emptyPrompt.title"
defaultMessage="Add an Investigation Guide"
/>
</h3>
}
titleSize="m"
body={
<p>
<FormattedMessage
id="xpack.observability.alertDetails.investigationGide.emptyPrompt.body"
defaultMessage="Add a guide to your alert's rule."
/>
</p>
}
actions={
<EuiButton
data-test-subj="xpack.observability.alertDetails.investigationGuide.emptyPrompt.addGuide"
color="primary"
onClick={() => setAlertDetailsRuleFormFlyoutOpen(true)}
fill
>
<FormattedMessage
id="xpack.observability.alertDetails.investigationGide.emptyPrompt.addGuideButton.copy"
defaultMessage="Add guide"
/>
</EuiButton>
}
/>
{!!rule && (
<AlertDetailsRuleFormFlyout
initialEditStep={RuleFormStepId.DETAILS}
isRuleFormFlyoutOpen={alertDetailsRuleFormFlyoutOpen}
setIsRuleFormFlyoutOpen={setAlertDetailsRuleFormFlyoutOpen}
onUpdate={onUpdate}
refetch={refetch}
rule={rule}
/>
)}
</>
);
}