Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,5 @@ export enum RuleFormStepId {
ACTIONS = 'rule-actions',
DETAILS = 'rule-details',
}

export const MAX_ARTIFACTS_INVESTIGATION_GUIDE_LENGTH = 1000;
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import React, { type ReactNode } from 'react';
import { fireEvent, render as rtlRender, screen } from '@testing-library/react';

import type { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
Expand Down Expand Up @@ -112,6 +112,8 @@ const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');

const mockOnChange = jest.fn();

const render = (toRender: ReactNode) => rtlRender(toRender, { wrapper: IntlProvider });

describe('Rule Definition', () => {
beforeEach(() => {
useRuleFormDispatch.mockReturnValue(mockOnChange);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,26 @@
*/

import React from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { fireEvent, render as rtlRender, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { RuleDetails } from './rule_details';

const mockOnChange = jest.fn();

jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));

const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');

const render = (toRender: React.ReactElement) =>
rtlRender(toRender, {
wrapper: ({ children }) => <IntlProvider>{children}</IntlProvider>,
});

const mockOnChange = jest.fn();

describe('RuleDetails', () => {
beforeEach(() => {
useRuleFormState.mockReturnValue({
Expand Down Expand Up @@ -88,4 +94,57 @@ describe('RuleDetails', () => {
expect(screen.getByText('name is invalid')).toBeInTheDocument();
expect(screen.getByText('tags is invalid')).toBeInTheDocument();
});

test('should call dispatch with artifacts object when investigation guide is added', async () => {
useRuleFormState.mockReturnValue({
plugins: {
contentManagement: {} as ContentManagementPublicStart,
},
formData: {
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
ruleTypeId: '.es-query',
},
canShowConsumerSelection: true,
validConsumers: ['logs', 'stackAlerts'],
});
render(<RuleDetails />);

const investigationGuideEditor = screen.getByTestId('investigationGuideEditor');
const investigationGuideTextArea = screen.getByLabelText(
'Add guidelines for addressing alerts created by this rule'
);
expect(investigationGuideEditor).toBeInTheDocument();
expect(investigationGuideEditor).toBeVisible();
expect(
screen.getByPlaceholderText('Add guidelines for addressing alerts created by this rule')
);

fireEvent.change(investigationGuideTextArea, {
target: {
value: '# Example investigation guide',
},
});

expect(mockOnChange).toHaveBeenCalledWith({
type: 'setRuleProperty',
payload: {
property: 'artifacts',
value: {
investigation_guide: {
blob: '# Example investigation guide',
},
},
},
});
expect(mockOnChange).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,24 @@ import {
EuiComboBoxOptionOption,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiIconTip,
} from '@elastic/eui';
import { RULE_NAME_INPUT_TITLE, RULE_TAG_INPUT_TITLE, RULE_TAG_PLACEHOLDER } from '../translations';
import { i18n } from '@kbn/i18n';

import {
RULE_INVESTIGATION_GUIDE_LABEL,
RULE_NAME_INPUT_TITLE,
RULE_TAG_INPUT_TITLE,
RULE_TAG_PLACEHOLDER,
} from '../translations';
import { useRuleFormState, useRuleFormDispatch } from '../hooks';
import { OptionalFieldLabel } from '../optional_field_label';
import { InvestigationGuideEditor } from './rule_investigation_guide_editor';
import { RuleDashboards } from './rule_dashboards';
import { MAX_ARTIFACTS_INVESTIGATION_GUIDE_LENGTH } from '../constants';

export const RULE_DETAIL_MIN_ROW_WIDTH = 600;

export const RuleDetails = () => {
const { formData, baseErrors, plugins } = useRuleFormState();
Expand Down Expand Up @@ -72,6 +85,19 @@ export const RuleDetails = () => {
}
}, [dispatch, tags]);

const onSetArtifacts = useCallback(
(value: object) => {
dispatch({
type: 'setRuleProperty',
payload: {
property: 'artifacts',
value: formData.artifacts ? { ...formData.artifacts, ...value } : value,
},
});
},
[dispatch, formData.artifacts]
);

return (
<>
<EuiFlexGroup>
Expand Down Expand Up @@ -113,7 +139,43 @@ export const RuleDetails = () => {
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiFormRow
fullWidth
label={
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>{RULE_INVESTIGATION_GUIDE_LABEL}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIconTip
type="questionInCircle"
content={
<p>
{i18n.translate(
'responseOpsRuleForm.ruleDetails.investigationGuideFormRow.toolTip.content',
{
defaultMessage:
'These details will be included in a new tab on the alert details page for every alert triggered by this rule.',
}
)}
</p>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
labelAppend={OptionalFieldLabel}
isInvalid={
(formData.artifacts?.investigation_guide?.blob?.length ?? 0) >
MAX_ARTIFACTS_INVESTIGATION_GUIDE_LENGTH
}
>
<InvestigationGuideEditor
setRuleParams={onSetArtifacts}
value={formData.artifacts?.investigation_guide?.blob ?? ''}
/>
</EuiFormRow>
{contentManagement && <RuleDashboards contentManagement={contentManagement} />}
<EuiSpacer size="xxl" />
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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 as rtlRender, screen } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { InvestigationGuideEditor } from './rule_investigation_guide_editor';
import { userEvent } from '@testing-library/user-event';

const render = (toRender: any) => rtlRender(toRender, { wrapper: IntlProvider });

describe('RuleInvestigationGuide', () => {
it('should render the investigation guide when provided', () => {
const setRuleParams = jest.fn();
render(<InvestigationGuideEditor setRuleParams={setRuleParams} value="123" />);
const editorElement = screen.getByLabelText(
'Add guidelines for addressing alerts created by this rule'
);
expect(editorElement).toBeInTheDocument();
});

it('should call setRuleParams when the value changes', async () => {
const setRuleParams = jest.fn();
render(<InvestigationGuideEditor setRuleParams={setRuleParams} value="# Markdown Summary" />);
const editorElement = screen.getByLabelText(
'Add guidelines for addressing alerts created by this rule'
);
expect(editorElement).toBeInTheDocument();
expect(editorElement).toHaveValue('# Markdown Summary');
expect(setRuleParams).toHaveBeenCalledTimes(0);

await userEvent.type(editorElement!, '!');

expect(setRuleParams).toHaveBeenCalled();
expect(setRuleParams.mock.calls[0]).toHaveLength(1);
expect(setRuleParams.mock.calls[0][0]).toEqual({
investigation_guide: { blob: '# Markdown Summary!' },
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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 { EuiMarkdownAstNode, EuiMarkdownEditor, EuiMarkdownParseError } from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import React, { useCallback } from 'react';
import { MAX_ARTIFACTS_INVESTIGATION_GUIDE_LENGTH } from '../constants';

interface Props {
setRuleParams: (v: { investigation_guide: { blob: string } }) => void;
value: string;
}

export function InvestigationGuideEditor({ setRuleParams, value }: Props) {
const [errorMessages, setErrorMessages] = React.useState<string[]>([]);
const onParse = useCallback(
(_: EuiMarkdownParseError | null, { ast }: { ast: EuiMarkdownAstNode }) => {
const length = ast.position?.end.offset ?? 0;
if (length > MAX_ARTIFACTS_INVESTIGATION_GUIDE_LENGTH) {
setErrorMessages([
i18n.translate('responseOpsRuleForm.investigationGuide.editor.errorMessage', {
defaultMessage:
'The Investigation Guide is too long. Please shorten it.\nCurrent length: {length}.\nMax length: {maxLength}.',
values: { length, maxLength: MAX_ARTIFACTS_INVESTIGATION_GUIDE_LENGTH },
}),
]);
} else if (errorMessages.length) {
setErrorMessages([]);
}
},
[errorMessages]
);
return (
<EuiMarkdownEditor
aria-label={i18n.translate(
'responseOpsRuleForm.ruleDetails.investigationGuide.editor.ariaLabel',
{
defaultMessage: 'Add guidelines for addressing alerts created by this rule',
}
)}
placeholder={i18n.translate(
'responseOpsRuleForm.ruleDetails.investigationGuide.editor.placeholder',
{
defaultMessage: 'Add guidelines for addressing alerts created by this rule',
}
)}
css={css`
.euiMarkdownFormat {
word-wrap: break-word;
}
`}
value={value}
onChange={(blob) => setRuleParams({ investigation_guide: { blob } })}
onParse={onParse}
errors={errorMessages}
height={200}
data-test-subj="investigationGuideEditor"
initialViewMode="editing"
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,13 @@ export const RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT = i18n.translate(
}
);

export const RULE_INVESTIGATION_GUIDE_TOO_LONG_TEXT = (length: number, maxLength: number) =>
i18n.translate('responseOpsRuleForm.ruleForm.error.investigationGuideTooLongText', {
defaultMessage:
'Investigation guide is too long. Current length: {length}. Max length: {maxLength}.',
values: { length, maxLength },
});

export const INTERVAL_MINIMUM_TEXT = (minimum: string) =>
i18n.translate('responseOpsRuleForm.ruleForm.error.belowMinimumText', {
defaultMessage: 'Interval must be at least {minimum}.',
Expand Down Expand Up @@ -297,6 +304,13 @@ export const RULE_TAG_PLACEHOLDER = i18n.translate(
}
);

export const RULE_INVESTIGATION_GUIDE_LABEL = i18n.translate(
'responseOpsRuleForm.ruleForm.ruleDetails.investigationGuide.editor.title',
{
defaultMessage: 'Investigation guide',
}
);

export const RULE_NAME_ARIA_LABEL_TEXT = i18n.translate(
'responseOpsRuleForm.ruleForm.rulePage.ruleNameAriaLabelText',
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
INTERVAL_REQUIRED_TEXT,
INTERVAL_MINIMUM_TEXT,
RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT,
RULE_INVESTIGATION_GUIDE_TOO_LONG_TEXT,
} from '../translations';
import type {
MinimumScheduleInterval,
Expand All @@ -27,6 +28,7 @@ import type {
RuleTypeModel,
RuleUiAction,
} from '../common';
import { MAX_ARTIFACTS_INVESTIGATION_GUIDE_LENGTH } from '../constants';

export const validateAction = ({ action }: { action: RuleUiAction }): RuleFormActionsErrors => {
const errors = {
Expand Down Expand Up @@ -64,6 +66,7 @@ export function validateRuleBase({
actionConnectors: new Array<string>(),
alertDelay: new Array<string>(),
tags: new Array<string>(),
artifacts: new Array<string>(),
};

if (!formData.name) {
Expand Down Expand Up @@ -94,6 +97,16 @@ export function validateRuleBase({
errors.alertDelay.push(RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT);
}

const investigationGuideLength = formData.artifacts?.investigation_guide?.blob.length ?? 0;
if (investigationGuideLength > MAX_ARTIFACTS_INVESTIGATION_GUIDE_LENGTH) {
errors.artifacts.push(
RULE_INVESTIGATION_GUIDE_TOO_LONG_TEXT(
investigationGuideLength,
MAX_ARTIFACTS_INVESTIGATION_GUIDE_LENGTH
)
);
}

return errors;
}

Expand Down
Loading