Skip to content

Commit

Permalink
[Response Ops] [Rule Form] Add Show Request and Add Action screens to…
Browse files Browse the repository at this point in the history
… flyout (elastic#206154)

## Summary

Part of elastic#195211

- Adds Show Request screen to the new rule form flyout

<details>
<summary>Screenshot</summary>
<img width="585" alt="Screenshot 2025-01-10 at 1 30 15 PM"
src="https://github.com/user-attachments/assets/72500b0d-d959-4d17-944e-a7dc0894fb98"
/>
</details>

- Renders the action connectors UI within the flyout instead of opening
a modal
 
<details>
<summary>Screenshot</summary>
<img width="505" alt="Screenshot 2025-01-10 at 1 28 38 PM"
src="https://github.com/user-attachments/assets/b5b464c0-7359-43ab-bea1-93d2981a5794"
/>
</details>

- Duplicates the dropdown filter design from the flyout UI within the
action connectors modal when displayed on a smaller screen

<details>
<summary>Screenshot</summary>
<img width="809" alt="Screenshot 2025-01-10 at 1 30 28 PM"
src="https://github.com/user-attachments/assets/5ef28458-1b6d-4a29-961d-fbcc1640e706"
/>
</details>

### Implementation notes

In order to get the action connectors UI to render the same way in both
a modal and the flyout, without duplicating a large amount of code, I
had to introduce a little bit of complexity. Within the Rule Page, it's
as simple as opening the UI inside a modal, but the flyout cannot open a
second flyout; it has to know when and how to completely replace its own
contents.

- The bulk of the action connectors UI is now moved to
`<RuleActionsConnectorsBody>`. `<RuleActionsConnectorsModal>` and
`<RuleFlyoutSelectConnector>` act as wrappers for this component.
- The `<RuleActions>` step no longer handles rendering the connector UI,
because it's not at a high enough level to know if it's in the
`<RulePage>` or the `<RuleFlyout>`. Instead, it simply sends a signal up
the context hierarchy to `setIsConnectorsScreenVisible`.
- A new context called `RuleFormScreenContext` keeps track of
`isConnectorsScreenVisible`, a state for whether or not the action
connectors "screen" is open, regardless of whether that screen is
displayed in a modal or a flyout.
- The Rule Page uses `isConnectorsScreenVisible` to determine whether to
render the modal. This works the same way as it used to, but handled by
the `<RulePage>` instead of the `<RuleActions>` component.
- The Rule Flyout uses `isConnectorsScreenVisible` to determine whether
to continue to render `<RuleFlyoutBody>` or to completely replace its
contents with `<RuleFlyoutSelectConnector>`

For consistency, this PR also moves the Show Request modal/flyout screen
into the same system.

### Testing

To test the new flyout, edit
`packages/response-ops/rule_form/src/create_rule_form.tsx` and
`packages/response-ops/rule_form/src/edit_rule_form.tsx` so that they
render `<RuleFlyout>` instead of `<RulePage>`.

<details>
<summary><strong>Use this diff block</strong></summary>

```diff
diff --git a/packages/response-ops/rule_form/src/create_rule_form.tsx b/packages/response-ops/rule_form/src/create_rule_form.tsx
index 2f5e0472dcd..564744b96ec 100644
--- a/packages/response-ops/rule_form/src/create_rule_form.tsx
+++ b/packages/response-ops/rule_form/src/create_rule_form.tsx
@@ -31,6 +31,7 @@ import {
   parseRuleCircuitBreakerErrorMessage,
 } from './utils';
 import { RULE_CREATE_SUCCESS_TEXT, RULE_CREATE_ERROR_TEXT } from './translations';
+import { RuleFlyout } from './rule_flyout';
 
 export interface CreateRuleFormProps {
   ruleTypeId: string;
@@ -199,7 +200,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => {
           }),
         }}
       >
-        <RulePage isEdit={false} isSaving={isSaving} onCancel={onCancel} onSave={onSave} />
+        <RuleFlyout isEdit={false} isSaving={isSaving} onCancel={onCancel} onSave={onSave} />
       </RuleFormStateProvider>
     </div>
   );
diff --git a/packages/response-ops/rule_form/src/edit_rule_form.tsx b/packages/response-ops/rule_form/src/edit_rule_form.tsx
index 392447114ed..41aecd7245a 100644
--- a/packages/response-ops/rule_form/src/edit_rule_form.tsx
+++ b/packages/response-ops/rule_form/src/edit_rule_form.tsx
@@ -26,6 +26,7 @@ import {
 import { RULE_EDIT_ERROR_TEXT, RULE_EDIT_SUCCESS_TEXT } from './translations';
 import { getAvailableRuleTypes, parseRuleCircuitBreakerErrorMessage } from './utils';
 import { DEFAULT_VALID_CONSUMERS, getDefaultFormData } from './constants';
+import { RuleFlyout } from './rule_flyout';
 
 export interface EditRuleFormProps {
   id: string;
@@ -193,7 +194,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => {
           showMustacheAutocompleteSwitch,
         }}
       >
-        <RulePage isEdit={true} isSaving={isSaving} onSave={onSave} onCancel={onCancel} />
+        <RuleFlyout isEdit={true} isSaving={isSaving} onSave={onSave} onCancel={onCancel} />
       </RuleFormStateProvider>
     </div>
   );
```

</details>

### Still Todo

1. Replace all instances of the v1 rule flyout with this new one (it's
used heavily in solutions, not in Stack Management)

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Elastic Machine <[email protected]>
  • Loading branch information
2 people authored and viduni94 committed Jan 23, 2025
1 parent cce5af5 commit 8f39378
Show file tree
Hide file tree
Showing 26 changed files with 1,124 additions and 647 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
export * from './use_rule_form_dispatch';
export * from './use_rule_form_state';
export * from './use_rule_form_steps';
export * from './use_rule_form_screen_context';
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* 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 { useContext } from 'react';
import { RuleFormScreenContext } from '../rule_form_screen_context';

export const useRuleFormScreenContext = () => {
return useContext(RuleFormScreenContext);
};
Original file line number Diff line number Diff line change
Expand Up @@ -149,27 +149,15 @@ const useCommonRuleFormSteps = ({
? {
title: RULE_FORM_PAGE_RULE_ACTIONS_TITLE,
status: actionsStatus,
children: (
<>
<RuleActions />
<EuiSpacer />
<EuiHorizontalRule margin="none" />
</>
),
children: <RuleActions />,
}
: null,
[RuleFormStepId.DETAILS]: {
title: shortTitles
? RULE_FORM_PAGE_RULE_DETAILS_TITLE_SHORT
: RULE_FORM_PAGE_RULE_DETAILS_TITLE,
status: ruleDetailsStatus,
children: (
<>
<RuleDetails />
<EuiSpacer />
<EuiHorizontalRule margin="none" />
</>
),
children: <RuleDetails />,
},
}),
[ruleDefinitionStatus, canReadConnectors, actionsStatus, ruleDetailsStatus, shortTitles]
Expand Down Expand Up @@ -210,7 +198,7 @@ export const useRuleFormSteps: () => RuleFormVerticalSteps = () => {

const mappedSteps = useMemo(() => {
return stepOrder
.map((stepId) => {
.map((stepId, index) => {
const step = steps[stepId];
return step
? {
Expand All @@ -227,6 +215,12 @@ export const useRuleFormSteps: () => RuleFormVerticalSteps = () => {
stepId={stepId}
>
{step.children}
{index > 0 && (
<>
<EuiSpacer />
<EuiHorizontalRule margin="none" />
</>
)}
</ReportOnBlur>
),
}
Expand All @@ -246,8 +240,10 @@ interface RuleFormHorizontalSteps {
hasNextStep: boolean;
hasPreviousStep: boolean;
}
export const useRuleFormHorizontalSteps: () => RuleFormHorizontalSteps = () => {
const [currentStep, setCurrentStep] = useState<RuleFormStepId>(STEP_ORDER[0]);
export const useRuleFormHorizontalSteps: (
initialStep?: RuleFormStepId
) => RuleFormHorizontalSteps = (initialStep = STEP_ORDER[0]) => {
const [currentStep, setCurrentStep] = useState<RuleFormStepId>(initialStep);
const [touchedSteps, setTouchedSteps] = useState<Record<RuleFormStepId, boolean>>(
STEP_ORDER.reduce(
(result, stepId) => ({ ...result, [stepId]: false }),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* 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".
*/

export * from './request_code_block';
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* 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 { omit, pick } from 'lodash';
import React, { useMemo } from 'react';
import { EuiCodeBlock } from '@elastic/eui';
import {
CreateRuleBody,
UPDATE_FIELDS_WITH_ACTIONS,
UpdateRuleBody,
transformCreateRuleBody,
transformUpdateRuleBody,
} from '../common/apis';
import { BASE_ALERTING_API_PATH } from '../constants';
import { useRuleFormState } from '../hooks';
import { SHOW_REQUEST_MODAL_ERROR } from '../translations';
import { RuleFormData } from '../types';

const stringifyBodyRequest = ({
formData,
isEdit,
}: {
formData: RuleFormData;
isEdit: boolean;
}): string => {
try {
const request = isEdit
? transformUpdateRuleBody(pick(formData, UPDATE_FIELDS_WITH_ACTIONS) as UpdateRuleBody)
: transformCreateRuleBody(omit(formData, 'id') as CreateRuleBody);
return JSON.stringify(request, null, 2);
} catch {
return SHOW_REQUEST_MODAL_ERROR;
}
};

interface RequestCodeBlockProps {
isEdit: boolean;
'data-test-subj'?: string;
}
export const RequestCodeBlock = (props: RequestCodeBlockProps) => {
const { isEdit, 'data-test-subj': dataTestSubj } = props;
const { formData, id, multiConsumerSelection } = useRuleFormState();

const formattedRequest = useMemo(() => {
return stringifyBodyRequest({
formData: {
...formData,
...(multiConsumerSelection ? { consumer: multiConsumerSelection } : {}),
},
isEdit,
});
}, [formData, isEdit, multiConsumerSelection]);

return (
<EuiCodeBlock language="json" isCopyable data-test-subj={dataTestSubj}>
{`${isEdit ? 'PUT' : 'POST'} kbn:${BASE_ALERTING_API_PATH}/rule${
isEdit ? `/${id}` : ''
}\n${formattedRequest}`}
</EuiCodeBlock>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const http = httpServiceMock.createStartContract();
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
useRuleFormScreenContext: jest.fn(),
}));

jest.mock('./rule_actions_system_actions_item', () => ({
Expand Down Expand Up @@ -94,7 +95,8 @@ const mockValidate = jest.fn().mockResolvedValue({
errors: {},
});

const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
const { useRuleFormState, useRuleFormDispatch, useRuleFormScreenContext } =
jest.requireMock('../hooks');
const { useLoadConnectors, useLoadConnectorTypes, useLoadRuleTypeAadTemplateField } =
jest.requireMock('../common/hooks');

Expand All @@ -109,6 +111,7 @@ const mockActions = [getAction('1'), getAction('2')];
const mockSystemActions = [getSystemAction('3')];

const mockOnChange = jest.fn();
const mockSetIsConnectorsScreenVisible = jest.fn();

describe('ruleActions', () => {
beforeEach(() => {
Expand Down Expand Up @@ -167,6 +170,9 @@ describe('ruleActions', () => {
aadTemplateFields: [],
});
useRuleFormDispatch.mockReturnValue(mockOnChange);
useRuleFormScreenContext.mockReturnValue({
setIsConnectorsScreenVisible: mockSetIsConnectorsScreenVisible,
});
});

afterEach(() => {
Expand Down Expand Up @@ -216,29 +222,7 @@ describe('ruleActions', () => {
render(<RuleActions />);

await userEvent.click(screen.getByTestId('ruleActionsAddActionButton'));
expect(screen.getByText('RuleActionsConnectorsModal')).toBeInTheDocument();
});

test('should call onSelectConnector with the correct parameters', async () => {
render(<RuleActions />);

await userEvent.click(screen.getByTestId('ruleActionsAddActionButton'));
expect(screen.getByText('RuleActionsConnectorsModal')).toBeInTheDocument();

await userEvent.click(screen.getByText('select connector'));
expect(mockOnChange).toHaveBeenCalledWith({
payload: {
actionTypeId: 'actionType-1',
frequency: { notifyWhen: 'onActionGroupChange', summary: false, throttle: null },
group: 'test',
id: 'connector-1',
params: { key: 'value' },
uuid: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
},
type: 'addAction',
});

expect(screen.queryByText('RuleActionsConnectorsModal')).not.toBeInTheDocument();
expect(mockSetIsConnectorsScreenVisible).toHaveBeenCalledWith(true);
});

test('should use the rule producer ID if it is not a multi-consumer rule', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,17 @@

import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiImage, EuiSpacer, EuiText } from '@elastic/eui';
import { RuleSystemAction } from '@kbn/alerting-types';
import { ActionConnector } from '@kbn/alerts-ui-shared';
import React, { useCallback, useMemo, useState } from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { v4 as uuidv4 } from 'uuid';
import { RuleAction, RuleFormParamsErrors } from '../common/types';
import { DEFAULT_FREQUENCY, MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants';
import { useRuleFormDispatch, useRuleFormState } from '../hooks';
import { RuleAction } from '../common/types';
import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants';
import { useRuleFormState, useRuleFormScreenContext } from '../hooks';
import {
ADD_ACTION_DESCRIPTION_TEXT,
ADD_ACTION_HEADER,
ADD_ACTION_OPTIONAL_TEXT,
ADD_ACTION_TEXT,
} from '../translations';
import { getDefaultParams } from '../utils';
import { RuleActionsConnectorsModal } from './rule_actions_connectors_modal';
import { RuleActionsItem } from './rule_actions_item';
import { RuleActionsSystemActionsItem } from './rule_actions_system_actions_item';

Expand All @@ -40,69 +36,19 @@ const useRuleActionsIllustration = () => {
};

export const RuleActions = () => {
const [isConnectorModalOpen, setIsConnectorModalOpen] = useState<boolean>(false);
const ruleActionsIllustration = useRuleActionsIllustration();
const { setIsConnectorsScreenVisible } = useRuleFormScreenContext();

const {
formData: { actions, consumer },
plugins: { actionTypeRegistry },
multiConsumerSelection,
selectedRuleType,
connectorTypes,
} = useRuleFormState();

const dispatch = useRuleFormDispatch();

const onModalOpen = useCallback(() => {
setIsConnectorModalOpen(true);
}, []);

const onModalClose = useCallback(() => {
setIsConnectorModalOpen(false);
}, []);

const onSelectConnector = useCallback(
async (connector: ActionConnector) => {
const { id, actionTypeId } = connector;
const uuid = uuidv4();
const group = selectedRuleType.defaultActionGroupId;
const actionTypeModel = actionTypeRegistry.get(actionTypeId);

const params =
getDefaultParams({
group,
ruleType: selectedRuleType,
actionTypeModel,
}) || {};

dispatch({
type: 'addAction',
payload: {
id,
actionTypeId,
uuid,
params,
group,
frequency: DEFAULT_FREQUENCY,
},
});

const res: { errors: RuleFormParamsErrors } = await actionTypeRegistry
.get(actionTypeId)
?.validateParams(params);

dispatch({
type: 'setActionParamsError',
payload: {
uuid,
errors: res.errors,
},
});

onModalClose();
},
[dispatch, onModalClose, selectedRuleType, actionTypeRegistry]
);
setIsConnectorsScreenVisible(true);
}, [setIsConnectorsScreenVisible]);

const producerId = useMemo(() => {
if (MULTI_CONSUMER_RULE_TYPE_IDS.includes(selectedRuleType.id)) {
Expand Down Expand Up @@ -184,9 +130,6 @@ export const RuleActions = () => {
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
{isConnectorModalOpen && (
<RuleActionsConnectorsModal onClose={onModalClose} onSelectConnector={onSelectConnector} />
)}
</>
);
};
Loading

0 comments on commit 8f39378

Please sign in to comment.