[Response Ops] [Rule Form] Add new flyout to rule form library, responsive design and illustration to rule form page (#206141)

## 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.***
<details>
<summary><h4>Screenshots</h4></summary>
<img width="508" alt="Screenshot 2025-01-08 at 4 29 55 PM"
src="https://github.com/user-attachments/assets/7f03cd3a-5f37-4ac2-9992-ca4951664770"
/>
<img width="502" alt="Screenshot 2025-01-08 at 4 29 59 PM"
src="https://github.com/user-attachments/assets/c0620fc2-83db-4603-90b7-1282e5b0e6ab"
/>
<img width="507" alt="Screenshot 2025-01-08 at 4 30 03 PM"
src="https://github.com/user-attachments/assets/8440d551-46af-49e0-9c92-22d6b3ba1866"
/>
<img width="507" alt="Screenshot 2025-01-08 at 4 30 32 PM"
src="https://github.com/user-attachments/assets/cf7493a7-6e4a-4e55-8027-89e9b36012fc"
/>

</details>


### 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
<details>
<summary>Screenshot</summary>
<img width="783" alt="Screenshot 2025-01-08 at 4 31 50 PM"
src="https://github.com/user-attachments/assets/a3532b92-9f22-4e88-bcc3-e408fc53e64c"
/>

</details>

- Adds the new illustrated "Add an action" empty prompt from the flyout
designs to the existing rule form page
<details>
<summary>Screenshot</summary>
<img width="1299" alt="Screenshot 2025-01-08 at 5 00 55 PM"
src="https://github.com/user-attachments/assets/c4acd50d-9268-4874-b650-ecba532f3e9c"
/>

</details>

### 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 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
This commit is contained in:
Zacqary Adam Xeper 2025-01-15 09:37:39 -06:00 committed by GitHub
parent bc47ff03b5
commit 471f948207
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1818 additions and 39 deletions

View file

@ -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(<TestComponent />);
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 () => {

View file

@ -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<RuleFormStepId, boolean>;
/* 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: <RuleDefinition />,
},
@ -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

View file

@ -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<boolean>(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 (
<>
<EuiFlexGroup data-test-subj="ruleActions" direction="column">
@ -120,15 +141,49 @@ export const RuleActions = () => {
);
})}
</EuiFlexGroup>
{!hasActions && (
<EuiFlexGroup justifyContent="center">
<EuiFlexGroup
alignItems="center"
direction="column"
gutterSize="m"
style={{ maxWidth: 356 }}
>
<EuiImage
alt="Rule actions illustration"
width={198}
height={180}
url={ruleActionsIllustration}
/>
<EuiFlexItem>
<EuiText textAlign="center">
<h3>{ADD_ACTION_HEADER}</h3>
</EuiText>
<EuiText size="s" textAlign="center" color="subdued">
{ADD_ACTION_OPTIONAL_TEXT}
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s" textAlign="center" color="subdued">
{ADD_ACTION_DESCRIPTION_TEXT}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
)}
<EuiSpacer />
<EuiButton
data-test-subj="ruleActionsAddActionButton"
iconType="push"
iconSide="left"
onClick={onModalOpen}
>
{ADD_ACTION_TEXT}
</EuiButton>
<EuiFlexGroup justifyContent={!hasActions ? 'center' : 'flexStart'}>
<EuiFlexItem grow={0}>
<EuiButton
data-test-subj="ruleActionsAddActionButton"
iconType="push"
iconSide="left"
onClick={onModalOpen}
>
{ADD_ACTION_TEXT}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
{isConnectorModalOpen && (
<RuleActionsConnectorsModal onClose={onModalClose} onSelectConnector={onSelectConnector} />
)}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 379 KiB

View file

@ -193,20 +193,20 @@ export const RuleDefinition = () => {
return (
<EuiSplitPanel.Outer hasBorder hasShadow={false} data-test-subj="ruleDefinition">
<EuiSplitPanel.Inner color="subdued">
<EuiFlexGroup gutterSize="s">
<EuiFlexGroup gutterSize="s" className="ruleDefinitionHeader">
<EuiFlexItem grow={false} data-test-subj="ruleDefinitionHeaderRuleTypeName">
<EuiText size="xs">
<EuiText size="xs" className="ruleDefinitionHeaderRuleTypeName">
<strong>{selectedRuleType.name}</strong>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false} data-test-subj="ruleDefinitionHeaderRuleTypeDescription">
<EuiText size="xs">
<EuiText size="xs" className="ruleDefinitionHeaderRuleTypeDescription">
<p>{selectedRuleTypeModel.description}</p>
</EuiText>
</EuiFlexItem>
{docsUrl && (
<EuiFlexItem grow={false}>
<EuiText size="xs">
<EuiText size="xs" className="ruleDefinitionHeaderDocsLink">
<EuiLink
href={docsUrl}
target="_blank"

View file

@ -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 './rule_flyout';

View file

@ -0,0 +1,151 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { RuleFlyout } from './rule_flyout';
import {
RULE_FORM_PAGE_RULE_DEFINITION_TITLE_SHORT,
RULE_FORM_PAGE_RULE_ACTIONS_TITLE,
RULE_FORM_PAGE_RULE_DETAILS_TITLE_SHORT,
} from '../translations';
import { RuleFormData } from '../types';
jest.mock('../rule_definition', () => ({
RuleDefinition: () => <div />,
}));
jest.mock('../rule_actions', () => ({
RuleActions: () => <div />,
}));
jest.mock('../rule_details', () => ({
RuleDetails: () => <div />,
}));
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(<RuleFlyout onCancel={onCancel} onSave={onSave} />);
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(<RuleFlyout onCancel={onCancel} onSave={onSave} />);
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(<RuleFlyout onCancel={onCancel} onSave={onSave} />);
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(<RuleFlyout onCancel={onCancel} onSave={onSave} />);
fireEvent.click(screen.getByTestId('ruleFlyoutFooterCancelButton'));
expect(onCancel).toHaveBeenCalled();
});
});

View file

@ -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 (
<EuiPortal>
<EuiFlyout
ownFocus
onClose={onCancel}
aria-labelledby="flyoutTitle"
size="m"
maxWidth={500}
className="ruleFormFlyout__container"
>
<RuleFlyoutBody onSave={onSave} onCancel={onCancel} isEdit={isEdit} isSaving={isSaving} />
</EuiFlyout>
</EuiPortal>
);
};

View file

@ -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 (
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s" data-test-subj={isEdit ? 'editRuleFlyoutTitle' : 'addRuleFlyoutTitle'}>
<h3 id="flyoutTitle">
{isEdit ? RULE_FLYOUT_HEADER_EDIT_TITLE : RULE_FLYOUT_HEADER_CREATE_TITLE}
</h3>
</EuiTitle>
{isEdit && <RuleFlyoutEditTabs steps={steps} />}
</EuiFlyoutHeader>
<EuiFlyoutBody>
{!isEdit && <EuiStepsHorizontal size="xs" steps={steps} />}
{hasActionsDisabled && (
<>
<EuiCallOut
size="s"
color="danger"
iconType="error"
data-test-subj="hasActionsDisabled"
title={DISABLED_ACTIONS_WARNING_TITLE}
/>
<EuiSpacer />
</>
)}
{currentStepComponent}
</EuiFlyoutBody>
{isEdit ? (
<RuleFlyoutEditFooter
onCancel={onCancel}
onSave={onSaveInternal}
onShowRequest={() => {} /* TODO */}
isSaving={isSaving}
hasErrors={hasErrors}
/>
) : (
<RuleFlyoutCreateFooter
onCancel={onCancel}
onSave={onSaveInternal}
onShowRequest={() => {} /* TODO */}
goToNextStep={goToNextStep}
goToPreviousStep={goToPreviousStep}
isSaving={isSaving}
hasNextStep={hasNextStep}
hasPreviousStep={hasPreviousStep}
hasErrors={hasErrors}
/>
)}
</>
);
};

