From d8b0b6e926f0198dd654cf5115af9660cb8ef663 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Fri, 10 Jan 2025 15:08:14 -0600 Subject: [PATCH] [ResponseOps] [Rule Form] Move rule form steps to hook with progress tracking (#205944) ## Summary Part of #195211 In preparation for the horizontal rule form layout, move the generation of the rule form steps into three hooks: - `useCommonRuleFormSteps`: private hook that generates a series of objects specifying the rule form steps, how to display them, and what order to display them in - `useRuleFormSteps`: hook that calls `useCommonRuleFormSteps` and transforms them into data for the standard vertical `EuiSteps`, along with progress tracking based on `onBlur` events - `useRuleFormHorizontalSteps`: hook that calls hook that calls `useCommonRuleFormSteps` and transforms them into data for `EuiStepsHorizontal`, plus navigation functions. ***These will be used in the smaller rule form flyout in a second PR*** Because `EuiStepsHorizontal` rely more heavily on the `EuiSteps` `status` property, I took this opportunity to improve progress tracking in the standard vertical steps. Most rule types will load the create page with Step 1: Rule Definition already being in a `danger` state, because an incomplete rule definition component immediately sends errors, and the error API doesn't distinguish between invalid data or incomplete data. This PR wraps each step in a `reportOnBlur` higher-order component, which will report the first time a step triggers an `onBlur` event. Steps with errors will now report `incomplete` until they first trigger an `onBlur`. The result: 1. The user loads the Create Rule page. Rule Definition is marked `incomplete` 2. The user interacts with Rule Definition, but does not yet complete the definition. 3. The user interacts with the Actions step, the Rule Details step, or another part of the page. The Rule Definition is now marked `danger`. This is inelegant compared to an error API that can actually distinguish between an incomplete form and an invalid form, but it's an improvement for now. --------- Co-authored-by: Elastic Machine --- .../rule_form/src/constants/index.ts | 6 + .../response-ops/rule_form/src/hooks/index.ts | 1 + .../src/hooks/use_rule_form_steps.test.tsx | 196 +++++++++++ .../src/hooks/use_rule_form_steps.tsx | 318 ++++++++++++++++++ .../src/rule_page/rule_page.test.tsx | 7 +- .../rule_form/src/rule_page/rule_page.tsx | 92 +---- 6 files changed, 534 insertions(+), 86 deletions(-) create mode 100644 packages/response-ops/rule_form/src/hooks/use_rule_form_steps.test.tsx create mode 100644 packages/response-ops/rule_form/src/hooks/use_rule_form_steps.tsx diff --git a/packages/response-ops/rule_form/src/constants/index.ts b/packages/response-ops/rule_form/src/constants/index.ts index 8e13b7af8303b..556cc5dbf1b88 100644 --- a/packages/response-ops/rule_form/src/constants/index.ts +++ b/packages/response-ops/rule_form/src/constants/index.ts @@ -74,3 +74,9 @@ export const DEFAULT_VALID_CONSUMERS: RuleCreationValidConsumer[] = [ export const CREATE_RULE_ROUTE = '/rule/create/:ruleTypeId' as const; export const EDIT_RULE_ROUTE = '/rule/edit/:id' as const; + +export enum RuleFormStepId { + DEFINITION = 'rule-definition', + ACTIONS = 'rule-actions', + DETAILS = 'rule-details', +} diff --git a/packages/response-ops/rule_form/src/hooks/index.ts b/packages/response-ops/rule_form/src/hooks/index.ts index a1b5bcfe2838b..aef31dc3d5135 100644 --- a/packages/response-ops/rule_form/src/hooks/index.ts +++ b/packages/response-ops/rule_form/src/hooks/index.ts @@ -9,3 +9,4 @@ export * from './use_rule_form_dispatch'; export * from './use_rule_form_state'; +export * from './use_rule_form_steps'; diff --git a/packages/response-ops/rule_form/src/hooks/use_rule_form_steps.test.tsx b/packages/response-ops/rule_form/src/hooks/use_rule_form_steps.test.tsx new file mode 100644 index 0000000000000..1bcfcd6f954c5 --- /dev/null +++ b/packages/response-ops/rule_form/src/hooks/use_rule_form_steps.test.tsx @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useRuleFormHorizontalSteps, useRuleFormSteps } from './use_rule_form_steps'; +import { + RULE_FORM_PAGE_RULE_DEFINITION_TITLE, + RULE_FORM_PAGE_RULE_ACTIONS_TITLE, + RULE_FORM_PAGE_RULE_DETAILS_TITLE, +} from '../translations'; +import { RuleFormData } from '../types'; +import { EuiSteps, EuiStepsHorizontal } from '@elastic/eui'; + +jest.mock('../rule_definition', () => ({ + RuleDefinition: () =>
, +})); + +jest.mock('../rule_actions', () => ({ + RuleActions: () =>
, +})); + +jest.mock('../rule_details', () => ({ + RuleDetails: () =>
, +})); + +jest.mock('./use_rule_form_state', () => ({ + useRuleFormState: jest.fn(), +})); + +const { useRuleFormState } = jest.requireMock('./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 ruleFormStateMock = { + plugins: { + application: { + navigateToUrl, + capabilities: { + actions: { + show: true, + save: true, + execute: true, + }, + }, + }, + }, + baseErrors: {}, + paramsErrors: {}, + multiConsumerSelection: 'logs', + formData: formDataMock, + connectors: [], + connectorTypes: [], + aadTemplateFields: [], +}; + +describe('useRuleFormSteps', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders correctly', () => { + useRuleFormState.mockReturnValue(ruleFormStateMock); + + const TestComponent = () => { + const { steps } = useRuleFormSteps(); + + return ; + }; + + render(); + + expect(screen.getByText(RULE_FORM_PAGE_RULE_DEFINITION_TITLE)).toBeInTheDocument(); + expect(screen.getByText(RULE_FORM_PAGE_RULE_ACTIONS_TITLE)).toBeInTheDocument(); + expect(screen.getByText(RULE_FORM_PAGE_RULE_DETAILS_TITLE)).toBeInTheDocument(); + }); + + test('renders initial errors as incomplete, then danger when the corresponding step blurs', async () => { + useRuleFormState.mockReturnValue({ + ...ruleFormStateMock, + baseErrors: { + interval: ['Interval is required'], + alertDelay: ['Alert delay is required'], + }, + }); + + const TestComponent = () => { + const { steps } = useRuleFormSteps(); + + return ; + }; + + render(); + + // Use screen reader text for testing + expect(await screen.getByText('Step 1 is incomplete')).toBeInTheDocument(); + const step1 = screen.getByTestId('ruleFormStep-rule-definition-reportOnBlur'); + await fireEvent.blur(step1!); + expect(await screen.getByText('Step 1 has errors')).toBeInTheDocument(); + }); +}); + +describe('useRuleFormHorizontalSteps', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders correctly', () => { + useRuleFormState.mockReturnValue(ruleFormStateMock); + + const TestComponent = () => { + const { steps } = useRuleFormHorizontalSteps(); + + return ; + }; + + render(); + + expect(screen.getByText(RULE_FORM_PAGE_RULE_DEFINITION_TITLE)).toBeInTheDocument(); + expect(screen.getByText(RULE_FORM_PAGE_RULE_ACTIONS_TITLE)).toBeInTheDocument(); + expect(screen.getByText(RULE_FORM_PAGE_RULE_DETAILS_TITLE)).toBeInTheDocument(); + }); + + test('tracks current step successfully', async () => { + useRuleFormState.mockReturnValue(ruleFormStateMock); + + const TestComponent = () => { + const { steps, goToNextStep, goToPreviousStep } = useRuleFormHorizontalSteps(); + + return ( + <> + + + + + ); + }; + + render(); + + expect(await screen.getByText('Current step is 1')).toBeInTheDocument(); + + const nextButton = screen.getByText('Next'); + const previousButton = screen.getByText('Previous'); + + fireEvent.click(nextButton); + fireEvent.click(nextButton); + + expect(await screen.getByText('Current step is 3')).toBeInTheDocument(); + + fireEvent.click(nextButton); + + expect(await screen.getByText('Current step is 3')).toBeInTheDocument(); + + fireEvent.click(previousButton); + + expect(await screen.getByText('Current step is 2')).toBeInTheDocument(); + + fireEvent.click(previousButton); + + expect(await screen.getByText('Current step is 1')).toBeInTheDocument(); + + fireEvent.click(previousButton); + + expect(await screen.getByText('Current step is 1')).toBeInTheDocument(); + }); +}); diff --git a/packages/response-ops/rule_form/src/hooks/use_rule_form_steps.tsx b/packages/response-ops/rule_form/src/hooks/use_rule_form_steps.tsx new file mode 100644 index 0000000000000..d484f6d8c58c6 --- /dev/null +++ b/packages/response-ops/rule_form/src/hooks/use_rule_form_steps.tsx @@ -0,0 +1,318 @@ +/* + * 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 } from 'lodash'; +import { EuiHorizontalRule, EuiSpacer, EuiStepsProps, EuiStepsHorizontalProps } from '@elastic/eui'; +import React, { useState, useMemo, useCallback, PropsWithChildren } from 'react'; +import { useRuleFormState } from './use_rule_form_state'; +import { RuleActions } from '../rule_actions'; +import { RuleDefinition } from '../rule_definition'; +import { RuleDetails } from '../rule_details'; +import { + RULE_FORM_PAGE_RULE_ACTIONS_TITLE, + RULE_FORM_PAGE_RULE_DEFINITION_TITLE, + RULE_FORM_PAGE_RULE_DETAILS_TITLE, +} from '../translations'; +import { hasActionsError, hasActionsParamsErrors, hasParamsErrors } from '../validation'; +import { RuleFormStepId } from '../constants'; + +interface UseRuleFormStepsOptions { + /* Used to track steps that have been interacted with and should mark errors with 'danger' instead of 'incomplete' */ + touchedSteps: Record; + /* Used to track the current step in horizontal steps, not used for vertical steps */ + currentStep?: RuleFormStepId; +} + +/** + * Define the order of the steps programmatically. Updating this array will update the order of the steps + * in all places needed. + */ +const STEP_ORDER = [RuleFormStepId.DEFINITION, RuleFormStepId.ACTIONS, RuleFormStepId.DETAILS]; + +const isStepBefore = (step: RuleFormStepId, comparisonStep: RuleFormStepId) => { + return STEP_ORDER.indexOf(step) < STEP_ORDER.indexOf(comparisonStep); +}; + +const getStepStatus = ({ + step, + currentStep, + hasErrors, + touchedSteps, +}: { + step: RuleFormStepId; + currentStep?: RuleFormStepId; + hasErrors: boolean; + touchedSteps: Record; +}) => { + // Only apply the current status if currentStep is being tracked + if (currentStep === step) return 'current'; + + if (hasErrors) { + // Only apply the danger status if the user has interacted with this step and then focused on something else + // Otherwise just mark it as incomplete + return touchedSteps[step] ? 'danger' : 'incomplete'; + } + // Only mark this step complete or incomplete if the currentStep flag is being used, otherwise set no status + if (currentStep && isStepBefore(step, currentStep)) { + return 'complete'; + } else if (currentStep) { + return 'incomplete'; + } + + return undefined; +}; + +// Create a common hook for both horizontal and vertical steps +const useCommonRuleFormSteps = ({ touchedSteps, currentStep }: UseRuleFormStepsOptions) => { + const { + plugins: { application }, + baseErrors = {}, + paramsErrors = {}, + actionsErrors = {}, + actionsParamsErrors = {}, + } = useRuleFormState(); + + const canReadConnectors = !!application.capabilities.actions?.show; + + const hasRuleDefinitionErrors = useMemo(() => { + return !!( + hasParamsErrors(paramsErrors) || + baseErrors.interval?.length || + baseErrors.alertDelay?.length + ); + }, [paramsErrors, baseErrors]); + + const hasActionErrors = useMemo(() => { + return hasActionsError(actionsErrors) || hasActionsParamsErrors(actionsParamsErrors); + }, [actionsErrors, actionsParamsErrors]); + + const hasRuleDetailsError = useMemo(() => { + return Boolean(baseErrors.name?.length || baseErrors.tags?.length); + }, [baseErrors]); + + const ruleDefinitionStatus = useMemo( + () => + getStepStatus({ + step: RuleFormStepId.DEFINITION, + currentStep, + hasErrors: hasRuleDefinitionErrors, + touchedSteps, + }), + [hasRuleDefinitionErrors, currentStep, touchedSteps] + ); + + const actionsStatus = useMemo( + () => + getStepStatus({ + step: RuleFormStepId.ACTIONS, + currentStep, + hasErrors: hasActionErrors, + touchedSteps, + }), + [hasActionErrors, currentStep, touchedSteps] + ); + + const ruleDetailsStatus = useMemo( + () => + getStepStatus({ + step: RuleFormStepId.DETAILS, + currentStep, + hasErrors: hasRuleDetailsError, + touchedSteps, + }), + [hasRuleDetailsError, currentStep, touchedSteps] + ); + + const steps = useMemo( + () => ({ + [RuleFormStepId.DEFINITION]: { + title: RULE_FORM_PAGE_RULE_DEFINITION_TITLE, + status: ruleDefinitionStatus, + children: , + }, + [RuleFormStepId.ACTIONS]: canReadConnectors + ? { + title: RULE_FORM_PAGE_RULE_ACTIONS_TITLE, + status: actionsStatus, + children: ( + <> + + + + + ), + } + : null, + [RuleFormStepId.DETAILS]: { + title: RULE_FORM_PAGE_RULE_DETAILS_TITLE, + status: ruleDetailsStatus, + children: ( + <> + + + + + ), + }, + }), + [ruleDefinitionStatus, canReadConnectors, actionsStatus, ruleDetailsStatus] + ); + + const stepOrder: RuleFormStepId[] = useMemo( + () => STEP_ORDER.filter((stepId) => steps[stepId]), + [steps] + ); + + return { steps, stepOrder }; +}; + +const ReportOnBlur: React.FC void }>> = ({ + onBlur, + stepId, + children, +}) => ( +
+ {children} +
+); + +interface RuleFormVerticalSteps { + steps: EuiStepsProps['steps']; +} + +export const useRuleFormSteps: () => RuleFormVerticalSteps = () => { + // Track steps that the user has interacted with and then focused away from + const [touchedSteps, setTouchedSteps] = useState>( + STEP_ORDER.reduce( + (result, stepId) => ({ ...result, [stepId]: false }), + {} as Record + ) + ); + + const { steps, stepOrder } = useCommonRuleFormSteps({ touchedSteps }); + + const mappedSteps = useMemo(() => { + return stepOrder + .map((stepId) => { + const step = steps[stepId]; + return step + ? { + ...step, + children: ( + + !touchedSteps[stepId] && + setTouchedSteps((prevTouchedSteps) => ({ + ...prevTouchedSteps, + [stepId]: true, + })) + } + stepId={stepId} + > + {step.children} + + ), + } + : null; + }) + .filter(Boolean) as EuiStepsProps['steps']; + }, [steps, stepOrder, touchedSteps]); + + return { steps: mappedSteps }; +}; + +interface RuleFormHorizontalSteps { + steps: EuiStepsHorizontalProps['steps']; + currentStepComponent: React.ReactNode; + goToNextStep: () => void; + goToPreviousStep: () => void; + hasNextStep: boolean; + hasPreviousStep: boolean; +} +export const useRuleFormHorizontalSteps: () => RuleFormHorizontalSteps = () => { + const [currentStep, setCurrentStep] = useState(STEP_ORDER[0]); + const [touchedSteps, setTouchedSteps] = useState>( + STEP_ORDER.reduce( + (result, stepId) => ({ ...result, [stepId]: false }), + {} as Record + ) + ); + + const { steps, stepOrder } = useCommonRuleFormSteps({ + touchedSteps, + currentStep, + }); + + // Determine current navigation position + const currentStepIndex = useMemo(() => stepOrder.indexOf(currentStep), [currentStep, stepOrder]); + const hasNextStep = useMemo( + () => currentStep && currentStepIndex < stepOrder.length - 1, + [currentStepIndex, currentStep, stepOrder] + ); + const hasPreviousStep = useMemo( + () => currentStep && currentStepIndex > 0, + [currentStepIndex, currentStep] + ); + + // Navigation functions + const goToNextStep = useCallback(() => { + if (currentStep && hasNextStep) { + const currentIndex = stepOrder.indexOf(currentStep); + const nextStep = stepOrder[currentIndex + 1]; + + setTouchedSteps((prevTouchedSteps) => ({ + ...prevTouchedSteps, + [currentStep]: true, + })); + setCurrentStep(nextStep); + } + }, [currentStep, stepOrder, hasNextStep]); + const goToPreviousStep = useCallback(() => { + if (currentStep && hasPreviousStep) { + const currentIndex = stepOrder.indexOf(currentStep); + const previousStep = stepOrder[currentIndex - 1]; + setCurrentStep(previousStep); + } + }, [currentStep, stepOrder, hasPreviousStep]); + const jumpToStep = useCallback( + (stepId: RuleFormStepId) => () => { + setTouchedSteps((prevTouchedSteps) => ({ + ...prevTouchedSteps, + [currentStep]: true, + })); + setCurrentStep(stepId); + }, + [currentStep] + ); + + // Add onClick handlers to each step, remove children component as horizontal steps don't render children + const mappedSteps = useMemo(() => { + return stepOrder + .map((stepId) => { + const step = steps[stepId]; + return step + ? { + ...omit(step, 'children'), + onClick: jumpToStep(stepId), + } + : null; + }) + .filter(Boolean) as EuiStepsHorizontalProps['steps']; + }, [steps, stepOrder, jumpToStep]); + + return { + steps: mappedSteps, + // Horizontal steps only render one step at a time, so pass the current step's children + currentStepComponent: steps[currentStep]?.children, + goToNextStep, + goToPreviousStep, + hasNextStep, + hasPreviousStep, + }; +}; diff --git a/packages/response-ops/rule_form/src/rule_page/rule_page.test.tsx b/packages/response-ops/rule_form/src/rule_page/rule_page.test.tsx index 710f046adb28a..14c85782253db 100644 --- a/packages/response-ops/rule_form/src/rule_page/rule_page.test.tsx +++ b/packages/response-ops/rule_form/src/rule_page/rule_page.test.tsx @@ -29,12 +29,15 @@ jest.mock('../rule_details', () => ({ RuleDetails: () =>
, })); -jest.mock('../hooks', () => ({ +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'); +const { useRuleFormState } = jest.requireMock('../hooks/use_rule_form_state'); const navigateToUrl = jest.fn(); diff --git a/packages/response-ops/rule_form/src/rule_page/rule_page.tsx b/packages/response-ops/rule_form/src/rule_page/rule_page.tsx index 58f16403ecc12..52c25ee79a5d8 100644 --- a/packages/response-ops/rule_form/src/rule_page/rule_page.tsx +++ b/packages/response-ops/rule_form/src/rule_page/rule_page.tsx @@ -13,34 +13,25 @@ import { EuiConfirmModal, EuiFlexGroup, EuiFlexItem, - EuiHorizontalRule, EuiPageTemplate, EuiSpacer, EuiSteps, - EuiStepsProps, useEuiBackgroundColorCSS, } from '@elastic/eui'; import { checkActionFormActionTypeEnabled } from '@kbn/alerts-ui-shared'; import React, { useCallback, useMemo, useState } from 'react'; -import type { RuleFormData } from '../types'; -import { RuleActions } from '../rule_actions'; -import { RuleDefinition } from '../rule_definition'; -import { RuleDetails } from '../rule_details'; -import { RulePageFooter } from './rule_page_footer'; -import { RulePageNameInput } from './rule_page_name_input'; -import { useRuleFormState } from '../hooks'; +import { useRuleFormState, useRuleFormSteps } from '../hooks'; import { DISABLED_ACTIONS_WARNING_TITLE, RULE_FORM_CANCEL_MODAL_CANCEL, RULE_FORM_CANCEL_MODAL_CONFIRM, RULE_FORM_CANCEL_MODAL_DESCRIPTION, RULE_FORM_CANCEL_MODAL_TITLE, - RULE_FORM_PAGE_RULE_ACTIONS_TITLE, - RULE_FORM_PAGE_RULE_DEFINITION_TITLE, - RULE_FORM_PAGE_RULE_DETAILS_TITLE, RULE_FORM_RETURN_TITLE, } from '../translations'; -import { hasActionsError, hasActionsParamsErrors, hasParamsErrors } from '../validation'; +import type { RuleFormData } from '../types'; +import { RulePageFooter } from './rule_page_footer'; +import { RulePageNameInput } from './rule_page_name_input'; export interface RulePageProps { isEdit?: boolean; @@ -53,22 +44,12 @@ export const RulePage = (props: RulePageProps) => { const { isEdit = false, isSaving = false, onCancel = () => {}, onSave } = props; const [isCancelModalOpen, setIsCancelModalOpen] = useState(false); - const { - plugins: { application }, - baseErrors = {}, - paramsErrors = {}, - actionsErrors = {}, - actionsParamsErrors = {}, - formData, - multiConsumerSelection, - connectorTypes, - connectors, - touched, - } = useRuleFormState(); + const { formData, multiConsumerSelection, connectorTypes, connectors, touched } = + useRuleFormState(); - const { actions } = formData; + const { steps } = useRuleFormSteps(); - const canReadConnectors = !!application.capabilities.actions?.show; + const { actions } = formData; const styles = useEuiBackgroundColorCSS().transparent; @@ -102,63 +83,6 @@ export const RulePage = (props: RulePageProps) => { }); }, [actions, connectors, connectorTypes]); - const hasRuleDefinitionErrors = useMemo(() => { - return !!( - hasParamsErrors(paramsErrors) || - baseErrors.interval?.length || - baseErrors.alertDelay?.length - ); - }, [paramsErrors, baseErrors]); - - const hasActionErrors = useMemo(() => { - return hasActionsError(actionsErrors) || hasActionsParamsErrors(actionsParamsErrors); - }, [actionsErrors, actionsParamsErrors]); - - const hasRuleDetailsError = useMemo(() => { - return baseErrors.name?.length || baseErrors.tags?.length; - }, [baseErrors]); - - const actionComponent: EuiStepsProps['steps'] = useMemo(() => { - if (canReadConnectors) { - return [ - { - title: RULE_FORM_PAGE_RULE_ACTIONS_TITLE, - status: hasActionErrors ? 'danger' : undefined, - children: ( - <> - - - - - ), - }, - ]; - } - return []; - }, [hasActionErrors, canReadConnectors]); - - const steps: EuiStepsProps['steps'] = useMemo(() => { - return [ - { - title: RULE_FORM_PAGE_RULE_DEFINITION_TITLE, - status: hasRuleDefinitionErrors ? 'danger' : undefined, - children: , - }, - ...actionComponent, - { - title: RULE_FORM_PAGE_RULE_DETAILS_TITLE, - status: hasRuleDetailsError ? 'danger' : undefined, - children: ( - <> - - - - - ), - }, - ]; - }, [hasRuleDefinitionErrors, hasRuleDetailsError, actionComponent]); - return ( <>