From 398062b77f25da35860a24b3e3f90776b586be0c Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 15 Jan 2025 09:37:39 -0600 Subject: [PATCH] [Response Ops] [Rule Form] Add new flyout to rule form library, responsive design and illustration to rule form page (#206141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Part of #195211 Adds components for the new rule form flyout, and duplicates some of its design elements as responsive design on the Rule Page. This PR makes use of CSS `@container` queries, which EUI doesn't yet support natively. I've opened https://github.com/elastic/eui/issues/8265 to get native EUI support for this functionality, but for now we can apply it through class names and SCSS. The reason we're using `@container` is so the Rule Form can be responsive regardless of whether it's bound by the window size (in the case of the Rule Page) or a container element on a larger screen (in the Rule Flyout). When responsive design just relies on `@media screen` queries, we can have a situation where we're trying to render the rule form in a 500px wide flyout, but because the window is 1920px wide, it still tries to apply wide screen styling. `@container` instead responds to the width of an enclosing element, which can either be the body of the Rule Page, or the width of the Rule Flyout. ### Non-User Facing Changes - Adds the new rule flyout to `@kbn/response-ops-rule-form`. ***It is not yet actually user-facing anywhere in the application, this will be done in a second PR.***

Screenshots

Screenshot 2025-01-08 at 4 29 55 PM Screenshot 2025-01-08 at 4 29 59 PM Screenshot 2025-01-08 at 4 30 03 PM Screenshot 2025-01-08 at 4 30 32 PM
### User-Facing Changes These changes were added to the existing full page rule form to minimize the amount of code differences between the flyout and the full page - Adds some responsive styling to the rule form page to make it look more similar to the flyout when the browser window is narrow
Screenshot Screenshot 2025-01-08 at 4 31 50 PM
- Adds the new illustrated "Add an action" empty prompt from the flyout designs to the existing rule form page
Screenshot Screenshot 2025-01-08 at 5 00 55 PM
### 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 `` instead of ``.
Use this diff block ```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) => { }), }} > - + ); 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, }} > - + ); ```
### Still Todo 1. Replace the action connector modal with an in-flyout UI as called for in the [design spec](https://www.figma.com/design/zetHXnUP0YnDG4YmvPwRb8/Adapt-new-Rule-form-to-work-in-flyout) 2. Add the Show Request UI 3. 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 (cherry picked from commit 471f9482070803fe4b884e7f95bc6502551f891e) --- .../src/hooks/use_rule_form_steps.test.tsx | 6 +- .../src/hooks/use_rule_form_steps.tsx | 20 +- .../src/rule_actions/rule_actions.tsx | 85 +- .../rule_actions_illustration.svg | 1025 +++++++++++++++++ .../src/rule_definition/rule_definition.tsx | 8 +- .../rule_form/src/rule_flyout/index.ts | 10 + .../src/rule_flyout/rule_flyout.test.tsx | 151 +++ .../rule_form/src/rule_flyout/rule_flyout.tsx | 40 + .../src/rule_flyout/rule_flyout_body.tsx | 155 +++ .../rule_flyout/rule_flyout_create_footer.tsx | 107 ++ .../rule_flyout/rule_flyout_edit_footer.tsx | 77 ++ .../src/rule_flyout/rule_flyout_edit_tabs.tsx | 37 + .../response-ops/rule_form/src/rule_form.scss | 27 + .../response-ops/rule_form/src/rule_form.tsx | 7 +- .../rule_form/src/translations.ts | 85 ++ .../response-ops/rule_form/tsconfig.json | 17 +- 16 files changed, 1818 insertions(+), 39 deletions(-) create mode 100644 src/platform/packages/shared/response-ops/rule_form/src/rule_actions/rule_actions_illustration.svg create mode 100644 src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/index.ts create mode 100644 src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.test.tsx create mode 100644 src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.tsx create mode 100644 src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_body.tsx create mode 100644 src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_create_footer.tsx create mode 100644 src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_edit_footer.tsx create mode 100644 src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_edit_tabs.tsx create mode 100644 src/platform/packages/shared/response-ops/rule_form/src/rule_form.scss diff --git a/src/platform/packages/shared/response-ops/rule_form/src/hooks/use_rule_form_steps.test.tsx b/src/platform/packages/shared/response-ops/rule_form/src/hooks/use_rule_form_steps.test.tsx index 1bcfcd6f954c5..14ef59bec057e 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/hooks/use_rule_form_steps.test.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/hooks/use_rule_form_steps.test.tsx @@ -14,6 +14,8 @@ import { RULE_FORM_PAGE_RULE_DEFINITION_TITLE, RULE_FORM_PAGE_RULE_ACTIONS_TITLE, RULE_FORM_PAGE_RULE_DETAILS_TITLE, + RULE_FORM_PAGE_RULE_DEFINITION_TITLE_SHORT, + RULE_FORM_PAGE_RULE_DETAILS_TITLE_SHORT, } from '../translations'; import { RuleFormData } from '../types'; import { EuiSteps, EuiStepsHorizontal } from '@elastic/eui'; @@ -145,9 +147,9 @@ describe('useRuleFormHorizontalSteps', () => { render(); - expect(screen.getByText(RULE_FORM_PAGE_RULE_DEFINITION_TITLE)).toBeInTheDocument(); + expect(screen.getByText(RULE_FORM_PAGE_RULE_DEFINITION_TITLE_SHORT)).toBeInTheDocument(); expect(screen.getByText(RULE_FORM_PAGE_RULE_ACTIONS_TITLE)).toBeInTheDocument(); - expect(screen.getByText(RULE_FORM_PAGE_RULE_DETAILS_TITLE)).toBeInTheDocument(); + expect(screen.getByText(RULE_FORM_PAGE_RULE_DETAILS_TITLE_SHORT)).toBeInTheDocument(); }); test('tracks current step successfully', async () => { diff --git a/src/platform/packages/shared/response-ops/rule_form/src/hooks/use_rule_form_steps.tsx b/src/platform/packages/shared/response-ops/rule_form/src/hooks/use_rule_form_steps.tsx index d484f6d8c58c6..74b926a2bc20c 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/hooks/use_rule_form_steps.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/hooks/use_rule_form_steps.tsx @@ -18,6 +18,8 @@ import { RULE_FORM_PAGE_RULE_ACTIONS_TITLE, RULE_FORM_PAGE_RULE_DEFINITION_TITLE, RULE_FORM_PAGE_RULE_DETAILS_TITLE, + RULE_FORM_PAGE_RULE_DEFINITION_TITLE_SHORT, + RULE_FORM_PAGE_RULE_DETAILS_TITLE_SHORT, } from '../translations'; import { hasActionsError, hasActionsParamsErrors, hasParamsErrors } from '../validation'; import { RuleFormStepId } from '../constants'; @@ -27,6 +29,7 @@ interface UseRuleFormStepsOptions { touchedSteps: Record; /* Used to track the current step in horizontal steps, not used for vertical steps */ currentStep?: RuleFormStepId; + shortTitles?: boolean; } /** @@ -69,7 +72,11 @@ const getStepStatus = ({ }; // Create a common hook for both horizontal and vertical steps -const useCommonRuleFormSteps = ({ touchedSteps, currentStep }: UseRuleFormStepsOptions) => { +const useCommonRuleFormSteps = ({ + touchedSteps, + currentStep, + shortTitles, +}: UseRuleFormStepsOptions) => { const { plugins: { application }, baseErrors = {}, @@ -132,7 +139,9 @@ const useCommonRuleFormSteps = ({ touchedSteps, currentStep }: UseRuleFormStepsO const steps = useMemo( () => ({ [RuleFormStepId.DEFINITION]: { - title: RULE_FORM_PAGE_RULE_DEFINITION_TITLE, + title: shortTitles + ? RULE_FORM_PAGE_RULE_DEFINITION_TITLE_SHORT + : RULE_FORM_PAGE_RULE_DEFINITION_TITLE, status: ruleDefinitionStatus, children: , }, @@ -150,7 +159,9 @@ const useCommonRuleFormSteps = ({ touchedSteps, currentStep }: UseRuleFormStepsO } : null, [RuleFormStepId.DETAILS]: { - title: RULE_FORM_PAGE_RULE_DETAILS_TITLE, + title: shortTitles + ? RULE_FORM_PAGE_RULE_DETAILS_TITLE_SHORT + : RULE_FORM_PAGE_RULE_DETAILS_TITLE, status: ruleDetailsStatus, children: ( <> @@ -161,7 +172,7 @@ const useCommonRuleFormSteps = ({ touchedSteps, currentStep }: UseRuleFormStepsO ), }, }), - [ruleDefinitionStatus, canReadConnectors, actionsStatus, ruleDetailsStatus] + [ruleDefinitionStatus, canReadConnectors, actionsStatus, ruleDetailsStatus, shortTitles] ); const stepOrder: RuleFormStepId[] = useMemo( @@ -247,6 +258,7 @@ export const useRuleFormHorizontalSteps: () => RuleFormHorizontalSteps = () => { const { steps, stepOrder } = useCommonRuleFormSteps({ touchedSteps, currentStep, + shortTitles: true, }); // Determine current navigation position diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_actions/rule_actions.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_actions/rule_actions.tsx index 2479c07edfb5f..168a7d4f5a3c3 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/rule_actions/rule_actions.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_actions/rule_actions.tsx @@ -7,22 +7,41 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useMemo, useState } from 'react'; -import { EuiButton, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { v4 as uuidv4 } from 'uuid'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiImage, EuiSpacer, EuiText } from '@elastic/eui'; import { RuleSystemAction } from '@kbn/alerting-types'; import { ActionConnector } from '@kbn/alerts-ui-shared'; -import { ADD_ACTION_TEXT } from '../translations'; -import { RuleActionsConnectorsModal } from './rule_actions_connectors_modal'; -import { useRuleFormDispatch, useRuleFormState } from '../hooks'; +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 { + 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'; -import { getDefaultParams } from '../utils'; + +const useRuleActionsIllustration = () => { + const [imageData, setImageData] = useState(''); + useEffectOnce(() => { + const fetchImage = async () => { + const image = await import('./rule_actions_illustration.svg'); + setImageData(image.default); + }; + fetchImage(); + }); + return imageData; +}; export const RuleActions = () => { const [isConnectorModalOpen, setIsConnectorModalOpen] = useState(false); + const ruleActionsIllustration = useRuleActionsIllustration(); const { formData: { actions, consumer }, @@ -92,6 +111,8 @@ export const RuleActions = () => { return selectedRuleType.producer; }, [consumer, multiConsumerSelection, selectedRuleType]); + const hasActions = actions.length > 0; + return ( <> @@ -120,15 +141,49 @@ export const RuleActions = () => { ); })} + {!hasActions && ( + + + + + +

{ADD_ACTION_HEADER}

+
+ + {ADD_ACTION_OPTIONAL_TEXT} + +
+ + + {ADD_ACTION_DESCRIPTION_TEXT} + + +
+
+ )} - - {ADD_ACTION_TEXT} - + + + + {ADD_ACTION_TEXT} + + + {isConnectorModalOpen && ( )} diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_actions/rule_actions_illustration.svg b/src/platform/packages/shared/response-ops/rule_form/src/rule_actions/rule_actions_illustration.svg new file mode 100644 index 0000000000000..caab2d84dd8f0 --- /dev/null +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_actions/rule_actions_illustration.svg @@ -0,0 +1,1025 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_definition/rule_definition.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_definition/rule_definition.tsx index 5cedd0d117527..70f63642e653b 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/rule_definition/rule_definition.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_definition/rule_definition.tsx @@ -193,20 +193,20 @@ export const RuleDefinition = () => { return ( - + - + {selectedRuleType.name} - +

{selectedRuleTypeModel.description}

{docsUrl && ( - + ({ + RuleDefinition: () =>
, +})); + +jest.mock('../rule_actions', () => ({ + RuleActions: () =>
, +})); + +jest.mock('../rule_details', () => ({ + RuleDetails: () =>
, +})); + +jest.mock('../hooks/use_rule_form_state', () => ({ + useRuleFormState: jest.fn(), +})); + +jest.mock('../hooks/use_rule_form_dispatch', () => ({ + useRuleFormDispatch: jest.fn(), +})); + +const { useRuleFormState } = jest.requireMock('../hooks/use_rule_form_state'); + +const navigateToUrl = jest.fn(); + +const formDataMock: RuleFormData = { + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'alert.executionStatus.lastExecutionDate', + }, + actions: [], + consumer: 'stackAlerts', + schedule: { interval: '1m' }, + tags: [], + name: 'test', + notifyWhen: 'onActionGroupChange', + alertDelay: { + active: 10, + }, +}; + +const onCancel = jest.fn(); + +useRuleFormState.mockReturnValue({ + plugins: { + application: { + navigateToUrl, + capabilities: { + actions: { + show: true, + save: true, + execute: true, + }, + }, + }, + }, + baseErrors: {}, + paramsErrors: {}, + multiConsumerSelection: 'logs', + formData: formDataMock, + connectors: [], + connectorTypes: [], + aadTemplateFields: [], +}); + +const onSave = jest.fn(); + +describe('ruleFlyout', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders correctly', () => { + render(); + + expect(screen.getByText(RULE_FORM_PAGE_RULE_DEFINITION_TITLE_SHORT)).toBeInTheDocument(); + expect(screen.getByText(RULE_FORM_PAGE_RULE_ACTIONS_TITLE)).toBeInTheDocument(); + expect(screen.getByText(RULE_FORM_PAGE_RULE_DETAILS_TITLE_SHORT)).toBeInTheDocument(); + + expect(screen.getByTestId('ruleFlyoutFooterCancelButton')).toBeInTheDocument(); + expect(screen.getByTestId('ruleFlyoutFooterNextStepButton')).toBeInTheDocument(); + }); + + test('should navigate back and forth through steps correctly', async () => { + render(); + + fireEvent.click(screen.getByTestId('ruleFlyoutFooterNextStepButton')); + await waitFor(() => + expect(screen.getByTestId('ruleFlyoutFooterPreviousStepButton')).toBeInTheDocument() + ); + fireEvent.click(screen.getByTestId('ruleFlyoutFooterNextStepButton')); + await waitFor(() => + expect(screen.getByTestId('ruleFlyoutFooterSaveButton')).toBeInTheDocument() + ); + fireEvent.click(screen.getByTestId('ruleFlyoutFooterPreviousStepButton')); + await waitFor(() => + expect(screen.getByTestId('ruleFlyoutFooterNextStepButton')).toBeInTheDocument() + ); + }); + + test('should call onSave when save button is pressed', async () => { + render(); + + fireEvent.click(screen.getByTestId('ruleFlyoutFooterNextStepButton')); + await waitFor(() => + expect(screen.getByTestId('ruleFlyoutFooterPreviousStepButton')).toBeInTheDocument() + ); + fireEvent.click(screen.getByTestId('ruleFlyoutFooterNextStepButton')); + await waitFor(() => + expect(screen.getByTestId('ruleFlyoutFooterSaveButton')).toBeInTheDocument() + ); + fireEvent.click(screen.getByTestId('ruleFlyoutFooterSaveButton')); + + expect(onSave).toHaveBeenCalledWith({ + ...formDataMock, + consumer: 'logs', + }); + }); + + test('should call onCancel when the cancel button is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('ruleFlyoutFooterCancelButton')); + expect(onCancel).toHaveBeenCalled(); + }); +}); diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.tsx new file mode 100644 index 0000000000000..f1a873302b823 --- /dev/null +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout.tsx @@ -0,0 +1,40 @@ +/* + * 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 { EuiFlyout, EuiPortal } from '@elastic/eui'; +import React from 'react'; +import type { RuleFormData } from '../types'; +import { RuleFlyoutBody } from './rule_flyout_body'; + +interface RuleFlyoutProps { + isEdit?: boolean; + isSaving?: boolean; + onCancel?: () => void; + onSave: (formData: RuleFormData) => void; +} + +// Wrapper component for the rule flyout. Currently only displays RuleFlyoutBody, but will be extended to conditionally +// display the Show Request UI or the Action Connector UI. These UIs take over the entire flyout, so we need to swap out +// their body elements entirely to avoid adding another EuiFlyout element to the DOM +export const RuleFlyout = ({ onSave, isEdit, isSaving, onCancel = () => {} }: RuleFlyoutProps) => { + return ( + + + + + + ); +}; diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_body.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_body.tsx new file mode 100644 index 0000000000000..ec5590b3a587b --- /dev/null +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_body.tsx @@ -0,0 +1,155 @@ +/* + * 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 { + EuiFlyoutBody, + EuiFlyoutHeader, + EuiStepsHorizontal, + EuiTitle, + EuiSpacer, + EuiCallOut, +} from '@elastic/eui'; +import { checkActionFormActionTypeEnabled } from '@kbn/alerts-ui-shared'; +import React, { useCallback, useMemo } from 'react'; +import { useRuleFormHorizontalSteps, useRuleFormState } from '../hooks'; +import { + RULE_FLYOUT_HEADER_CREATE_TITLE, + RULE_FLYOUT_HEADER_EDIT_TITLE, + DISABLED_ACTIONS_WARNING_TITLE, +} from '../translations'; +import type { RuleFormData } from '../types'; +import { hasRuleErrors } from '../validation'; +import { RuleFlyoutCreateFooter } from './rule_flyout_create_footer'; +import { RuleFlyoutEditFooter } from './rule_flyout_edit_footer'; +import { RuleFlyoutEditTabs } from './rule_flyout_edit_tabs'; + +interface RuleFlyoutBodyProps { + isEdit?: boolean; + isSaving?: boolean; + onCancel: () => void; + onSave: (formData: RuleFormData) => void; +} + +export const RuleFlyoutBody = ({ + isEdit = false, + isSaving = false, + onCancel, + onSave, +}: RuleFlyoutBodyProps) => { + const { + formData, + multiConsumerSelection, + connectorTypes, + connectors, + baseErrors = {}, + paramsErrors = {}, + actionsErrors = {}, + actionsParamsErrors = {}, + } = useRuleFormState(); + + const hasErrors = useMemo(() => { + const hasBrokenConnectors = formData.actions.some((action) => { + return !connectors.find((connector) => connector.id === action.id); + }); + + if (hasBrokenConnectors) { + return true; + } + + return hasRuleErrors({ + baseErrors, + paramsErrors, + actionsErrors, + actionsParamsErrors, + }); + }, [formData, connectors, baseErrors, paramsErrors, actionsErrors, actionsParamsErrors]); + + const { + steps, + currentStepComponent, + goToNextStep, + goToPreviousStep, + hasNextStep, + hasPreviousStep, + } = useRuleFormHorizontalSteps(); + + const { actions } = formData; + + const onSaveInternal = useCallback(() => { + onSave({ + ...formData, + ...(multiConsumerSelection ? { consumer: multiConsumerSelection } : {}), + }); + }, [onSave, formData, multiConsumerSelection]); + + const hasActionsDisabled = useMemo(() => { + const preconfiguredConnectors = connectors.filter((connector) => connector.isPreconfigured); + return actions.some((action) => { + const actionType = connectorTypes.find(({ id }) => id === action.actionTypeId); + if (!actionType) { + return false; + } + const checkEnabledResult = checkActionFormActionTypeEnabled( + actionType, + preconfiguredConnectors + ); + return !actionType.enabled && !checkEnabledResult.isEnabled; + }); + }, [actions, connectors, connectorTypes]); + + return ( + <> + + +

+ {isEdit ? RULE_FLYOUT_HEADER_EDIT_TITLE : RULE_FLYOUT_HEADER_CREATE_TITLE} +

+
+ {isEdit && } +
+ + {!isEdit && } + {hasActionsDisabled && ( + <> + + + + )} + {currentStepComponent} + + {isEdit ? ( + {} /* TODO */} + isSaving={isSaving} + hasErrors={hasErrors} + /> + ) : ( + {} /* TODO */} + goToNextStep={goToNextStep} + goToPreviousStep={goToPreviousStep} + isSaving={isSaving} + hasNextStep={hasNextStep} + hasPreviousStep={hasPreviousStep} + hasErrors={hasErrors} + /> + )} + + ); +}; diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_create_footer.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_create_footer.tsx new file mode 100644 index 0000000000000..caacacb9240dc --- /dev/null +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_create_footer.tsx @@ -0,0 +1,107 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutFooter, +} from '@elastic/eui'; +import React from 'react'; +import { + RULE_FLYOUT_FOOTER_BACK_TEXT, + RULE_FLYOUT_FOOTER_CANCEL_TEXT, + RULE_FLYOUT_FOOTER_CREATE_TEXT, + RULE_FLYOUT_FOOTER_NEXT_TEXT, + RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT, +} from '../translations'; + +export interface RuleFlyoutCreateFooterProps { + isSaving: boolean; + hasErrors: boolean; + onCancel: () => void; + onSave: () => void; + onShowRequest: () => void; + hasNextStep: boolean; + hasPreviousStep: boolean; + goToNextStep: () => void; + goToPreviousStep: () => void; +} +export const RuleFlyoutCreateFooter = ({ + onCancel, + onSave, + onShowRequest, + hasErrors, + isSaving, + hasNextStep, + hasPreviousStep, + goToNextStep, + goToPreviousStep, +}: RuleFlyoutCreateFooterProps) => { + return ( + + + + {hasPreviousStep ? ( + + {RULE_FLYOUT_FOOTER_BACK_TEXT} + + ) : ( + + {RULE_FLYOUT_FOOTER_CANCEL_TEXT} + + )} + + + + + {!hasNextStep && ( + + + {RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT} + + + )} + + {hasNextStep ? ( + + {RULE_FLYOUT_FOOTER_NEXT_TEXT} + + ) : ( + + {RULE_FLYOUT_FOOTER_CREATE_TEXT} + + )} + + + + + + ); +}; diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_edit_footer.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_edit_footer.tsx new file mode 100644 index 0000000000000..5cb64b40fb180 --- /dev/null +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_edit_footer.tsx @@ -0,0 +1,77 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutFooter, +} from '@elastic/eui'; +import React from 'react'; +import { + RULE_FLYOUT_FOOTER_CANCEL_TEXT, + RULE_FLYOUT_FOOTER_SAVE_TEXT, + RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT, +} from '../translations'; + +export interface RuleFlyoutEditFooterProps { + isSaving: boolean; + hasErrors: boolean; + onCancel: () => void; + onSave: () => void; + onShowRequest: () => void; +} +export const RuleFlyoutEditFooter = ({ + onCancel, + onSave, + onShowRequest, + hasErrors, + isSaving, +}: RuleFlyoutEditFooterProps) => { + return ( + + + + + {RULE_FLYOUT_FOOTER_CANCEL_TEXT} + + + + + + + + {RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT} + + + + + + {RULE_FLYOUT_FOOTER_SAVE_TEXT} + + + + + + + ); +}; diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_edit_tabs.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_edit_tabs.tsx new file mode 100644 index 0000000000000..ea560832f9514 --- /dev/null +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_flyout/rule_flyout_edit_tabs.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useMemo } from 'react'; +import { EuiTabs, EuiTab, useEuiPaddingSize } from '@elastic/eui'; +import { EuiStepHorizontalProps } from '@elastic/eui/src/components/steps/step_horizontal'; + +interface RuleFlyoutEditTabsProps { + steps: Array>; +} + +export const RuleFlyoutEditTabs = ({ steps }: RuleFlyoutEditTabsProps) => { + const bottomMarginOffset = `-${useEuiPaddingSize('l')}`; + + const tabs = useMemo( + () => + steps.map((step, index) => { + return ( + + {step.title} + + ); + }), + [steps] + ); + return ( +
+ {tabs} +
+ ); +}; diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_form.scss b/src/platform/packages/shared/response-ops/rule_form/src/rule_form.scss new file mode 100644 index 0000000000000..fd905ed8f9d04 --- /dev/null +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_form.scss @@ -0,0 +1,27 @@ +.ruleForm__container { + container-type: inline-size; +} + +.ruleFormFlyout__container { + container-type: inline-size; +} + +@container (max-width: 768px) { + .euiDescribedFormGroup { + flex-direction: column; + } + .euiDescribedFormGroup > .euiFlexItem { + width: 100%; + } + .ruleDefinitionHeader { + flex-direction: column; + gap: $euiSizeM; + } + .ruleDefinitionHeaderRuleTypeName { + font-size: $euiFontSizeM; + margin-bottom: $euiSizeXS; + } + .ruleDefinitionHeaderRuleTypeDescription, .ruleDefinitionHeaderDocsLink { + font-size: $euiFontSizeS; + } +} \ No newline at end of file diff --git a/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx b/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx index f34f811a7b488..5b3f43a5bd4ba 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx +++ b/src/platform/packages/shared/response-ops/rule_form/src/rule_form.tsx @@ -18,6 +18,7 @@ import { RULE_FORM_ROUTE_PARAMS_ERROR_TEXT, } from './translations'; import { RuleFormPlugins } from './types'; +import './rule_form.scss'; const queryClient = new QueryClient(); @@ -114,5 +115,9 @@ export const RuleForm = (props: RuleFormProps) => { onSubmit, ]); - return {ruleFormComponent}; + return ( + +
{ruleFormComponent}
+
+ ); }; diff --git a/src/platform/packages/shared/response-ops/rule_form/src/translations.ts b/src/platform/packages/shared/response-ops/rule_form/src/translations.ts index 3f34baee68b3c..eebe9dd2157c3 100644 --- a/src/platform/packages/shared/response-ops/rule_form/src/translations.ts +++ b/src/platform/packages/shared/response-ops/rule_form/src/translations.ts @@ -233,6 +233,28 @@ export const ADD_ACTION_TEXT = i18n.translate( } ); +export const ADD_ACTION_HEADER = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleActions.addActionHeader', + { + defaultMessage: 'Add an action', + } +); + +export const ADD_ACTION_OPTIONAL_TEXT = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleActions.addActionOptionalText', + { + defaultMessage: 'Optional', + } +); + +export const ADD_ACTION_DESCRIPTION_TEXT = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleActions.addActionDescriptionText', + { + defaultMessage: + 'Select a connector and configure the actions to be performed when an alert is triggered', + } +); + export const RULE_DETAILS_TITLE = i18n.translate('responseOpsRuleForm.ruleForm.ruleDetails.title', { defaultMessage: 'Rule name and tags', }); @@ -307,6 +329,55 @@ export const RULE_PAGE_FOOTER_SAVE_TEXT = i18n.translate( } ); +export const RULE_FLYOUT_HEADER_CREATE_TITLE = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleFlyoutHeader.createTitle', + { + defaultMessage: 'Create rule', + } +); + +export const RULE_FLYOUT_HEADER_EDIT_TITLE = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleFlyoutHeader.editTitle', + { + defaultMessage: 'Edit rule', + } +); + +export const RULE_FLYOUT_FOOTER_CANCEL_TEXT = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleFlyoutFooter.cancelText', + { + defaultMessage: 'Cancel', + } +); + +export const RULE_FLYOUT_FOOTER_BACK_TEXT = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleFlyoutFooter.backText', + { + defaultMessage: 'Back', + } +); + +export const RULE_FLYOUT_FOOTER_NEXT_TEXT = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleFlyoutFooter.nextText', + { + defaultMessage: 'Next', + } +); + +export const RULE_FLYOUT_FOOTER_CREATE_TEXT = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleFlyoutFooter.createText', + { + defaultMessage: 'Create rule', + } +); + +export const RULE_FLYOUT_FOOTER_SAVE_TEXT = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleFlyoutFooter.saveText', + { + defaultMessage: 'Save changes', + } +); + export const HEALTH_CHECK_ALERTS_ERROR_TITLE = i18n.translate( 'responseOpsRuleForm.healthCheck.alertsErrorTitle', { @@ -490,6 +561,13 @@ export const RULE_FORM_PAGE_RULE_DEFINITION_TITLE = i18n.translate( } ); +export const RULE_FORM_PAGE_RULE_DEFINITION_TITLE_SHORT = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleDefinitionTitleShort', + { + defaultMessage: 'Definition', + } +); + export const RULE_FORM_PAGE_RULE_ACTIONS_TITLE = i18n.translate( 'responseOpsRuleForm.ruleForm.ruleActionsTitle', { @@ -518,6 +596,13 @@ export const RULE_FORM_PAGE_RULE_DETAILS_TITLE = i18n.translate( } ); +export const RULE_FORM_PAGE_RULE_DETAILS_TITLE_SHORT = i18n.translate( + 'responseOpsRuleForm.ruleForm.ruleDetailsTitleShort', + { + defaultMessage: 'Details', + } +); + export const RULE_FORM_RETURN_TITLE = i18n.translate('responseOpsRuleForm.ruleForm.returnTitle', { defaultMessage: 'Return', }); diff --git a/src/platform/packages/shared/response-ops/rule_form/tsconfig.json b/src/platform/packages/shared/response-ops/rule_form/tsconfig.json index b43a362905ecd..1e487cf3b6209 100644 --- a/src/platform/packages/shared/response-ops/rule_form/tsconfig.json +++ b/src/platform/packages/shared/response-ops/rule_form/tsconfig.json @@ -2,19 +2,10 @@ "extends": "../../../../../../tsconfig.base.json", "compilerOptions": { "outDir": "target/types", - "types": [ - "jest", - "node", - "react" - ] + "types": ["jest", "node", "react", "@kbn/ambient-ui-types"] }, - "include": [ - "**/*.ts", - "**/*.tsx", - ], - "exclude": [ - "target/**/*" - ], + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["target/**/*"], "kbn_references": [ "@kbn/alerting-types", "@kbn/i18n", @@ -39,6 +30,6 @@ "@kbn/kibana-react-plugin", "@kbn/core-i18n-browser", "@kbn/core-theme-browser", - "@kbn/core-user-profile-browser", + "@kbn/core-user-profile-browser" ] }