View file

@ -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 (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
{hasPreviousStep ? (
<EuiButtonEmpty
data-test-subj="ruleFlyoutFooterPreviousStepButton"
onClick={goToPreviousStep}
>
{RULE_FLYOUT_FOOTER_BACK_TEXT}
</EuiButtonEmpty>
) : (
<EuiButtonEmpty data-test-subj="ruleFlyoutFooterCancelButton" onClick={onCancel}>
{RULE_FLYOUT_FOOTER_CANCEL_TEXT}
</EuiButtonEmpty>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexEnd" gutterSize="m">
{!hasNextStep && (
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
data-test-subj="ruleFlyoutFooterShowRequestButton"
isDisabled={isSaving || hasErrors}
onClick={onShowRequest}
>
{RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT}
</EuiButton>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
{hasNextStep ? (
<EuiButton
fill
data-test-subj="ruleFlyoutFooterNextStepButton"
onClick={goToNextStep}
>
{RULE_FLYOUT_FOOTER_NEXT_TEXT}
</EuiButton>
) : (
<EuiButton
fill
data-test-subj="ruleFlyoutFooterSaveButton"
type="submit"
isDisabled={isSaving || hasErrors}
isLoading={isSaving}
onClick={onSave}
>
{RULE_FLYOUT_FOOTER_CREATE_TEXT}
</EuiButton>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
);
};

View file

@ -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 (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="cancelSaveRuleButton" onClick={onCancel}>
{RULE_FLYOUT_FOOTER_CANCEL_TEXT}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexEnd" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
data-test-subj="ruleFlyoutFooterShowRequestButton"
isDisabled={isSaving || hasErrors}
onClick={onShowRequest}
>
{RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
data-test-subj="saveRuleButton"
type="submit"
isDisabled={isSaving || hasErrors}
isLoading={isSaving}
onClick={onSave}
>
{RULE_FLYOUT_FOOTER_SAVE_TEXT}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
);
};

View file

@ -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<Omit<EuiStepHorizontalProps, 'step'>>;
}
export const RuleFlyoutEditTabs = ({ steps }: RuleFlyoutEditTabsProps) => {
const bottomMarginOffset = `-${useEuiPaddingSize('l')}`;
const tabs = useMemo(
() =>
steps.map((step, index) => {
return (
<EuiTab key={index} isSelected={step.status === 'current'} onClick={step.onClick}>
{step.title}
</EuiTab>
);
}),
[steps]
);
return (
<div style={{ marginBottom: bottomMarginOffset }}>
<EuiTabs bottomBorder={false}>{tabs}</EuiTabs>
</div>
);
};

View file

@ -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;
}
}

View file

@ -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 <QueryClientProvider client={queryClient}>{ruleFormComponent}</QueryClientProvider>;
return (
<QueryClientProvider client={queryClient}>
<div className="ruleForm__container">{ruleFormComponent}</div>
</QueryClientProvider>
);
};

View file

@ -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',
});

View file

@ -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"
]
}