diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index ba1de0e40e270..d9d9fde8fc8cc 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -58,6 +58,8 @@ import { createAndActivateRule, fillAboutRuleAndContinue, fillDefineCustomRuleWithImportedQueryAndContinue, + expectDefineFormToRepopulateAndContinue, + expectAboutFormToRepopulateAndContinue, } from '../tasks/create_new_rule'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; @@ -82,6 +84,8 @@ describe('Detection rules, custom', () => { goToCreateNewRule(); fillDefineCustomRuleWithImportedQueryAndContinue(newRule); fillAboutRuleAndContinue(newRule); + expectDefineFormToRepopulateAndContinue(newRule); + expectAboutFormToRepopulateAndContinue(newRule); createAndActivateRule(); cy.get(CUSTOM_RULES_BTN).invoke('text').should('eql', 'Custom rules (1)'); diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index 83ace877cd51d..397d0c0142179 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -6,6 +6,8 @@ export const ABOUT_CONTINUE_BTN = '[data-test-subj="about-continue"]'; +export const ABOUT_EDIT_BUTTON = '[data-test-subj="edit-about-rule"]'; + export const ADD_FALSE_POSITIVE_BTN = '[data-test-subj="detectionEngineStepAboutRuleFalsePositives"] .euiButtonEmpty__text'; @@ -26,6 +28,8 @@ export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]'; +export const DEFINE_EDIT_BUTTON = '[data-test-subj="edit-define-rule"]'; + export const IMPORT_QUERY_FROM_SAVED_TIMELINE_LINK = '[data-test-subj="importQueryFromSavedTimeline"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 1cce72a48e0f0..3fa300ce9d8d0 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -48,6 +48,8 @@ import { THRESHOLD_FIELD_SELECTION, THRESHOLD_INPUT_AREA, THRESHOLD_TYPE, + DEFINE_EDIT_BUTTON, + ABOUT_EDIT_BUTTON, } from '../screens/create_new_rule'; import { TIMELINE } from '../screens/timeline'; @@ -175,6 +177,20 @@ export const fillDefineCustomRuleWithImportedQueryAndContinue = ( cy.get(CUSTOM_QUERY_INPUT).should('not.exist'); }; +export const expectDefineFormToRepopulateAndContinue = (rule: CustomRule) => { + cy.get(DEFINE_EDIT_BUTTON).click(); + cy.get(CUSTOM_QUERY_INPUT).invoke('text').should('eq', rule.customQuery); + cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); + cy.get(DEFINE_CONTINUE_BUTTON).should('not.exist'); +}; + +export const expectAboutFormToRepopulateAndContinue = (rule: CustomRule) => { + cy.get(ABOUT_EDIT_BUTTON).click(); + cy.get(RULE_NAME_INPUT).invoke('val').should('eq', rule.name); + cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true }); + cy.get(ABOUT_CONTINUE_BTN).should('not.exist'); +}; + export const fillDefineThresholdRuleAndContinue = (rule: ThresholdRule) => { const thresholdField = 0; const threshold = 1; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.ts index 075f06084384b..b4fb307a62b6c 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.ts @@ -19,12 +19,11 @@ export interface WithKibanaProps { kibana: KibanaContext; } -// eslint-disable-next-line react-hooks/rules-of-hooks -const typedUseKibana = () => useKibana(); +const useTypedKibana = () => useKibana(); export { KibanaContextProvider, - typedUseKibana as useKibana, + useTypedKibana as useKibana, useUiSetting, useUiSetting$, withKibana, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx index 8b3d05ce5a574..8179e5865e4ef 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import { - StepRuleDescriptionComponent, + StepRuleDescription, addFilterStateIfNotThere, buildListItems, getDescriptionItem, @@ -52,24 +52,24 @@ describe('description_step', () => { mockAboutStep = mockAboutStepRule(); }); - describe('StepRuleDescriptionComponent', () => { + describe('StepRuleDescription', () => { test('renders tow columns when "columns" is "multi"', () => { const wrapper = shallow( - + ); expect(wrapper.find('[data-test-subj="listItemColumnStepRuleDescription"]')).toHaveLength(2); }); test('renders single column when "columns" is "single"', () => { const wrapper = shallow( - + ); expect(wrapper.find('[data-test-subj="listItemColumnStepRuleDescription"]')).toHaveLength(1); }); test('renders one column with title and description split when "columns" is "singleSplit', () => { const wrapper = shallow( - + ); expect(wrapper.find('[data-test-subj="listItemColumnStepRuleDescription"]')).toHaveLength(1); expect( @@ -299,7 +299,6 @@ describe('description_step', () => { describe('queryBar', () => { test('returns array of ListItems when queryBar exist', () => { const mockQueryBar = { - isNew: false, queryBar: { query: { query: 'user.name: root or user.name: admin', @@ -369,7 +368,6 @@ describe('description_step', () => { describe('threshold', () => { test('returns threshold description when threshold exist and field is empty', () => { const mockThreshold = { - isNew: false, threshold: { field: [''], value: 100, @@ -391,7 +389,6 @@ describe('description_step', () => { test('returns threshold description when threshold exist and field is set', () => { const mockThreshold = { - isNew: false, threshold: { field: ['user.name'], value: 100, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index cf27fa97b1479..99e36669f78bb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -37,7 +37,6 @@ import { buildRuleTypeDescription, buildThresholdDescription, } from './helpers'; -import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs'; import { buildMlJobDescription } from './ml_job_description'; import { buildActionsDescription } from './actions_description'; import { buildThrottleDescription } from './throttle_description'; @@ -52,22 +51,21 @@ const DescriptionListContainer = styled(EuiDescriptionList)` } `; -interface StepRuleDescriptionProps { +interface StepRuleDescriptionProps { columns?: 'multi' | 'single' | 'singleSplit'; data: unknown; indexPatterns?: IIndexPattern; - schema: FormSchema; + schema: FormSchema; } -export const StepRuleDescriptionComponent: React.FC = ({ +export const StepRuleDescriptionComponent = ({ data, columns = 'multi', indexPatterns, schema, -}) => { +}: StepRuleDescriptionProps) => { const kibana = useKibana(); const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); - const { jobs } = useSecurityJobs(false); const keys = Object.keys(schema); const listItems = keys.reduce((acc: ListItems[], key: string) => { @@ -76,8 +74,7 @@ export const StepRuleDescriptionComponent: React.FC = ...acc, buildMlJobDescription( get(key, data) as string, - (get(key, schema) as { label: string }).label, - jobs + (get(key, schema) as { label: string }).label ), ]; } @@ -125,11 +122,13 @@ export const StepRuleDescriptionComponent: React.FC = ); }; -export const StepRuleDescription = memo(StepRuleDescriptionComponent); +export const StepRuleDescription = memo( + StepRuleDescriptionComponent +) as typeof StepRuleDescriptionComponent; -export const buildListItems = ( +export const buildListItems = ( data: unknown, - schema: FormSchema, + schema: FormSchema, filterManager: FilterManager, indexPatterns?: IIndexPattern ): ListItems[] => diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx index 3152fef12c652..ec46610286b46 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx @@ -14,7 +14,7 @@ jest.mock('../../../../common/lib/kibana'); describe('MlJobDescription', () => { it('renders correctly', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="machineLearningJobId"]')).toHaveLength(1); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx index 6baa2abab33d1..414f6f2c2d3bb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx @@ -10,6 +10,7 @@ import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; import { MlSummaryJob } from '../../../../../../ml/public'; import { isJobStarted } from '../../../../../common/machine_learning/helpers'; +import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs'; import { useKibana } from '../../../../common/lib/kibana'; import { ListItems } from './types'; import { ML_JOB_STARTED, ML_JOB_STOPPED } from './translations'; @@ -69,35 +70,33 @@ const Wrapper = styled.div` overflow: hidden; `; -const MlJobDescriptionComponent: React.FC<{ job: MlSummaryJob }> = ({ job }) => { +const MlJobDescriptionComponent: React.FC<{ jobId: string }> = ({ jobId }) => { + const { jobs } = useSecurityJobs(false); const jobUrl = useKibana().services.application.getUrlForApp( - `ml#/jobs?mlManagement=(jobId:${encodeURI(job.id)})` + `ml#/jobs?mlManagement=(jobId:${encodeURI(jobId)})` ); + const job = jobs.find(({ id }) => id === jobId); - return ( + const jobIdSpan = {jobId}; + + return job != null ? (
- - {job.id} + + {jobIdSpan}
+ ) : ( + jobIdSpan ); }; export const MlJobDescription = React.memo(MlJobDescriptionComponent); -export const buildMlJobDescription = ( - jobId: string, - label: string, - jobs: MlSummaryJob[] -): ListItems => { - const job = jobs.find(({ id }) => id === jobId); - - return { - title: label, - description: job ? : jobId, - }; -}; +export const buildMlJobDescription = (jobId: string, label: string): ListItems => ({ + title: label, + description: , +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx index d97c2b4c8c0aa..7c8f5230cc8ba 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx @@ -9,7 +9,7 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elasti import * as RuleI18n from '../../../pages/detection_engine/rules/translations'; interface NextStepProps { - onClick: () => Promise; + onClick: () => void; isDisabled: boolean; dataTestSubj?: string; } diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx index 1ef3edf8c720e..08cea23d89e1e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx @@ -8,12 +8,12 @@ import styled from 'styled-components'; import { EuiHealth } from '@elastic/eui'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; -import * as I18n from './translations'; -export type SeverityValue = 'low' | 'medium' | 'high' | 'critical'; +import { Severity } from '../../../../../common/detection_engine/schemas/common/schemas'; +import * as I18n from './translations'; export interface SeverityOptionItem { - value: SeverityValue; + value: Severity; inputDisplay: React.ReactElement; } @@ -44,7 +44,7 @@ export const severityOptions: SeverityOptionItem[] = [ }, ]; -export const defaultRiskScoreBySeverity: Record = { +export const defaultRiskScoreBySeverity: Record = { low: 21, medium: 47, high: 73, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts index b9c3e4f84c18e..56c61c2ad6766 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts @@ -21,9 +21,8 @@ export const stepAboutDefaultValue: AboutStepRule = { description: '', isAssociatedToEndpointList: false, isBuildingBlock: false, - isNew: true, severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false }, - riskScore: { value: 50, mapping: [], isMappingChecked: false }, + riskScore: { value: 21, mapping: [], isMappingChecked: false }, references: [''], falsePositives: [''], license: '', diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx index 0c834b9fff33a..cb25785eaa5b2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { act } from 'react-dom/test-utils'; import { mount, shallow } from 'enzyme'; import { ThemeProvider } from 'styled-components'; import euiDarkVars from '@elastic/eui/dist/eui_theme_light.json'; @@ -14,9 +13,11 @@ import { StepAboutRule } from '.'; import { mockAboutStepRule } from '../../../pages/detection_engine/rules/all/__mocks__/mock'; import { StepRuleDescription } from '../description_step'; import { stepAboutDefaultValue } from './default_value'; -// we don't have the types for waitFor just yet, so using "as waitFor" until when we do -import { wait as waitFor } from '@testing-library/react'; -import { AboutStepRule } from '../../../pages/detection_engine/rules/types'; +import { + AboutStepRule, + RuleStepsFormHooks, + RuleStep, +} from '../../../pages/detection_engine/rules/types'; import { fillEmptySeverityMappings } from '../../../pages/detection_engine/rules/helpers'; const theme = () => ({ eui: euiDarkVars, darkMode: true }); @@ -33,6 +34,18 @@ afterAll(() => { /* eslint-enable no-console */ describe('StepAboutRuleComponent', () => { + let formHook: RuleStepsFormHooks[RuleStep.aboutRule] | null = null; + const setFormHook = ( + step: K, + hook: RuleStepsFormHooks[K] + ) => { + formHook = hook as typeof formHook; + }; + + beforeEach(() => { + formHook = null; + }); + test('it renders StepRuleDescription if isReadOnlyView is true and "name" property exists', () => { const wrapper = shallow( { expect(wrapper.find(StepRuleDescription).exists()).toBeTruthy(); }); - test('it prevents user from clicking continue if no "description" defined', () => { + it('is invalid if description is not present', async () => { const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={jest.fn()} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') .first() .simulate('change', { target: { value: 'Test name text' } }); - const descriptionInput = wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') - .first(); - wrapper.find('button[data-test-subj="about-continue"]').first().simulate('click'); - - expect( - wrapper.find('[data-test-subj="detectionEngineStepAboutRuleName"] input').first().props() - .value - ).toEqual('Test name text'); - expect(descriptionInput.props().value).toEqual(''); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] label') - .first() - .hasClass('euiFormLabel-isInvalid') - ).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] EuiTextArea') - .first() - .prop('isInvalid') - ).toBeTruthy(); + const result = await formHook(); + expect(result?.isValid).toEqual(false); }); - test('it prevents user from clicking continue if no "name" defined', () => { + it('is invalid if no "name" is present', async () => { const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={jest.fn()} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') .first() .simulate('change', { target: { value: 'Test description text' } }); - const nameInput = wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') - .first(); - - wrapper.find('button[data-test-subj="about-continue"]').first().simulate('click'); - - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') - .first() - .props().value - ).toEqual('Test description text'); - expect(nameInput.props().value).toEqual(''); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleName"] label') - .first() - .hasClass('euiFormLabel-isInvalid') - ).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleName"] EuiFieldText') - .first() - .prop('isInvalid') - ).toBeTruthy(); + const result = await formHook(); + expect(result?.isValid).toEqual(false); }); - test('it allows user to click continue if "name" and "description" are defined', async () => { - const stepDataMock = jest.fn(); + it('is valid if both "name" and "description" are present', async () => { const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={stepDataMock} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') .first() .simulate('change', { target: { value: 'Test description text' } }); - wrapper .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') .first() .simulate('change', { target: { value: 'Test name text' } }); - wrapper.find('button[data-test-subj="about-continue"]').first().simulate('click').update(); - await waitFor(() => { - const expected: Omit = { - author: [], - isAssociatedToEndpointList: false, - isBuildingBlock: false, - license: '', - ruleNameOverride: '', - timestampOverride: '', - description: 'Test description text', - falsePositives: [''], - name: 'Test name text', - note: '', - references: [''], - riskScore: { value: 50, mapping: [], isMappingChecked: false }, - severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false }, - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: 'none', name: 'none', reference: 'none' }, - technique: [], - }, - ], - }; - expect(stepDataMock.mock.calls[1][1]).toEqual(expected); - }); + const expected: AboutStepRule = { + author: [], + isAssociatedToEndpointList: false, + isBuildingBlock: false, + license: '', + ruleNameOverride: '', + timestampOverride: '', + description: 'Test description text', + falsePositives: [''], + name: 'Test name text', + note: '', + references: [''], + riskScore: { value: 21, mapping: [], isMappingChecked: false }, + severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false }, + tags: [], + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { id: 'none', name: 'none', reference: 'none' }, + technique: [], + }, + ], + }; + + const result = await formHook(); + expect(result?.isValid).toEqual(true); + expect(result?.data).toEqual(expected); }); test('it allows user to set the risk score as a number (and not a string)', async () => { - const stepDataMock = jest.fn(); const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={stepDataMock} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') .first() @@ -224,11 +203,7 @@ describe('StepAboutRuleComponent', () => { .first() .simulate('change', { target: { value: '80' } }); - await act(async () => { - wrapper.find('[data-test-subj="about-continue"]').first().simulate('click').update(); - }); - - const expected: Omit = { + const expected: AboutStepRule = { author: [], isAssociatedToEndpointList: false, isBuildingBlock: false, @@ -251,6 +226,52 @@ describe('StepAboutRuleComponent', () => { }, ], }; - expect(stepDataMock.mock.calls[1][1]).toEqual(expected); + + const result = await formHook(); + expect(result?.isValid).toEqual(true); + expect(result?.data).toEqual(expected); + }); + + it('does not modify the provided risk score until the user changes the severity', async () => { + const wrapper = mount( + + + + ); + + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') + .first() + .simulate('change', { target: { value: 'Test name text' } }); + + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') + .first() + .simulate('change', { target: { value: 'Test description text' } }); + + const result = await formHook(); + expect(result?.isValid).toEqual(true); + expect(result?.data?.riskScore.value).toEqual(21); + + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleSeverity"] [data-test-subj="select"]') + .last() + .simulate('click'); + wrapper.find('button#medium').simulate('click'); + + const result2 = await formHook(); + expect(result2?.isValid).toEqual(true); + expect(result2?.data?.riskScore.value).toEqual(47); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx index 295b13717f076..d2c84883fa99b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx @@ -22,13 +22,14 @@ import { AddMitreThreat } from '../mitre'; import { Field, Form, - FormDataProvider, getUseField, UseField, useForm, + useFormData, + FieldHook, } from '../../../../shared_imports'; -import { defaultRiskScoreBySeverity, severityOptions, SeverityValue } from './data'; +import { defaultRiskScoreBySeverity, severityOptions } from './data'; import { stepAboutDefaultValue } from './default_value'; import { isUrlInvalid } from '../../../../common/utils/validators'; import { schema } from './schema'; @@ -68,47 +69,69 @@ const StepAboutRuleComponent: FC = ({ isReadOnlyView, isUpdateView = false, isLoading, + onSubmit, setForm, - setStepData, }) => { const initialState = defaultValues ?? stepAboutDefaultValue; - const [myStepData, setMyStepData] = useState(initialState); + const [severityValue, setSeverityValue] = useState(initialState.severity.value); const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns( defineRuleData?.index ?? [], - 'step_about_rule' + RuleStep.aboutRule ); const canUseExceptions = defineRuleData?.ruleType && !isMlRule(defineRuleData.ruleType) && !isThresholdRule(defineRuleData.ruleType); - const { form } = useForm({ + const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); - const { getFields, submit } = form; + const { getFields, getFormData, submit } = form; + const [{ severity: formSeverity }] = (useFormData({ + form, + watch: ['severity'], + }) as unknown) as [Partial]; - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.aboutRule, null, false); - const { isValid, data } = await submit(); - if (isValid) { - setStepData(RuleStep.aboutRule, data, isValid); - setMyStepData({ ...data, isNew: false } as AboutStepRule); + useEffect(() => { + const formSeverityValue = formSeverity?.value; + if (formSeverityValue != null && formSeverityValue !== severityValue) { + setSeverityValue(formSeverityValue); + + const newRiskScoreValue = defaultRiskScoreBySeverity[formSeverityValue]; + if (newRiskScoreValue != null) { + const riskScoreField = getFields().riskScore as FieldHook; + riskScoreField.setValue({ ...riskScoreField.value, value: newRiskScoreValue }); } } - }, [setStepData, submit]); + }, [formSeverity?.value, getFields, severityValue]); + + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); + + const handleSubmit = useCallback(() => { + if (onSubmit) { + onSubmit(); + } + }, [onSubmit]); useEffect(() => { if (setForm) { - setForm(RuleStep.aboutRule, form); + setForm(RuleStep.aboutRule, getData); } - }, [setForm, form]); + }, [getData, setForm]); - return isReadOnlyView && myStepData.name != null ? ( + return isReadOnlyView ? ( - + ) : ( <> @@ -305,26 +328,11 @@ const StepAboutRuleComponent: FC = ({ }} /> - - {({ severity }) => { - const newRiskScore = defaultRiskScoreBySeverity[severity as SeverityValue]; - const severityField = getFields().severity; - const riskScoreField = getFields().riskScore; - if ( - severityField.value !== severity && - newRiskScore != null && - riskScoreField.value !== newRiskScore - ) { - riskScoreField.setValue(newRiskScore); - } - return null; - }} - {!isUpdateView && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx index 2264a11341eb8..6df94eca429c5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx @@ -13,7 +13,7 @@ import { ValidationFunc, ERROR_CODE, } from '../../../../shared_imports'; -import { IMitreEnterpriseAttack } from '../../../pages/detection_engine/rules/types'; +import { IMitreEnterpriseAttack, AboutStepRule } from '../../../pages/detection_engine/rules/types'; import { isMitreAttackInvalid } from '../mitre/helpers'; import { OptionalFieldLabel } from '../optional_field_label'; import { isUrlInvalid } from '../../../../common/utils/validators'; @@ -21,7 +21,7 @@ import * as I18n from './translations'; const { emptyField } = fieldValidators; -export const schema: FormSchema = { +export const schema: FormSchema = { author: { type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index 8a1f961883519..158f323b739e6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -7,15 +7,14 @@ import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; import React, { FC, memo, useCallback, useState, useEffect } from 'react'; import styled from 'styled-components'; -import deepEqual from 'fast-deep-equal'; +import isEqual from 'lodash/isEqual'; import { DEFAULT_INDEX_KEY } from '../../../../../common/constants'; +import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../containers/detection_engine/rules'; -import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations'; import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; import { useUiSetting$ } from '../../../../common/lib/kibana'; import { @@ -42,9 +41,8 @@ import { getUseField, UseField, UseMultiFields, - FormDataProvider, useForm, - FormSchema, + useFormData, } from '../../../../shared_imports'; import { schema } from './schema'; import * as i18n from './translations'; @@ -52,13 +50,12 @@ import * as i18n from './translations'; const CommonUseField = getUseField({ component: Field }); interface StepDefineRuleProps extends RuleStepProps { - defaultValues?: DefineStepRule | null; + defaultValues?: DefineStepRule; } const stepDefineDefaultValue: DefineStepRule = { anomalyThreshold: 50, index: [], - isNew: true, machineLearningJobId: '', ruleType: 'query', queryBar: { @@ -103,8 +100,8 @@ const StepDefineRuleComponent: FC = ({ isReadOnlyView, isLoading, isUpdateView = false, + onSubmit, setForm, - setStepData, }) => { const mlCapabilities = useMlCapabilities(); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); @@ -112,38 +109,54 @@ const StepDefineRuleComponent: FC = ({ const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); const initialState = defaultValues ?? { ...stepDefineDefaultValue, - index: indicesConfig ?? [], + index: indicesConfig, }; - const [localRuleType, setLocalRuleType] = useState(initialState.ruleType); - const [myStepData, setMyStepData] = useState(initialState); - const [ - { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, - ] = useFetchIndexPatterns(myStepData.index, 'step_define_rule'); - - const { form } = useForm({ + const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); - const { getFields, reset, submit } = form; - const clearErrors = useCallback(() => reset({ resetValues: false }), [reset]); + const { getFields, getFormData, reset, submit } = form; + const [{ index: formIndex, ruleType: formRuleType }] = (useFormData({ + form, + watch: ['index', 'ruleType'], + }) as unknown) as [Partial]; + const index = formIndex || initialState.index; + const ruleType = formRuleType || initialState.ruleType; + const [ + { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, + ] = useFetchIndexPatterns(index, RuleStep.defineRule); - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.defineRule, null, false); - const { isValid, data } = await submit(); - if (isValid && setStepData) { - setStepData(RuleStep.defineRule, data, isValid); - setMyStepData({ ...data, isNew: false } as DefineStepRule); - } + // reset form when rule type changes + useEffect(() => { + reset({ resetValues: false }); + }, [reset, ruleType]); + + useEffect(() => { + setIndexModified(!isEqual(index, indicesConfig)); + }, [index, indicesConfig]); + + const handleSubmit = useCallback(() => { + if (onSubmit) { + onSubmit(); } - }, [setStepData, submit]); + }, [onSubmit]); + + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); useEffect(() => { if (setForm) { - setForm(RuleStep.defineRule, form); + setForm(RuleStep.defineRule, getData); } - }, [form, setForm]); + }, [getData, setForm]); const handleResetIndices = useCallback(() => { const indexField = getFields().index; @@ -173,9 +186,9 @@ const StepDefineRuleComponent: FC = ({ ) : ( @@ -192,7 +205,7 @@ const StepDefineRuleComponent: FC = ({ isMlAdmin: hasMlAdminPermissions(mlCapabilities), }} /> - + <> = ({ /> - + <> = ({ @@ -269,11 +282,9 @@ const StepDefineRuleComponent: FC = ({ fields={{ thresholdField: { path: 'threshold.field', - defaultValue: initialState.threshold.field, }, thresholdValue: { path: 'threshold.value', - defaultValue: initialState.threshold.value, }, }} > @@ -290,31 +301,11 @@ const StepDefineRuleComponent: FC = ({ dataTestSubj: 'detectionEngineStepDefineRuleTimeline', }} /> - - {({ index, ruleType }) => { - if (index != null) { - if (deepEqual(index, indicesConfig) && indexModified) { - setIndexModified(false); - } else if (!deepEqual(index, indicesConfig) && !indexModified) { - setIndexModified(true); - } - if (myStepData.index !== index) { - setMyStepData((prevValue) => ({ ...prevValue, index })); - } - } - - if (ruleType !== localRuleType) { - setLocalRuleType(ruleType); - clearErrors(); - } - return null; - }} - {!isUpdateView && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx index 333b28bf27bbf..07eff94bbbef4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx @@ -19,9 +19,10 @@ import { FormSchema, ValidationFunc, } from '../../../../shared_imports'; +import { DefineStepRule } from '../../../pages/detection_engine/rules/types'; import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations'; -export const schema: FormSchema = { +export const schema: FormSchema = { index: { type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index 5b4f7677dbc30..e6f1c25bf9dac 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -13,7 +13,7 @@ import { EuiSpacer, } from '@elastic/eui'; import { findIndex } from 'lodash/fp'; -import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect, useMemo } from 'react'; import { ActionVariable } from '../../../../../../triggers_actions_ui/public'; import { @@ -22,7 +22,7 @@ import { ActionsStepRule, } from '../../../pages/detection_engine/rules/types'; import { StepRuleDescription } from '../description_step'; -import { Form, UseField, useForm } from '../../../../shared_imports'; +import { Form, UseField, useForm, useFormData } from '../../../../shared_imports'; import { StepContentWrapper } from '../step_content_wrapper'; import { ThrottleSelectField, @@ -40,9 +40,8 @@ interface StepRuleActionsProps extends RuleStepProps { actionMessageParams: ActionVariable[]; } -const stepActionsDefaultValue = { +const stepActionsDefaultValue: ActionsStepRule = { enabled: true, - isNew: true, actions: [], kibanaSiemAppUrl: '', throttle: DEFAULT_THROTTLE_OPTION.value, @@ -65,27 +64,16 @@ const StepRuleActionsComponent: FC = ({ isReadOnlyView, isLoading, isUpdateView = false, - setStepData, + onSubmit, setForm, actionMessageParams, }) => { - const initialState = defaultValues ?? stepActionsDefaultValue; - const [myStepData, setMyStepData] = useState(initialState); const { services: { application, triggers_actions_ui: { actionTypeRegistry }, }, } = useKibana(); - const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]); - - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - const { submit } = form; - const kibanaAbsoluteUrl = useMemo( () => application.getUrlForApp(`${APP_ID}`, { @@ -93,37 +81,52 @@ const StepRuleActionsComponent: FC = ({ }), [application] ); - - const onSubmit = useCallback( - async (enabled: boolean) => { - if (setStepData) { - setStepData(RuleStep.ruleActions, null, false); - const { isValid: newIsValid, data } = await submit(); - if (newIsValid) { - setStepData(RuleStep.ruleActions, { ...data, enabled }, newIsValid); - setMyStepData({ ...data, isNew: false } as ActionsStepRule); - } + const initialState = { + ...(defaultValues ?? stepActionsDefaultValue), + kibanaSiemAppUrl: kibanaAbsoluteUrl, + }; + const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]); + const { form } = useForm({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema, + }); + const { getFields, getFormData, submit } = form; + const [{ throttle: formThrottle }] = (useFormData({ + form, + watch: ['throttle'], + }) as unknown) as [Partial]; + const throttle = formThrottle || initialState.throttle; + + const handleSubmit = useCallback( + (enabled: boolean) => { + getFields().enabled.setValue(enabled); + if (onSubmit) { + onSubmit(); } }, - [setStepData, submit] + [getFields, onSubmit] ); + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); + useEffect(() => { if (setForm) { - setForm(RuleStep.ruleActions, form); + setForm(RuleStep.ruleActions, getData); } - }, [form, setForm]); - - const updateThrottle = useCallback((throttle) => setMyStepData({ ...myStepData, throttle }), [ - myStepData, - setMyStepData, - ]); + }, [getData, setForm]); const throttleOptions = useMemo(() => { - const throttle = myStepData.throttle; - return getThrottleOptions(throttle); - }, [myStepData]); + }, [throttle]); const throttleFieldComponentProps = useMemo( () => ({ @@ -131,18 +134,16 @@ const StepRuleActionsComponent: FC = ({ isDisabled: isLoading, dataTestSubj: 'detectionEngineStepRuleActionsThrottle', hasNoInitialSelection: false, - handleChange: updateThrottle, euiFieldProps: { options: throttleOptions, }, }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [isLoading, updateThrottle] + [isLoading, throttleOptions] ); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView ? ( - + ) : ( <> @@ -154,12 +155,11 @@ const StepRuleActionsComponent: FC = ({ component={ThrottleSelectField} componentProps={throttleFieldComponentProps} /> - {myStepData.throttle !== stepActionsDefaultValue.throttle ? ( + {throttle !== stepActionsDefaultValue.throttle ? ( <> = ({ /> ) : ( - + )} - - + + @@ -197,7 +189,7 @@ const StepRuleActionsComponent: FC = ({ fill={false} isDisabled={isLoading} isLoading={isLoading} - onClick={onSubmit.bind(null, false)} + onClick={() => handleSubmit(false)} > {I18n.COMPLETE_WITHOUT_ACTIVATING} @@ -207,7 +199,7 @@ const StepRuleActionsComponent: FC = ({ fill isDisabled={isLoading} isLoading={isLoading} - onClick={onSubmit.bind(null, true)} + onClick={() => handleSubmit(true)} data-test-subj="create-activate" > {I18n.COMPLETE_WITH_ACTIVATING} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx index a093f991afaf7..38de3a2026eca 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx @@ -12,7 +12,8 @@ import { AlertAction, ActionTypeRegistryContract, } from '../../../../../../triggers_actions_ui/public'; -import { FormSchema, FormData, ValidationFunc, ERROR_CODE } from '../../../../shared_imports'; +import { FormSchema, ValidationFunc, ERROR_CODE } from '../../../../shared_imports'; +import { ActionsStepRule } from '../../../pages/detection_engine/rules/types'; import * as I18n from './translations'; import { isUuidv4, getActionTypeName, validateMustache, validateActionParams } from './utils'; @@ -61,7 +62,7 @@ export const getSchema = ({ actionTypeRegistry, }: { actionTypeRegistry: ActionTypeRegistryContract; -}): FormSchema => ({ +}): FormSchema => ({ actions: { validations: [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx index 52f04f8423bec..d451932a6b634 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, memo, useCallback, useEffect, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect } from 'react'; import { RuleStep, @@ -22,9 +22,8 @@ interface StepScheduleRuleProps extends RuleStepProps { defaultValues?: ScheduleStepRule | null; } -const stepScheduleDefaultValue = { +const stepScheduleDefaultValue: ScheduleStepRule = { interval: '5m', - isNew: true, from: '1m', }; @@ -35,39 +34,44 @@ const StepScheduleRuleComponent: FC = ({ isReadOnlyView, isLoading, isUpdateView = false, - setStepData, + onSubmit, setForm, }) => { const initialState = defaultValues ?? stepScheduleDefaultValue; - const [myStepData, setMyStepData] = useState(initialState); - const { form } = useForm({ + const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); - const { submit } = form; - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.scheduleRule, null, false); - const { isValid: newIsValid, data } = await submit(); - if (newIsValid) { - setStepData(RuleStep.scheduleRule, { ...data }, newIsValid); - setMyStepData({ ...data, isNew: false } as ScheduleStepRule); - } + const { getFormData, submit } = form; + + const handleSubmit = useCallback(() => { + if (onSubmit) { + onSubmit(); } - }, [setStepData, submit]); + }, [onSubmit]); + + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); useEffect(() => { if (setForm) { - setForm(RuleStep.scheduleRule, form); + setForm(RuleStep.scheduleRule, getData); } - }, [form, setForm]); + }, [getData, setForm]); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView ? ( - + ) : ( <> @@ -96,7 +100,7 @@ const StepScheduleRuleComponent: FC = ({ {!isUpdateView && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx index f4c371a2364f6..cf93a9b61710c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx @@ -9,9 +9,10 @@ import { i18n } from '@kbn/i18n'; import { OptionalFieldLabel } from '../optional_field_label'; +import { ScheduleStepRule } from '../../../pages/detection_engine/rules/types'; import { FormSchema } from '../../../../shared_imports'; -export const schema: FormSchema = { +export const schema: FormSchema = { interval: { label: i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepScheduleRule.fieldIntervalLabel', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 8c6e91254314e..5a626ce0ff00a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -165,8 +165,7 @@ export const mockRuleWithEverything = (id: string): Rule => ({ }); // TODO: update types mapping -export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ - isNew, +export const mockAboutStepRule = (): AboutStepRule => ({ author: ['Elastic'], isAssociatedToEndpointList: false, isBuildingBlock: false, @@ -200,16 +199,14 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ note: '# this is some markdown documentation', }); -export const mockActionsStepRule = (isNew = false, enabled = false): ActionsStepRule => ({ - isNew, +export const mockActionsStepRule = (enabled = false): ActionsStepRule => ({ actions: [], kibanaSiemAppUrl: 'http://localhost:5601/app/siem', enabled, throttle: 'no_actions', }); -export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ - isNew, +export const mockDefineStepRule = (): DefineStepRule => ({ ruleType: 'query', anomalyThreshold: 50, machineLearningJobId: '', @@ -225,8 +222,7 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ }, }); -export const mockScheduleStepRule = (isNew = false): ScheduleStepRule => ({ - isNew, +export const mockScheduleStepRule = (): ScheduleStepRule => ({ interval: '5m', from: '6m', to: 'now', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 874ef032b7c5e..0137777f8f8f2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -13,7 +13,7 @@ import { transformAlertToRuleAction } from '../../../../../../common/detection_e import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { isThresholdRule } from '../../../../../../common/detection_engine/utils'; import { List } from '../../../../../../common/detection_engine/schemas/types'; -import { ENDPOINT_LIST_ID } from '../../../../../shared_imports'; +import { ENDPOINT_LIST_ID, ExceptionListType, NamespaceType } from '../../../../../shared_imports'; import { Rule } from '../../../../containers/detection_engine/rules'; import { Type } from '../../../../../../common/detection_engine/schemas/common/schemas'; @@ -26,6 +26,8 @@ import { ScheduleStepRuleJson, AboutStepRuleJson, ActionsStepRuleJson, + RuleStepsFormData, + RuleStep, } from '../types'; export const getTimeTypeValue = (time: string): { unit: string; value: number } => { @@ -33,8 +35,8 @@ export const getTimeTypeValue = (time: string): { unit: string; value: number } unit: '', value: 0, }; - const filterTimeVal = (time as string).match(/\d+/g); - const filterTimeType = (time as string).match(/[a-zA-Z]+/g); + const filterTimeVal = time.match(/\d+/g); + const filterTimeType = time.match(/[a-zA-Z]+/g); if (!isEmpty(filterTimeVal) && filterTimeVal != null && !isNaN(Number(filterTimeVal[0]))) { timeObj.value = Number(filterTimeVal[0]); } @@ -48,6 +50,23 @@ export const getTimeTypeValue = (time: string): { unit: string; value: number } return timeObj; }; +export const stepIsValid = ( + formData?: T +): formData is { [K in keyof T]: Exclude } => + !!formData?.isValid && !!formData.data; + +export const isDefineStep = (input: unknown): input is RuleStepsFormData[RuleStep.defineRule] => + has('data.ruleType', input); + +export const isAboutStep = (input: unknown): input is RuleStepsFormData[RuleStep.aboutRule] => + has('data.name', input); + +export const isScheduleStep = (input: unknown): input is RuleStepsFormData[RuleStep.scheduleRule] => + has('data.interval', input); + +export const isActionsStep = (input: unknown): input is RuleStepsFormData[RuleStep.ruleActions] => + has('data.actions', input); + export interface RuleFields { anomalyThreshold: unknown; machineLearningJobId: unknown; @@ -129,7 +148,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep }; export const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRuleJson => { - const { isNew, ...formatScheduleData } = scheduleData; + const { ...formatScheduleData } = scheduleData; if (!isEmpty(formatScheduleData.interval) && !isEmpty(formatScheduleData.from)) { const { unit: intervalUnit, value: intervalValue } = getTimeTypeValue( formatScheduleData.interval @@ -161,7 +180,6 @@ export const formatAboutStepData = ( threat, isAssociatedToEndpointList, isBuildingBlock, - isNew, note, ruleNameOverride, timestampOverride, @@ -180,11 +198,11 @@ export const formatAboutStepData = ( { id: ENDPOINT_LIST_ID, list_id: ENDPOINT_LIST_ID, - namespace_type: 'agnostic', - type: 'endpoint', + namespace_type: 'agnostic' as NamespaceType, + type: 'endpoint' as ExceptionListType, }, ...detectionExceptionLists, - ] as AboutStepRuleJson['exceptions_list'], + ], } : exceptionsList != null ? { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index 22d84c593b415..48247392dfe7f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -22,32 +22,20 @@ import { displaySuccessToast, useStateToaster } from '../../../../../common/comp import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; import { useUserData } from '../../../../components/user_info'; import { AccordionTitle } from '../../../../components/rules/accordion_title'; -import { FormData, FormHook } from '../../../../../shared_imports'; -import { StepAboutRule } from '../../../../components/rules/step_about_rule'; import { StepDefineRule } from '../../../../components/rules/step_define_rule'; +import { StepAboutRule } from '../../../../components/rules/step_about_rule'; import { StepScheduleRule } from '../../../../components/rules/step_schedule_rule'; import { StepRuleActions } from '../../../../components/rules/step_rule_actions'; import { DetectionEngineHeaderPage } from '../../../../components/detection_engine_header_page'; import * as RuleI18n from '../translations'; import { redirectToDetections, getActionMessageParams, userHasNoPermissions } from '../helpers'; -import { - AboutStepRule, - DefineStepRule, - RuleStep, - RuleStepData, - ScheduleStepRule, - ActionsStepRule, -} from '../types'; -import { formatRule } from './helpers'; +import { RuleStep, RuleStepsFormData, RuleStepsFormHooks } from '../types'; +import { formatRule, stepIsValid } from './helpers'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../../app/types'; +import { ruleStepsOrder } from '../utils'; -const stepsRuleOrder = [ - RuleStep.defineRule, - RuleStep.aboutRule, - RuleStep.scheduleRule, - RuleStep.ruleActions, -]; +const formHookNoop = async (): Promise => undefined; const MyEuiPanel = styled(EuiPanel)<{ zindex?: number; @@ -100,95 +88,137 @@ const CreateRulePageComponent: React.FC = () => { } = useListsConfig(); const loading = userInfoLoading || listsConfigLoading; const [, dispatchToaster] = useStateToaster(); - const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); + const [activeStep, setActiveStep] = useState(RuleStep.defineRule); + const getNextStep = (step: RuleStep): RuleStep | undefined => + ruleStepsOrder[ruleStepsOrder.indexOf(step) + 1]; const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); const scheduleRuleRef = useRef(null); const ruleActionsRef = useRef(null); - const stepsForm = useRef | null>>({ - [RuleStep.defineRule]: null, - [RuleStep.aboutRule]: null, - [RuleStep.scheduleRule]: null, - [RuleStep.ruleActions]: null, + const formHooks = useRef({ + [RuleStep.defineRule]: formHookNoop, + [RuleStep.aboutRule]: formHookNoop, + [RuleStep.scheduleRule]: formHookNoop, + [RuleStep.ruleActions]: formHookNoop, }); - const stepsData = useRef>({ + const setFormHook = useCallback( + (step: K, hook: RuleStepsFormHooks[K]) => { + formHooks.current[step] = hook; + }, + [] + ); + const stepsData = useRef({ [RuleStep.defineRule]: { isValid: false, data: undefined }, [RuleStep.aboutRule]: { isValid: false, data: undefined }, [RuleStep.scheduleRule]: { isValid: false, data: undefined }, [RuleStep.ruleActions]: { isValid: false, data: undefined }, }); - const [isStepRuleInReadOnlyView, setIsStepRuleInEditView] = useState>({ + const setStepData = ( + step: K, + data: RuleStepsFormData[K] + ): void => { + stepsData.current[step] = data; + }; + const [openSteps, setOpenSteps] = useState({ [RuleStep.defineRule]: false, [RuleStep.aboutRule]: false, [RuleStep.scheduleRule]: false, [RuleStep.ruleActions]: false, }); const [{ isLoading, isSaved }, setRule] = useCreateRule(); - const actionMessageParams = useMemo( - () => - getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule)?.ruleType), - // eslint-disable-next-line react-hooks/exhaustive-deps - [stepsData.current['define-rule'].data] - ); + const ruleType = stepsData.current[RuleStep.defineRule].data?.ruleType; + const ruleName = stepsData.current[RuleStep.aboutRule].data?.name; + const actionMessageParams = useMemo(() => getActionMessageParams(ruleType), [ruleType]); const history = useHistory(); - const setStepData = useCallback( - (step: RuleStep, data: unknown, isValid: boolean) => { - stepsData.current[step] = { ...stepsData.current[step], data, isValid }; - if (isValid) { - const stepRuleIdx = stepsRuleOrder.findIndex((item) => step === item); - if ([0, 1, 2].includes(stepRuleIdx)) { - if (isStepRuleInReadOnlyView[stepsRuleOrder[stepRuleIdx + 1]]) { - setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [step]: true, - [stepsRuleOrder[stepRuleIdx + 1]]: false, - }); - } else if (openAccordionId !== stepsRuleOrder[stepRuleIdx + 1]) { - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [step]: true, - }); - openCloseAccordion(stepsRuleOrder[stepRuleIdx + 1]); - setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); + const handleAccordionToggle = useCallback( + (step: RuleStep, isOpen: boolean) => + setOpenSteps((_openSteps) => ({ + ..._openSteps, + [step]: isOpen, + })), + [] + ); + const goToStep = useCallback( + (step: RuleStep) => { + if (ruleStepsOrder.indexOf(step) > ruleStepsOrder.indexOf(activeStep) && !openSteps[step]) { + toggleStepAccordion(step); + } + setActiveStep(step); + }, + [activeStep, openSteps] + ); + + const toggleStepAccordion = (step: RuleStep | null) => { + if (step === RuleStep.defineRule) { + defineRuleRef.current?.onToggle(); + } else if (step === RuleStep.aboutRule) { + aboutRuleRef.current?.onToggle(); + } else if (step === RuleStep.scheduleRule) { + scheduleRuleRef.current?.onToggle(); + } else if (step === RuleStep.ruleActions) { + ruleActionsRef.current?.onToggle(); + } + }; + + const editStep = useCallback( + async (step: RuleStep) => { + const activeStepData = await formHooks.current[activeStep](); + + if (activeStepData?.isValid) { + setStepData(activeStep, activeStepData); + goToStep(step); + } + }, + [activeStep, goToStep] + ); + const submitStep = useCallback( + async (step: RuleStep) => { + const stepData = await formHooks.current[step](); + + if (stepData?.isValid) { + setStepData(step, stepData); + const nextStep = getNextStep(step); + + if (nextStep != null) { + goToStep(nextStep); + } else { + const defineStep = await stepsData.current[RuleStep.defineRule]; + const aboutStep = await stepsData.current[RuleStep.aboutRule]; + const scheduleStep = await stepsData.current[RuleStep.scheduleRule]; + const actionsStep = await stepsData.current[RuleStep.ruleActions]; + + if ( + stepIsValid(defineStep) && + stepIsValid(aboutStep) && + stepIsValid(scheduleStep) && + stepIsValid(actionsStep) + ) { + setRule( + formatRule( + defineStep.data, + aboutStep.data, + scheduleStep.data, + actionsStep.data + ) + ); } - } else if ( - stepRuleIdx === 3 && - stepsData.current[RuleStep.defineRule].isValid && - stepsData.current[RuleStep.aboutRule].isValid && - stepsData.current[RuleStep.scheduleRule].isValid - ) { - setRule( - formatRule( - stepsData.current[RuleStep.defineRule].data as DefineStepRule, - stepsData.current[RuleStep.aboutRule].data as AboutStepRule, - stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule, - stepsData.current[RuleStep.ruleActions].data as ActionsStepRule - ) - ); } } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [isStepRuleInReadOnlyView, openAccordionId, stepsData.current, setRule] + [goToStep, setRule] ); - const setStepsForm = useCallback((step: RuleStep, form: FormHook) => { - stepsForm.current[step] = form; - }, []); - const getAccordionType = useCallback( - (accordionId: RuleStep) => { - if (accordionId === openAccordionId) { + (step: RuleStep) => { + if (step === activeStep) { return 'active'; - } else if (stepsData.current[accordionId].isValid) { + } else if (stepsData.current[step].isValid) { return 'valid'; } return 'passive'; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [openAccordionId, stepsData.current] + [activeStep] ); const defineRuleButton = ( @@ -198,7 +228,6 @@ const CreateRulePageComponent: React.FC = () => { type={getAccordionType(RuleStep.defineRule)} /> ); - const aboutRuleButton = ( { type={getAccordionType(RuleStep.aboutRule)} /> ); - const scheduleRuleButton = ( { type={getAccordionType(RuleStep.scheduleRule)} /> ); - const ruleActionsButton = ( { /> ); - const openCloseAccordion = (accordionId: RuleStep | null) => { - if (accordionId != null) { - if (accordionId === RuleStep.defineRule && defineRuleRef.current != null) { - defineRuleRef.current.onToggle(); - } else if (accordionId === RuleStep.aboutRule && aboutRuleRef.current != null) { - aboutRuleRef.current.onToggle(); - } else if (accordionId === RuleStep.scheduleRule && scheduleRuleRef.current != null) { - scheduleRuleRef.current.onToggle(); - } else if (accordionId === RuleStep.ruleActions && ruleActionsRef.current != null) { - ruleActionsRef.current.onToggle(); - } - } - }; - - const manageAccordions = useCallback( - (id: RuleStep, isOpen: boolean) => { - const activeRuleIdx = stepsRuleOrder.findIndex((step) => step === openAccordionId); - const stepRuleIdx = stepsRuleOrder.findIndex((step) => step === id); - - if ((id === openAccordionId || stepRuleIdx < activeRuleIdx) && !isOpen) { - openCloseAccordion(id); - } else if (stepRuleIdx >= activeRuleIdx) { - if ( - openAccordionId !== id && - !stepsData.current[openAccordionId].isValid && - !isStepRuleInReadOnlyView[id] && - isOpen - ) { - openCloseAccordion(id); - } - } - }, - [isStepRuleInReadOnlyView, openAccordionId, stepsData] - ); - - const manageIsEditable = useCallback( - async (id: RuleStep) => { - const activeForm = await stepsForm.current[openAccordionId]?.submit(); - if (activeForm != null && activeForm?.isValid) { - stepsData.current[openAccordionId] = { - ...stepsData.current[openAccordionId], - data: activeForm.data, - isValid: activeForm.isValid, - }; - setOpenAccordionId(id); - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [openAccordionId]: true, - [id]: false, - }); - } - }, - [isStepRuleInReadOnlyView, openAccordionId] - ); - - if (isSaved) { - const ruleName = (stepsData.current[RuleStep.aboutRule].data as AboutStepRule).name; + if (isSaved && ruleName) { displaySuccessToast(i18n.SUCCESSFULLY_CREATED_RULES(ruleName), dispatchToaster); history.replace(getRulesUrl()); return null; @@ -320,13 +291,14 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={defineRuleButton} paddingSize="xs" ref={defineRuleRef} - onToggle={manageAccordions.bind(null, RuleStep.defineRule)} + onToggle={handleAccordionToggle.bind(null, RuleStep.defineRule)} extraAction={ stepsData.current[RuleStep.defineRule].isValid && ( editStep(RuleStep.defineRule)} > {i18n.EDIT_RULE} @@ -336,11 +308,11 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.defineRule)} descriptionColumns="singleSplit" /> @@ -353,13 +325,14 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={aboutRuleButton} paddingSize="xs" ref={aboutRuleRef} - onToggle={manageAccordions.bind(null, RuleStep.aboutRule)} + onToggle={handleAccordionToggle.bind(null, RuleStep.aboutRule)} extraAction={ stepsData.current[RuleStep.aboutRule].isValid && ( editStep(RuleStep.aboutRule)} > {i18n.EDIT_RULE} @@ -369,13 +342,13 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.aboutRule)} /> @@ -387,13 +360,13 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={scheduleRuleButton} paddingSize="xs" ref={scheduleRuleRef} - onToggle={manageAccordions.bind(null, RuleStep.scheduleRule)} + onToggle={handleAccordionToggle.bind(null, RuleStep.scheduleRule)} extraAction={ stepsData.current[RuleStep.scheduleRule].isValid && ( editStep(RuleStep.scheduleRule)} > {i18n.EDIT_RULE} @@ -403,12 +376,12 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.scheduleRule)} /> @@ -420,13 +393,13 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={ruleActionsButton} paddingSize="xs" ref={ruleActionsRef} - onToggle={manageAccordions.bind(null, RuleStep.ruleActions)} + onToggle={handleAccordionToggle.bind(null, RuleStep.ruleActions)} extraAction={ stepsData.current[RuleStep.ruleActions].isValid && ( editStep(RuleStep.ruleActions)} > {i18n.EDIT_RULE} @@ -436,10 +409,11 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.ruleActions)} actionMessageParams={actionMessageParams} /> diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx index b251b2eba10ae..5f4fd59669103 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -17,6 +17,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams, useHistory } from 'react-router-dom'; +import { UpdateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; import { useRule, useUpdateRule } from '../../../../containers/detection_engine/rules'; import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { WrapperPage } from '../../../../../common/components/wrapper_page'; @@ -28,13 +29,19 @@ import { displaySuccessToast, useStateToaster } from '../../../../../common/comp import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; import { useUserData } from '../../../../components/user_info'; import { DetectionEngineHeaderPage } from '../../../../components/detection_engine_header_page'; -import { FormHook, FormData } from '../../../../../shared_imports'; import { StepPanel } from '../../../../components/rules/step_panel'; import { StepAboutRule } from '../../../../components/rules/step_about_rule'; import { StepDefineRule } from '../../../../components/rules/step_define_rule'; import { StepScheduleRule } from '../../../../components/rules/step_schedule_rule'; import { StepRuleActions } from '../../../../components/rules/step_rule_actions'; -import { formatRule } from '../create/helpers'; +import { + formatRule, + stepIsValid, + isDefineStep, + isAboutStep, + isScheduleStep, + isActionsStep, +} from '../create/helpers'; import { getStepsData, redirectToDetections, @@ -42,33 +49,12 @@ import { userHasNoPermissions, } from '../helpers'; import * as ruleI18n from '../translations'; -import { - RuleStep, - DefineStepRule, - AboutStepRule, - ScheduleStepRule, - ActionsStepRule, -} from '../types'; +import { RuleStep, RuleStepsFormHooks, RuleStepsFormData, RuleStepsData } from '../types'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../../app/types'; -import { UpdateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; - -interface StepRuleForm { - isValid: boolean; -} -interface AboutStepRuleForm extends StepRuleForm { - data: AboutStepRule | null; -} -interface DefineStepRuleForm extends StepRuleForm { - data: DefineStepRule | null; -} -interface ScheduleStepRuleForm extends StepRuleForm { - data: ScheduleStepRule | null; -} +import { ruleStepsOrder } from '../utils'; -interface ActionsStepRuleForm extends StepRuleForm { - data: ActionsStepRule | null; -} +const formHookNoop = async (): Promise => undefined; const EditRulePageComponent: FC = () => { const history = useHistory(); @@ -86,49 +72,49 @@ const EditRulePageComponent: FC = () => { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration, } = useListsConfig(); - const initLoading = userInfoLoading || listsConfigLoading; const { detailName: ruleId } = useParams<{ detailName: string | undefined }>(); - const [loading, rule] = useRule(ruleId); + const [ruleLoading, rule] = useRule(ruleId); + const loading = ruleLoading || userInfoLoading || listsConfigLoading; - const [initForm, setInitForm] = useState(false); - const [myAboutRuleForm, setMyAboutRuleForm] = useState({ - data: null, - isValid: false, - }); - const [myDefineRuleForm, setMyDefineRuleForm] = useState({ - data: null, - isValid: false, + const formHooks = useRef({ + [RuleStep.defineRule]: formHookNoop, + [RuleStep.aboutRule]: formHookNoop, + [RuleStep.scheduleRule]: formHookNoop, + [RuleStep.ruleActions]: formHookNoop, }); - const [myScheduleRuleForm, setMyScheduleRuleForm] = useState({ - data: null, - isValid: false, + const stepsData = useRef({ + [RuleStep.defineRule]: { isValid: false, data: undefined }, + [RuleStep.aboutRule]: { isValid: false, data: undefined }, + [RuleStep.scheduleRule]: { isValid: false, data: undefined }, + [RuleStep.ruleActions]: { isValid: false, data: undefined }, }); - const [myActionsRuleForm, setMyActionsRuleForm] = useState({ - data: null, - isValid: false, - }); - const [selectedTab, setSelectedTab] = useState(); - const stepsForm = useRef | null>>({ - [RuleStep.defineRule]: null, - [RuleStep.aboutRule]: null, - [RuleStep.scheduleRule]: null, - [RuleStep.ruleActions]: null, + const defineStep = stepsData.current[RuleStep.defineRule]; + const aboutStep = stepsData.current[RuleStep.aboutRule]; + const scheduleStep = stepsData.current[RuleStep.scheduleRule]; + const actionsStep = stepsData.current[RuleStep.ruleActions]; + const [activeStep, setActiveStep] = useState(RuleStep.defineRule); + const invalidSteps = ruleStepsOrder.filter((step) => { + const stepData = stepsData.current[step]; + return stepData.data != null && !stepIsValid(stepData); }); const [{ isLoading, isSaved }, setRule] = useUpdateRule(); - const [tabHasError, setTabHasError] = useState([]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule]); - const setStepsForm = useCallback( - (step: RuleStep, form: FormHook) => { - stepsForm.current[step] = form; - if (initForm && step === (selectedTab?.id as RuleStep) && form.isSubmitted === false) { - setInitForm(false); - form.submit(); + const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule?.type]); + const setFormHook = useCallback( + (step: K, hook: RuleStepsFormHooks[K]) => { + formHooks.current[step] = hook; + if (step === activeStep) { + hook(); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [initForm, selectedTab] + [activeStep] + ); + const setStepData = useCallback( + (step: K, data: RuleStepsData[K], isValid: boolean) => { + stepsData.current[step] = { ...stepsData.current[step], data, isValid }; + }, + [] ); + const tabs = useMemo( () => [ { @@ -138,14 +124,14 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myDefineRuleForm.data != null && ( + + {defineStep.data != null && ( )} @@ -160,15 +146,15 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myAboutRuleForm.data != null && myDefineRuleForm.data != null && ( + + {aboutStep.data != null && defineStep.data != null && ( )} @@ -183,14 +169,14 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myScheduleRuleForm.data != null && ( + + {scheduleStep.data != null && ( )} @@ -204,14 +190,14 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myActionsRuleForm.data != null && ( + + {actionsStep.data != null && ( )} @@ -221,76 +207,56 @@ const EditRulePageComponent: FC = () => { ), }, ], - // eslint-disable-next-line react-hooks/exhaustive-deps [ - rule, + rule?.immutable, loading, - initLoading, + defineStep.data, isLoading, - myAboutRuleForm, - myDefineRuleForm, - myScheduleRuleForm, - myActionsRuleForm, - setStepsForm, - stepsForm, + setFormHook, + aboutStep.data, + scheduleStep.data, + actionsStep.data, actionMessageParams, ] ); const onSubmit = useCallback(async () => { - const activeFormId = selectedTab?.id as RuleStep; - const activeForm = await stepsForm.current[activeFormId]?.submit(); - - const invalidForms = [ - RuleStep.aboutRule, - RuleStep.defineRule, - RuleStep.scheduleRule, - RuleStep.ruleActions, - ].reduce((acc, step) => { - if ( - (step === activeFormId && activeForm != null && !activeForm?.isValid) || - (step === RuleStep.aboutRule && !myAboutRuleForm.isValid) || - (step === RuleStep.defineRule && !myDefineRuleForm.isValid) || - (step === RuleStep.scheduleRule && !myScheduleRuleForm.isValid) || - (step === RuleStep.ruleActions && !myActionsRuleForm.isValid) - ) { - return [...acc, step]; - } - return acc; - }, []); + const activeStepData = await formHooks.current[activeStep](); + if (activeStepData?.data != null) { + setStepData(activeStep, activeStepData.data, activeStepData.isValid); + } + const define = isDefineStep(activeStepData) ? activeStepData : defineStep; + const about = isAboutStep(activeStepData) ? activeStepData : aboutStep; + const schedule = isScheduleStep(activeStepData) ? activeStepData : scheduleStep; + const actions = isActionsStep(activeStepData) ? activeStepData : actionsStep; - if (invalidForms.length === 0 && activeForm != null) { - setTabHasError([]); + if ( + stepIsValid(define) && + stepIsValid(about) && + stepIsValid(schedule) && + stepIsValid(actions) + ) { setRule({ ...formatRule( - (activeFormId === RuleStep.defineRule - ? activeForm.data - : myDefineRuleForm.data) as DefineStepRule, - (activeFormId === RuleStep.aboutRule - ? activeForm.data - : myAboutRuleForm.data) as AboutStepRule, - (activeFormId === RuleStep.scheduleRule - ? activeForm.data - : myScheduleRuleForm.data) as ScheduleStepRule, - (activeFormId === RuleStep.ruleActions - ? activeForm.data - : myActionsRuleForm.data) as ActionsStepRule, + define.data, + about.data, + schedule.data, + actions.data, rule ), ...(ruleId ? { id: ruleId } : {}), }); - } else { - setTabHasError(invalidForms); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - stepsForm, - myAboutRuleForm, - myDefineRuleForm, - myScheduleRuleForm, - myActionsRuleForm, - selectedTab, + aboutStep, + actionsStep, + activeStep, + defineStep, + rule, ruleId, + scheduleStep, + setRule, + setStepData, ]); useEffect(() => { @@ -298,48 +264,29 @@ const EditRulePageComponent: FC = () => { const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ rule, }); - setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); - setMyDefineRuleForm({ data: defineRuleData, isValid: true }); - setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); - setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); + setStepData(RuleStep.defineRule, defineRuleData, true); + setStepData(RuleStep.aboutRule, aboutRuleData, true); + setStepData(RuleStep.scheduleRule, scheduleRuleData, true); + setStepData(RuleStep.ruleActions, ruleActionsData, true); } - }, [rule]); + }, [rule, setStepData]); + + const goToStep = useCallback(async (step: RuleStep) => { + setActiveStep(step); + await formHooks.current[step](); + }, []); const onTabClick = useCallback( async (tab: EuiTabbedContentTab) => { - if (selectedTab != null) { - const ruleStep = selectedTab.id as RuleStep; - const respForm = await stepsForm.current[ruleStep]?.submit(); + const targetStep = tab.id as RuleStep; + const activeStepData = await formHooks.current[activeStep](); - if (respForm != null) { - if (ruleStep === RuleStep.aboutRule) { - setMyAboutRuleForm({ - data: respForm.data as AboutStepRule, - isValid: respForm.isValid, - }); - } else if (ruleStep === RuleStep.defineRule) { - setMyDefineRuleForm({ - data: respForm.data as DefineStepRule, - isValid: respForm.isValid, - }); - } else if (ruleStep === RuleStep.scheduleRule) { - setMyScheduleRuleForm({ - data: respForm.data as ScheduleStepRule, - isValid: respForm.isValid, - }); - } else if (ruleStep === RuleStep.ruleActions) { - setMyActionsRuleForm({ - data: respForm.data as ActionsStepRule, - isValid: respForm.isValid, - }); - } - } + if (activeStepData?.data != null) { + setStepData(activeStep, activeStepData.data, activeStepData.isValid); + goToStep(targetStep); } - setInitForm(true); - setSelectedTab(tab); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedTab, stepsForm.current] + [activeStep, goToStep, setStepData] ); const goToDetailsRule = useCallback( @@ -351,23 +298,13 @@ const EditRulePageComponent: FC = () => { ); useEffect(() => { - if (rule != null) { - const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ - rule, - }); - setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); - setMyDefineRuleForm({ data: defineRuleData, isValid: true }); - setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); - setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); + if (rule?.immutable) { + setActiveStep(RuleStep.ruleActions); + } else { + setActiveStep(RuleStep.defineRule); } }, [rule]); - useEffect(() => { - const tabIndex = rule?.immutable ? 3 : 0; - setSelectedTab(tabs[tabIndex]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rule]); - if (isSaved) { displaySuccessToast(i18n.SUCCESSFULLY_SAVED_RULE(rule?.name ?? ''), dispatchToaster); history.replace(getRuleDetailsUrl(ruleId ?? '')); @@ -401,14 +338,14 @@ const EditRulePageComponent: FC = () => { isLoading={isLoading} title={i18n.PAGE_TITLE} /> - {tabHasError.length > 0 && ( + {invalidSteps.length > 0 && ( { if (t === RuleStep.aboutRule) { return ruleI18n.ABOUT; @@ -429,7 +366,7 @@ const EditRulePageComponent: FC = () => { t.id === selectedTab?.id)} + selectedTab={tabs.find((t) => t.id === activeStep)} onTabClick={onTabClick} tabs={tabs} /> @@ -454,7 +391,7 @@ const EditRulePageComponent: FC = () => { onClick={onSubmit} iconType="save" isLoading={isLoading} - isDisabled={initLoading} + isDisabled={loading} > {i18n.SAVE_CHANGES} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx index 10a20807d6f87..f11b0ac4ec3f8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx @@ -43,7 +43,6 @@ describe('rule helpers', () => { rule: mockRuleWithEverything('test-id'), }); const defineRuleStepData = { - isNew: false, ruleType: 'saved_query', anomalyThreshold: 50, index: ['auditbeat-*'], @@ -93,7 +92,6 @@ describe('rule helpers', () => { falsePositives: ['test'], isAssociatedToEndpointList: false, isBuildingBlock: false, - isNew: false, license: 'Elastic License', name: 'Query with rule-id', note: '# this is some markdown documentation', @@ -121,11 +119,10 @@ describe('rule helpers', () => { ], timestampOverride: 'event.ingested', }; - const scheduleRuleStepData = { from: '0s', interval: '5m', isNew: false }; + const scheduleRuleStepData = { from: '0s', interval: '5m' }; const ruleActionsStepData = { enabled: true, throttle: 'no_actions', - isNew: false, actions: [], }; const aboutRuleDataDetailsData = { @@ -202,7 +199,6 @@ describe('rule helpers', () => { test('returns with saved_id if value exists on rule', () => { const result: DefineStepRule = getDefineStepsData(mockRule('test-id')); const expected = { - isNew: false, ruleType: 'saved_query', anomalyThreshold: 50, machineLearningJobId: '', @@ -235,7 +231,6 @@ describe('rule helpers', () => { delete mockedRule.saved_id; const result: DefineStepRule = getDefineStepsData(mockedRule); const expected = { - isNew: false, ruleType: 'saved_query', anomalyThreshold: 50, machineLearningJobId: '', @@ -311,7 +306,6 @@ describe('rule helpers', () => { }; const result: ScheduleStepRule = getScheduleStepsData(mockedRule); const expected = { - isNew: false, interval: mockedRule.interval, from: '0s', }; @@ -344,7 +338,6 @@ describe('rule helpers', () => { }, ], enabled: mockedRule.enabled, - isNew: false, throttle: 'no_actions', }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index 8178f5ae5ba1d..aab73c5d5a1e5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -27,6 +27,7 @@ import { import { SeverityMapping, Type, + Severity, } from '../../../../../common/detection_engine/schemas/common/schemas'; import { severityOptions } from '../../../components/rules/step_about_rule/data'; @@ -67,7 +68,6 @@ export const getActionsStepsData = ( return { actions: actions?.map(transformRuleToAlertAction), - isNew: false, throttle, kibanaSiemAppUrl: meta?.kibana_siem_app_url, enabled, @@ -75,7 +75,6 @@ export const getActionsStepsData = ( }; export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ - isNew: false, ruleType: rule.type, anomalyThreshold: rule.anomaly_threshold ?? 50, machineLearningJobId: rule.machine_learning_job_id ?? '', @@ -100,7 +99,6 @@ export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => { const fromHumanizedValue = getHumanizedDuration(from, interval); return { - isNew: false, interval, from: fromHumanizedValue, }; @@ -142,7 +140,6 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu } = rule; return { - isNew: false, author, isAssociatedToEndpointList: exceptionsList?.some(({ id }) => id === ENDPOINT_LIST_ID) ?? false, isBuildingBlock: buildingBlockType !== undefined, @@ -154,7 +151,7 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu note: note!, references, severity: { - value: severity, + value: severity as Severity, mapping: fillEmptySeverityMappings(severityMapping), isMappingChecked: severityMapping.length > 0, }, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts index a7603051add49..e3d0ea123872a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts @@ -7,7 +7,6 @@ import { RuleAlertAction } from '../../../../../common/detection_engine/types'; import { AlertAction } from '../../../../../../alerts/common'; import { Filter } from '../../../../../../../../src/plugins/data/common'; -import { FormData, FormHook } from '../../../../shared_imports'; import { FieldValueQueryBar } from '../../../components/rules/query_bar'; import { FieldValueTimeline } from '../../../components/rules/pick_timeline'; import { FieldValueThreshold } from '../../../components/rules/threshold_input'; @@ -21,6 +20,7 @@ import { SortOrder, TimestampOverride, Type, + Severity, } from '../../../../../common/detection_engine/schemas/common/schemas'; import { List } from '../../../../../common/detection_engine/schemas/types'; @@ -37,34 +37,51 @@ export interface EuiBasicTableOnChange { sort?: EuiBasicTableSortTypes; } +export type RuleStatusType = 'passive' | 'active' | 'valid'; + export enum RuleStep { defineRule = 'define-rule', aboutRule = 'about-rule', scheduleRule = 'schedule-rule', ruleActions = 'rule-actions', } -export type RuleStatusType = 'passive' | 'active' | 'valid'; +export type RuleStepsOrder = [ + RuleStep.defineRule, + RuleStep.aboutRule, + RuleStep.scheduleRule, + RuleStep.ruleActions +]; -export interface RuleStepData { - data: unknown; - isValid: boolean; +export interface RuleStepsData { + [RuleStep.defineRule]: DefineStepRule; + [RuleStep.aboutRule]: AboutStepRule; + [RuleStep.scheduleRule]: ScheduleStepRule; + [RuleStep.ruleActions]: ActionsStepRule; } +export type RuleStepsFormData = { + [K in keyof RuleStepsData]: { + data: RuleStepsData[K] | undefined; + isValid: boolean; + }; +}; + +export type RuleStepsFormHooks = { + [K in keyof RuleStepsData]: () => Promise; +}; + export interface RuleStepProps { addPadding?: boolean; descriptionColumns?: 'multi' | 'single' | 'singleSplit'; - setStepData?: (step: RuleStep, data: unknown, isValid: boolean) => void; isReadOnlyView: boolean; isUpdateView?: boolean; isLoading: boolean; + onSubmit?: () => void; resizeParentContainer?: (height: number) => void; - setForm?: (step: RuleStep, form: FormHook) => void; + setForm?: (step: K, hook: RuleStepsFormHooks[K]) => void; } -interface StepRuleData { - isNew: boolean; -} -export interface AboutStepRule extends StepRuleData { +export interface AboutStepRule { author: string[]; name: string; description: string; @@ -88,7 +105,7 @@ export interface AboutStepRuleDetails { } export interface AboutStepSeverity { - value: string; + value: Severity; mapping: SeverityMapping; isMappingChecked: boolean; } @@ -99,7 +116,7 @@ export interface AboutStepRiskScore { isMappingChecked: boolean; } -export interface DefineStepRule extends StepRuleData { +export interface DefineStepRule { anomalyThreshold: number; index: string[]; machineLearningJobId: string; @@ -109,13 +126,13 @@ export interface DefineStepRule extends StepRuleData { threshold: FieldValueThreshold; } -export interface ScheduleStepRule extends StepRuleData { +export interface ScheduleStepRule { interval: string; from: string; to?: string; } -export interface ActionsStepRule extends StepRuleData { +export interface ActionsStepRule { actions: AlertAction[]; enabled: boolean; kibanaSiemAppUrl?: string; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts index f862a06807e6f..890746838b0d0 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts @@ -20,6 +20,14 @@ import { RouteSpyState } from '../../../../common/utils/route/types'; import { GetUrlForApp } from '../../../../common/components/navigation/types'; import { SecurityPageName } from '../../../../app/types'; import { APP_ID } from '../../../../../common/constants'; +import { RuleStep, RuleStepsOrder } from './types'; + +export const ruleStepsOrder: RuleStepsOrder = [ + RuleStep.defineRule, + RuleStep.aboutRule, + RuleStep.scheduleRule, + RuleStep.ruleActions, +]; const getTabBreadcrumb = (pathname: string, search: string[], getUrlForApp: GetUrlForApp) => { const tabPath = pathname.split('/')[1]; diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 097166a9c866a..08e9fb854e5a2 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -21,6 +21,7 @@ export { UseMultiFields, useForm, useFormContext, + useFormData, ValidationFunc, VALIDATION_TYPES, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';