diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/__stories__/rule_form_flyout.stories.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/__stories__/rule_form_flyout.stories.tsx index e56c3e7e9e934..cb2ea551173cd 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/__stories__/rule_form_flyout.stories.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/__stories__/rule_form_flyout.stories.tsx @@ -13,7 +13,7 @@ import { StandaloneRuleFormFlyout } from '../standalone_rule_form_flyout'; import { RuleFormFlyout } from '../rule_form_flyout'; import { DynamicRuleForm } from '../../form/dynamic_rule_form'; import { StandaloneRuleForm } from '../../form/standalone_rule_form'; -import type { RuleFormServices } from '../../form/contexts/rule_form_services_context'; +import type { RuleFormServices } from '../../form/contexts/rule_form_context'; const mockServices = { http: { diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/dynamic_rule_form_flyout.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/dynamic_rule_form_flyout.tsx index 07bbc6bd732ef..74766b8aa54c9 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/dynamic_rule_form_flyout.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/dynamic_rule_form_flyout.tsx @@ -43,11 +43,10 @@ const DynamicRuleFormFlyoutInner: React.FC = ({ const { createRule, isLoading } = useCreateRule({ http: services.http, notifications: services.notifications, - onSuccess: onClose, }); const handleSubmit = (values: FormValues) => { - createRule(values); + createRule(values, { onSuccess: onClose }); }; return ( @@ -57,6 +56,7 @@ const DynamicRuleFormFlyoutInner: React.FC = ({ isSubmitting={isLoading} query={query} services={services} + layout="flyout" /> ); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/rule_form_flyout.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/rule_form_flyout.tsx index 8ca4434be8bc6..ba86b88d8e794 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/rule_form_flyout.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/rule_form_flyout.tsx @@ -51,7 +51,7 @@ export const RuleFormFlyout: React.FC = ({ type={push ? 'push' : 'overlay'} onClose={onClose || (() => {})} aria-labelledby={FLYOUT_TITLE_ID} - size="m" + size="l" maxWidth={600} > diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/standalone_rule_form_flyout.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/standalone_rule_form_flyout.tsx index 9caa44a6a4b6d..23c647f0dd14f 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/standalone_rule_form_flyout.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/flyout/standalone_rule_form_flyout.tsx @@ -43,11 +43,10 @@ const StandaloneRuleFormFlyoutInner: React.FC = ( const { createRule, isLoading } = useCreateRule({ http: services.http, notifications: services.notifications, - onSuccess: onClose ?? (() => {}), }); const handleSubmit = (values: FormValues) => { - createRule(values); + createRule(values, { onSuccess: onClose }); }; return ( @@ -57,6 +56,7 @@ const StandaloneRuleFormFlyoutInner: React.FC = ( isSubmitting={isLoading} query={query} services={services} + layout="flyout" /> ); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/components/edit_mode_toggle.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/components/edit_mode_toggle.tsx index 34edad8c84887..460ebe9afebbf 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/components/edit_mode_toggle.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/components/edit_mode_toggle.tsx @@ -23,6 +23,7 @@ const toggleButtons = [ label: i18n.translate('xpack.alertingV2.ruleForm.editMode.form', { defaultMessage: 'Form', }), + iconType: 'productDashboard', 'data-test-subj': 'ruleV2FormEditModeFormButton', }, { @@ -30,6 +31,7 @@ const toggleButtons = [ label: i18n.translate('xpack.alertingV2.ruleForm.editMode.yaml', { defaultMessage: 'YAML', }), + iconType: 'code', 'data-test-subj': 'ruleV2FormEditModeYamlButton', }, ]; @@ -48,6 +50,7 @@ export const EditModeToggle: React.FC = ({ editMode, onChan idSelected={editMode} onChange={handleChange} buttonSize="compressed" + isIconOnly isFullWidth={false} isDisabled={disabled} data-test-subj="ruleV2FormEditModeToggle" diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/index.ts index fa22abbd94333..051fa3fb5d6d2 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/index.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/index.ts @@ -6,7 +6,10 @@ */ export { - RuleFormServicesProvider, + RuleFormProvider, useRuleFormServices, + useRuleFormMeta, type RuleFormServices, -} from './rule_form_services_context'; + type RuleFormMeta, + type RuleFormLayout, +} from './rule_form_context'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/rule_form_context.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/rule_form_context.tsx new file mode 100644 index 0000000000000..2282403dedae1 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/rule_form_context.tsx @@ -0,0 +1,68 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PropsWithChildren } from 'react'; +import React, { createContext, useContext, useMemo } from 'react'; +import type { ApplicationStart, HttpStart, NotificationsStart } from '@kbn/core/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; + +export interface RuleFormServices { + http: HttpStart; + data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; + notifications: NotificationsStart; + application: ApplicationStart; +} + +export type RuleFormLayout = 'page' | 'flyout'; + +export interface RuleFormMeta { + /** Whether the form is rendered on a full page or inside a flyout. */ + layout: RuleFormLayout; +} + +interface RuleFormContextValue { + services: RuleFormServices; + meta: RuleFormMeta; +} + +const DEFAULT_META: RuleFormMeta = { layout: 'page' }; + +const RuleFormContext = createContext(undefined); + +/** + * Provides services and metadata to all rule form descendants. + * + * `meta` defaults to `{ layout: 'page' }` when omitted. + */ +export const RuleFormProvider: React.FC< + PropsWithChildren<{ services: RuleFormServices; meta?: RuleFormMeta }> +> = ({ children, services, meta = DEFAULT_META }) => { + const value = useMemo(() => ({ services, meta }), [services, meta]); + return {children}; +}; + +const useRuleFormContext = (): RuleFormContextValue => { + const context = useContext(RuleFormContext); + if (!context) { + throw new Error('useRuleFormContext must be used within RuleFormProvider'); + } + return context; +}; + +/** Backward-compatible hook that returns only the services object. */ +export const useRuleFormServices = (): RuleFormServices => { + const { services } = useRuleFormContext(); + return services; +}; + +/** Returns the form metadata (layout, etc.). */ +export const useRuleFormMeta = (): RuleFormMeta => { + const { meta } = useRuleFormContext(); + return meta; +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/rule_form_services_context.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/rule_form_services_context.tsx deleted file mode 100644 index 1fb3e5189b5e8..0000000000000 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/contexts/rule_form_services_context.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { PropsWithChildren } from 'react'; -import React, { createContext, useContext } from 'react'; -import type { ApplicationStart, HttpStart, NotificationsStart } from '@kbn/core/public'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; - -export interface RuleFormServices { - http: HttpStart; - data: DataPublicPluginStart; - dataViews: DataViewsPublicPluginStart; - notifications: NotificationsStart; - application: ApplicationStart; -} - -const RuleFormServicesContext = createContext(undefined); - -export const RuleFormServicesProvider: React.FC< - PropsWithChildren<{ services: RuleFormServices }> -> = ({ children, services }) => ( - {children} -); - -export const useRuleFormServices = (): RuleFormServices => { - const context = useContext(RuleFormServicesContext); - if (!context) { - throw new Error('useRuleFormServices must be used within RuleFormServicesProvider'); - } - return context; -}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/dynamic_rule_form.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/dynamic_rule_form.test.tsx index 4d615359b8396..826caa6a9da55 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/dynamic_rule_form.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/dynamic_rule_form.test.tsx @@ -79,8 +79,7 @@ describe('DynamicRuleForm', () => { ); // The form should render - expect(screen.getByText('Name')).toBeInTheDocument(); - expect(screen.getByText('Rule details')).toBeInTheDocument(); + expect(screen.getByText('Untitled rule')).toBeInTheDocument(); }); it('updates form state when query prop changes', async () => { @@ -101,7 +100,7 @@ describe('DynamicRuleForm', () => { // The form should update - we can verify by checking that no errors occurred // and the component re-rendered successfully await waitFor(() => { - expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Untitled rule')).toBeInTheDocument(); }); }); @@ -115,12 +114,23 @@ describe('DynamicRuleForm', () => { ); - // User modifies the name field - const nameInput = screen.getByRole('textbox', { name: 'Name' }); - await user.type(nameInput, 'My Custom Rule'); + // User modifies the name field — click to enter edit mode, then type + const readModeButton = screen.getByTestId('euiInlineReadModeButton'); + await user.click(readModeButton); + + // Find the name input (not the combo box input) and replace content + const nameInput = screen.getByLabelText('Edit rule name'); + + // Select all text and replace with new value + await user.tripleClick(nameInput); + await user.keyboard('My Custom Rule'); expect(nameInput).toHaveValue('My Custom Rule'); + // Save the edit + const saveButton = screen.getByTestId('euiInlineEditModeSaveButton'); + await user.click(saveButton); + // Query prop changes (simulating Discover updating the query) rerender( @@ -130,7 +140,7 @@ describe('DynamicRuleForm', () => { // User's input should be preserved (keepDirtyValues: true) await waitFor(() => { - expect(nameInput).toHaveValue('My Custom Rule'); + expect(screen.getByText('My Custom Rule')).toBeInTheDocument(); }); }); @@ -170,7 +180,7 @@ describe('DynamicRuleForm', () => { // Form should have updated - component renders without errors await waitFor(() => { - expect(screen.getByText('Rule details')).toBeInTheDocument(); + expect(screen.getByText('Untitled rule')).toBeInTheDocument(); }); }); @@ -185,7 +195,7 @@ describe('DynamicRuleForm', () => { ); // Form should still render - expect(screen.getByText('Rule details')).toBeInTheDocument(); + expect(screen.getByText('Untitled rule')).toBeInTheDocument(); }); it('handles query prop changes from invalid to valid', () => { @@ -204,7 +214,7 @@ describe('DynamicRuleForm', () => { ); // Form should still render - expect(screen.getByText('Rule details')).toBeInTheDocument(); + expect(screen.getByText('Untitled rule')).toBeInTheDocument(); }); it('calls onSubmit with form values when form is submitted', async () => { @@ -222,9 +232,19 @@ describe('DynamicRuleForm', () => { ); - // Fill in required field - const nameInput = screen.getByRole('textbox', { name: 'Name' }); - await user.type(nameInput, 'Test Rule'); + // Fill in required field — click inline edit title, then type + const readModeButton = screen.getByTestId('euiInlineReadModeButton'); + await user.click(readModeButton); + + const nameInput = screen.getByLabelText('Edit rule name'); + + // Select all and replace with new value + await user.tripleClick(nameInput); + await user.keyboard('Test Rule'); + + // Save the edit + const saveButton = screen.getByTestId('euiInlineEditModeSaveButton'); + await user.click(saveButton); // Submit the form using the constant RULE_FORM_ID const form = document.getElementById(RULE_FORM_ID); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/dynamic_rule_form.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/dynamic_rule_form.tsx index 67609336e5af2..54a9aa64fd34c 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/dynamic_rule_form.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/dynamic_rule_form.tsx @@ -11,13 +11,15 @@ import { i18n } from '@kbn/i18n'; import { validateEsqlQuery } from '@kbn/alerting-v2-schemas'; import type { FormValues } from './types'; import { RuleForm } from './rule_form'; -import type { RuleFormServices } from './contexts'; +import type { RuleFormServices, RuleFormLayout } from './contexts'; import { useFormDefaults } from './hooks/use_form_defaults'; export interface DynamicRuleFormProps { /** The query that drives form values - changes will sync to form state */ query: string; services: RuleFormServices; + /** Layout mode: 'page' renders the preview side-by-side; 'flyout' uses a nested flyout. Default: 'page'. */ + layout?: RuleFormLayout; /** * External submit handler. When provided, form submission delegates to this callback. * When omitted, the form uses `useCreateRule` internally. @@ -53,6 +55,7 @@ export interface DynamicRuleFormProps { export const DynamicRuleForm: React.FC = ({ query, services, + layout, onSubmit, onSuccess, includeYaml = false, @@ -90,6 +93,7 @@ export const DynamicRuleForm: React.FC = ({ /> ({ ), })); +jest.mock('../fields/alert_delay_field', () => ({ + AlertDelayField: () =>
Alert Delay Field
, +})); + +jest.mock('../fields/recovery_delay_field', () => ({ + RecoveryDelayField: () =>
Recovery Delay Field
, +})); + describe('AlertConditionsFieldGroup', () => { - it('renders the field group with title', () => { - const Wrapper = createFormWrapper(); + it('renders the field group with title when kind is alert', () => { + const Wrapper = createFormWrapper({ kind: 'alert' }); render( @@ -40,8 +48,44 @@ describe('AlertConditionsFieldGroup', () => { expect(screen.getByText('Alert conditions')).toBeInTheDocument(); }); + it('does not render when kind is signal', () => { + const Wrapper = createFormWrapper({ kind: 'signal' }); + + render( + + + + ); + + expect(screen.queryByText('Alert conditions')).not.toBeInTheDocument(); + }); + + it('renders the alert delay field', () => { + const Wrapper = createFormWrapper({ kind: 'alert' }); + + render( + + + + ); + + expect(screen.getByTestId('mockAlertDelayField')).toBeInTheDocument(); + }); + + it('renders the recovery delay field', () => { + const Wrapper = createFormWrapper({ kind: 'alert' }); + + render( + + + + ); + + expect(screen.getByTestId('mockRecoveryDelayField')).toBeInTheDocument(); + }); + it('renders the recovery type field', () => { - const Wrapper = createFormWrapper(); + const Wrapper = createFormWrapper({ kind: 'alert' }); render( @@ -54,6 +98,7 @@ describe('AlertConditionsFieldGroup', () => { it('does not render recovery fields when type is no_breach', () => { const Wrapper = createFormWrapper({ + kind: 'alert', recoveryPolicy: { type: 'no_breach' }, }); @@ -69,6 +114,7 @@ describe('AlertConditionsFieldGroup', () => { it('renders RecoveryBaseQueryOnlyField when type is query and no evaluation condition exists', () => { const Wrapper = createFormWrapper({ + kind: 'alert', recoveryPolicy: { type: 'query' }, evaluation: { query: { base: 'FROM logs | STATS count() BY host' } }, }); @@ -85,6 +131,7 @@ describe('AlertConditionsFieldGroup', () => { it('renders RecoveryBaseAndConditionField when type is query and evaluation condition exists', () => { const Wrapper = createFormWrapper({ + kind: 'alert', recoveryPolicy: { type: 'query' }, evaluation: { query: { @@ -106,6 +153,7 @@ describe('AlertConditionsFieldGroup', () => { it('always renders recovery type field regardless of type', () => { const Wrapper = createFormWrapper({ + kind: 'alert', recoveryPolicy: { type: 'query' }, }); @@ -120,6 +168,7 @@ describe('AlertConditionsFieldGroup', () => { it('falls back to RecoveryBaseQueryOnlyField when evaluation condition is only whitespace', () => { const Wrapper = createFormWrapper({ + kind: 'alert', recoveryPolicy: { type: 'query' }, evaluation: { query: { diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/alert_conditions_field_group.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/alert_conditions_field_group.tsx index c623c152bb9b4..e87d857d76838 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/alert_conditions_field_group.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/alert_conditions_field_group.tsx @@ -16,33 +16,44 @@ import { RecoveryBaseQueryOnlyField } from '../fields/recovery_base_query_only_f import { RecoveryBaseAndConditionField } from '../fields/recovery_base_and_condition_field'; import { useRuleFormServices } from '../contexts'; import { useRecoveryValidation } from '../hooks/use_recovery_validation'; +import { AlertDelayField } from '../fields/alert_delay_field'; +import { RecoveryDelayField } from '../fields/recovery_delay_field'; /** - * Alert conditions field group for configuring recovery policy. + * Alert conditions field group for configuring alert and recovery policies. * * Displays: + * - Alert delay (pending state transition: immediate / breaches / duration) * - A dropdown to select recovery type (no_breach vs. custom query) * - When `query` type is selected: * - If an evaluation condition (WHERE clause) exists: * uses RecoveryBaseAndConditionField (split mode with WHERE clause editor) * - If no evaluation condition exists: * uses RecoveryBaseQueryOnlyField (full ES|QL editor with "not same as eval" validation) + * - Recovery delay (recovering state transition: immediate / breaches / duration) */ export const AlertConditionsFieldGroup: React.FC = () => { const { control } = useFormContext(); const { data } = useRuleFormServices(); + const kind = useWatch({ control, name: 'kind' }); const recoveryType = useWatch({ control, name: 'recoveryPolicy.type' }); const recoveryValidation = useRecoveryValidation({ search: data.search.search, }); + if (kind !== 'alert') { + return null; + } + return ( + + {recoveryType === 'query' && ( <> @@ -54,6 +65,8 @@ export const AlertConditionsFieldGroup: React.FC = () => { )} )} + + ); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/condition_field_group.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/condition_field_group.tsx index b3c9bb7c03c3c..118d53a09c251 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/condition_field_group.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/condition_field_group.tsx @@ -7,12 +7,14 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiText, EuiSpacer, EuiFormRow, EuiCodeBlock } from '@elastic/eui'; +import { EuiSpacer, EuiFormRow, EuiCodeBlock } from '@elastic/eui'; import { useFormContext, useWatch } from 'react-hook-form'; import type { FormValues } from '../types'; import { FieldGroup } from './field_group'; import { WhereClauseEditor } from '../fields/where_clause_editor'; import { EvaluationQueryField } from '../fields/evaluation_query_field'; +import { GroupFieldSelect } from '../fields/group_field_select'; +import { TimeFieldSelect } from '../fields/time_field_select'; interface ConditionFieldGroupProps { /** @@ -47,14 +49,6 @@ export const ConditionFieldGroup: React.FC = ({ defaultMessage: 'Rule evaluation', })} > - - {i18n.translate('xpack.alertingV2.ruleForm.conditionDescription', { - defaultMessage: - 'The condition determines when this rule should trigger an alert. Define a WHERE clause condition (e.g., count > 100).', - })} - - - {includeBase ? ( // Editable base query <> @@ -100,6 +94,8 @@ export const ConditionFieldGroup: React.FC = ({ disabled={!baseQuery} fullWidth={true} /> + + ); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/field_group.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/field_group.tsx index 634b11015f481..497fd47bd3278 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/field_group.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/field_group.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiTitle, EuiSplitPanel } from '@elastic/eui'; interface FieldGroupProps { title: string; @@ -15,14 +15,15 @@ interface FieldGroupProps { export const FieldGroup: React.FC = ({ title, children }) => { return ( - <> - -

- {title} -

-
- - {children} - + + + +

+ {title} +

+
+
+ {children} +
); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/index.ts index 043d3d534a7ce..4a2b4e0237a2d 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/index.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/index.ts @@ -8,4 +8,3 @@ export { ConditionFieldGroup } from './condition_field_group'; export { RuleDetailsFieldGroup } from './rule_details_field_group'; export { RuleExecutionFieldGroup } from './rule_execution_field_group'; -export { StateTransitionFieldGroup } from './state_transition_field_group'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_details_field_group.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_details_field_group.test.tsx index abc99cdc2b2e2..61643bf5de6e5 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_details_field_group.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_details_field_group.test.tsx @@ -12,19 +12,22 @@ import { createFormWrapper } from '../../test_utils'; import { RuleDetailsFieldGroup } from './rule_details_field_group'; describe('RuleDetailsFieldGroup', () => { - it('renders the field group with title', () => { + it('renders without a field group wrapper', () => { const Wrapper = createFormWrapper(); - render( + const { container } = render( ); - expect(screen.getByText('Rule details')).toBeInTheDocument(); + // Should not render the FieldGroup panel + expect(container.querySelector('.euiSplitPanel')).not.toBeInTheDocument(); + // Should not render a "Rule details" title + expect(screen.queryByText('Rule details')).not.toBeInTheDocument(); }); - it('renders the name field', () => { + it('renders the tags field with optional label', () => { const Wrapper = createFormWrapper(); render( @@ -33,20 +36,8 @@ describe('RuleDetailsFieldGroup', () => {
); - expect(screen.getByText('Name')).toBeInTheDocument(); - expect(screen.getByRole('textbox', { name: 'Name' })).toBeInTheDocument(); - }); - - it('renders the labels field', () => { - const Wrapper = createFormWrapper(); - - render( - - - - ); - - expect(screen.getByText('Labels')).toBeInTheDocument(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('optional')).toBeInTheDocument(); }); it('renders the add description button initially', () => { @@ -75,7 +66,7 @@ describe('RuleDetailsFieldGroup', () => { expect(screen.getByText('Description')).toBeInTheDocument(); }); - it('renders the enabled field', () => { + it('does not render enabled or kind fields', () => { const Wrapper = createFormWrapper(); render( @@ -84,93 +75,10 @@ describe('RuleDetailsFieldGroup', () => {
); - expect(screen.getByText('Enabled')).toBeInTheDocument(); - expect(screen.getByRole('switch')).toBeInTheDocument(); - }); - - it('renders the kind field', () => { - const Wrapper = createFormWrapper(); - - render( - - - - ); - - // "Rule kind" appears in both label and legend (for screen readers) - expect(screen.getAllByText('Rule kind').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('Alert')).toBeInTheDocument(); - expect(screen.getByText('Monitor')).toBeInTheDocument(); - }); - - it('allows entering a name', async () => { - const Wrapper = createFormWrapper(); - - render( - - - - ); - - const nameInput = screen.getByRole('textbox', { name: 'Name' }); - await userEvent.type(nameInput, 'My Test Rule'); - - expect(nameInput).toHaveValue('My Test Rule'); - }); - - it('allows toggling enabled state', async () => { - const Wrapper = createFormWrapper(); - - render( - - - - ); - - const toggle = screen.getByRole('switch'); - expect(toggle).toBeChecked(); - - await userEvent.click(toggle); - - expect(toggle).not.toBeChecked(); - }); - - it('allows switching rule kind', async () => { - const Wrapper = createFormWrapper(); - - render( - - - - ); - - // Default is 'alert', so Alert button should be selected - const monitorButton = screen.getByText('Monitor'); - await userEvent.click(monitorButton); - - // The button group should now have Monitor selected - expect(monitorButton.closest('button')).toHaveClass('euiButtonGroupButton-isSelected'); - }); - - it('renders with pre-filled values', () => { - const Wrapper = createFormWrapper({ - metadata: { - name: 'Pre-filled Rule', - enabled: false, - }, - kind: 'signal', - }); - - render( - - - - ); - - expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('Pre-filled Rule'); - // Monitor should be selected (signal kind displays as "Monitor") - expect(screen.getByText('Monitor').closest('button')).toHaveClass( - 'euiButtonGroupButton-isSelected' - ); + expect(screen.queryByText('Enabled')).not.toBeInTheDocument(); + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + expect(screen.queryByText('Rule kind')).not.toBeInTheDocument(); + expect(screen.queryByText('Alert')).not.toBeInTheDocument(); + expect(screen.queryByText('Monitor')).not.toBeInTheDocument(); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_details_field_group.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_details_field_group.tsx index 2bad6d2177df1..9afd450f9d0c7 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_details_field_group.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_details_field_group.tsx @@ -6,26 +6,14 @@ */ import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { FieldGroup } from './field_group'; -import { NameField } from '../fields/name_field'; import { TagsField } from '../fields/tags_field'; import { DescriptionField } from '../fields/description_field'; -import { EnabledField } from '../fields/enabled_field'; -import { KindField } from '../fields/kind_field'; export const RuleDetailsFieldGroup: React.FC = () => { return ( - - + <> - - - + ); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_execution_field_group.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_execution_field_group.tsx index 5797b023b395a..5b4930eacf285 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_execution_field_group.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/rule_execution_field_group.tsx @@ -9,9 +9,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FieldGroup } from './field_group'; import { ScheduleField } from '../fields/schedule_field'; -import { TimeFieldSelect } from '../fields/time_field_select'; import { LookbackWindowField } from '../fields/lookback_window_field'; -import { GroupFieldSelect } from '../fields/group_field_select'; export const RuleExecutionFieldGroup: React.FC = () => { return ( @@ -21,9 +19,7 @@ export const RuleExecutionFieldGroup: React.FC = () => { })} > - - ); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/state_transition_field_group.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/state_transition_field_group.test.tsx deleted file mode 100644 index 2230df7e94138..0000000000000 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/state_transition_field_group.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { StateTransitionFieldGroup } from './state_transition_field_group'; -import { createFormWrapper } from '../../test_utils'; - -describe('StateTransitionFieldGroup', () => { - it('renders immediate mode by default when kind is "alert"', () => { - render(, { - wrapper: createFormWrapper({ kind: 'alert' }), - }); - - expect(screen.getByText('Alert delay')).toBeInTheDocument(); - expect(screen.getByText('Immediate')).toBeInTheDocument(); - expect(screen.getByText('Breaches')).toBeInTheDocument(); - expect(screen.getByText('Duration')).toBeInTheDocument(); - expect(screen.getByTestId('stateTransitionImmediateDescription')).toBeInTheDocument(); - expect(screen.queryByTestId('stateTransitionCountInput')).not.toBeInTheDocument(); - expect(screen.queryByTestId('stateTransitionTimeframeNumberInput')).not.toBeInTheDocument(); - }); - - it('shows breaches input when breaches is selected', () => { - render(, { - wrapper: createFormWrapper({ kind: 'alert' }), - }); - - fireEvent.click(screen.getByRole('button', { name: 'Breaches' })); - - expect(screen.getByTestId('stateTransitionCountInput')).toBeInTheDocument(); - expect(screen.getByTestId('stateTransitionCountInput')).toHaveValue(2); - expect(screen.queryByTestId('stateTransitionImmediateDescription')).not.toBeInTheDocument(); - expect(screen.queryByTestId('stateTransitionTimeframeNumberInput')).not.toBeInTheDocument(); - }); - - it('does not render when kind is "signal"', () => { - render(, { - wrapper: createFormWrapper({ kind: 'signal' }), - }); - - expect(screen.queryByText('Alert delay')).not.toBeInTheDocument(); - }); - - it('shows immediate mode text when immediate is selected', () => { - render(, { - wrapper: createFormWrapper({ kind: 'alert' }), - }); - - fireEvent.click(screen.getByRole('button', { name: 'Immediate' })); - - expect(screen.getByTestId('stateTransitionImmediateDescription')).toBeInTheDocument(); - expect(screen.queryByTestId('stateTransitionCountInput')).not.toBeInTheDocument(); - expect(screen.queryByTestId('stateTransitionTimeframeNumberInput')).not.toBeInTheDocument(); - }); - - it('shows duration inputs when duration is selected', () => { - render(, { - wrapper: createFormWrapper({ kind: 'alert' }), - }); - - fireEvent.click(screen.getByRole('button', { name: 'Duration' })); - - expect(screen.getByTestId('stateTransitionTimeframeNumberInput')).toBeInTheDocument(); - expect(screen.getByTestId('stateTransitionTimeframeUnitInput')).toBeInTheDocument(); - expect(screen.getByTestId('stateTransitionTimeframeNumberInput')).toHaveValue(2); - expect(screen.getByTestId('stateTransitionTimeframeUnitInput')).toHaveValue('m'); - expect(screen.queryByTestId('stateTransitionCountInput')).not.toBeInTheDocument(); - }); -}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/alert_delay_field.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/alert_delay_field.test.tsx new file mode 100644 index 0000000000000..fd413df62669c --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/alert_delay_field.test.tsx @@ -0,0 +1,105 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AlertDelayField } from './alert_delay_field'; +import { createFormWrapper } from '../../test_utils'; + +describe('AlertDelayField', () => { + it('renders the alert delay form row', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + expect(screen.getByTestId('alertDelayFormRow')).toBeInTheDocument(); + }); + + it('renders with label "Alert delay"', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + expect(screen.getByText('Alert delay')).toBeInTheDocument(); + }); + + it('defaults to immediate mode', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + expect(screen.getByTestId('stateTransitionImmediateDescription')).toBeInTheDocument(); + expect(screen.getByText('No delay - Alerts on first breach')).toBeInTheDocument(); + }); + + it('derives breaches mode from form state with pendingCount', () => { + render(, { + wrapper: createFormWrapper({ + kind: 'alert', + stateTransition: { pendingCount: 3 }, + }), + }); + + expect(screen.getByTestId('stateTransitionCountInput')).toBeInTheDocument(); + }); + + it('derives duration mode from form state with pendingTimeframe', () => { + render(, { + wrapper: createFormWrapper({ + kind: 'alert', + stateTransition: { pendingTimeframe: '10m' }, + }), + }); + + expect(screen.getByTestId('stateTransitionTimeframeNumberInput')).toBeInTheDocument(); + }); + + it('switches to breaches mode when Breaches button is clicked', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + fireEvent.click(screen.getByText('Breaches')); + + expect(screen.getByTestId('stateTransitionCountInput')).toBeInTheDocument(); + expect(screen.queryByTestId('stateTransitionImmediateDescription')).not.toBeInTheDocument(); + }); + + it('switches to duration mode when Duration button is clicked', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + fireEvent.click(screen.getByText('Duration')); + + expect(screen.getByTestId('stateTransitionTimeframeNumberInput')).toBeInTheDocument(); + expect(screen.queryByTestId('stateTransitionImmediateDescription')).not.toBeInTheDocument(); + }); + + it('switches back to immediate mode', () => { + render(, { + wrapper: createFormWrapper({ + kind: 'alert', + stateTransition: { pendingCount: 3 }, + }), + }); + + // Start in breaches mode, switch to immediate + fireEvent.click(screen.getByText('Immediate')); + + expect(screen.getByTestId('stateTransitionImmediateDescription')).toBeInTheDocument(); + expect(screen.queryByTestId('stateTransitionCountInput')).not.toBeInTheDocument(); + }); + + it('renders the mode toggle button group', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + expect(screen.getByTestId('stateTransitionDelayMode')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/state_transition_field_group.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/alert_delay_field.tsx similarity index 50% rename from x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/state_transition_field_group.tsx rename to x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/alert_delay_field.tsx index 11b0c014135da..9af63a05f897b 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/field_groups/state_transition_field_group.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/alert_delay_field.tsx @@ -6,32 +6,31 @@ */ import React, { useCallback, useState } from 'react'; -import { EuiButtonGroup, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiButtonGroup, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useFormContext, useWatch } from 'react-hook-form'; import type { FormValues } from '../types'; -import { FieldGroup } from './field_group'; -import { StateTransitionCountField } from '../fields/state_transition_count_field'; -import { StateTransitionTimeframeField } from '../fields/state_transition_timeframe_field'; +import { StateTransitionCountField } from './state_transition_count_field'; +import { StateTransitionTimeframeField } from './state_transition_timeframe_field'; type DelayMode = 'immediate' | 'breaches' | 'duration'; const MODE_OPTIONS = [ { id: 'immediate' as const, - label: i18n.translate('xpack.alertingV2.ruleForm.stateTransition.delayModeImmediate', { + label: i18n.translate('xpack.alertingV2.ruleForm.alertDelay.delayModeImmediate', { defaultMessage: 'Immediate', }), }, { id: 'breaches' as const, - label: i18n.translate('xpack.alertingV2.ruleForm.stateTransition.delayModeBreaches', { + label: i18n.translate('xpack.alertingV2.ruleForm.alertDelay.delayModeBreaches', { defaultMessage: 'Breaches', }), }, { id: 'duration' as const, - label: i18n.translate('xpack.alertingV2.ruleForm.stateTransition.delayModeDuration', { + label: i18n.translate('xpack.alertingV2.ruleForm.alertDelay.delayModeDuration', { defaultMessage: 'Duration', }), }, @@ -49,9 +48,8 @@ const deriveMode = (stateTransition?: { return 'immediate'; }; -export const StateTransitionFieldGroup: React.FC = () => { +export const AlertDelayField: React.FC = () => { const { control, setValue } = useFormContext(); - const kind = useWatch({ control, name: 'kind' }); const stateTransition = useWatch({ control, name: 'stateTransition' }); const [selectedMode, setSelectedMode] = useState(deriveMode(stateTransition)); @@ -60,7 +58,8 @@ export const StateTransitionFieldGroup: React.FC = () => { switch (mode as DelayMode) { case 'immediate': setSelectedMode('immediate'); - setValue('stateTransition', undefined); + setValue('stateTransition.pendingCount', undefined); + setValue('stateTransition.pendingTimeframe', undefined); break; case 'breaches': setSelectedMode('breaches'); @@ -83,51 +82,53 @@ export const StateTransitionFieldGroup: React.FC = () => { [setValue, stateTransition?.pendingCount, stateTransition?.pendingTimeframe] ); - if (kind !== 'alert') { - return null; - } - return ( - - - - {selectedMode === 'immediate' && ( - - {i18n.translate('xpack.alertingV2.ruleForm.stateTransition.immediateDescription', { - defaultMessage: 'No delay - Alerts on first breach', + <> + - )} - {selectedMode === 'breaches' && ( - - )} - {selectedMode === 'duration' && ( - - )} - + + {selectedMode === 'immediate' && ( + + {i18n.translate('xpack.alertingV2.ruleForm.alertDelay.immediateDescription', { + defaultMessage: 'No delay - Alerts on first breach', + })} + + )} + {selectedMode === 'breaches' && ( + + )} + {selectedMode === 'duration' && ( + + )} + + ); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/group_field_select.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/group_field_select.tsx index 041134c4bac88..8e689d16b69ad 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/group_field_select.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/group_field_select.tsx @@ -58,6 +58,9 @@ export const GroupFieldSelect: React.FC = () => { label={i18n.translate('xpack.alertingV2.ruleForm.groupingKeyLabel', { defaultMessage: 'Group Fields', })} + labelAppend={i18n.translate('xpack.alertingV2.ruleForm.groupingKeyOptional', { + defaultMessage: 'optional', + })} isInvalid={!!error} error={error?.message} fullWidth diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.test.tsx index 6de71003a445e..da1810ebef9f8 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.test.tsx @@ -6,62 +6,59 @@ */ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { KindField } from './kind_field'; import { createFormWrapper } from '../../test_utils'; describe('KindField', () => { - it('renders the rule kind label', () => { + it('renders the checkbox label', () => { render(, { wrapper: createFormWrapper() }); - // "Rule kind" appears twice (form label + button group legend), so use getAllByText - expect(screen.getAllByText('Rule kind')).toHaveLength(2); + expect(screen.getByText('Track active and recovered state over time')).toBeInTheDocument(); }); - it('renders help text', () => { + it('renders the description text', () => { render(, { wrapper: createFormWrapper() }); - expect( - screen.getByText('Choose whether this rule creates monitors or alerts.') - ).toBeInTheDocument(); + expect(screen.getByText(/Enables lifecycle management/)).toBeInTheDocument(); }); - it('renders Alert and Monitor options', () => { - render(, { wrapper: createFormWrapper() }); - - expect(screen.getByText('Alert')).toBeInTheDocument(); - expect(screen.getByText('Monitor')).toBeInTheDocument(); - }); - - it('selects Alert by default', () => { + it('is checked when kind is alert (default)', () => { render(, { wrapper: createFormWrapper({ kind: 'alert' }) }); - const alertButton = screen.getByText('Alert').closest('button'); - expect(alertButton).toHaveClass('euiButtonGroupButton-isSelected'); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeChecked(); }); - it('selects Monitor when kind is signal', () => { + it('is unchecked when kind is signal', () => { render(, { wrapper: createFormWrapper({ kind: 'signal' }) }); - const monitorButton = screen.getByText('Monitor').closest('button'); - expect(monitorButton).toHaveClass('euiButtonGroupButton-isSelected'); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); }); - it('updates value when user clicks Monitor', () => { + it('changes kind from alert to signal when unchecked', async () => { + const user = userEvent.setup(); render(, { wrapper: createFormWrapper({ kind: 'alert' }) }); - const monitorButton = screen.getByText('Monitor'); - fireEvent.click(monitorButton); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeChecked(); + + await user.click(checkbox); - expect(monitorButton.closest('button')).toHaveClass('euiButtonGroupButton-isSelected'); + expect(checkbox).not.toBeChecked(); }); - it('updates value when user clicks Alert', () => { + it('changes kind from signal to alert when checked', async () => { + const user = userEvent.setup(); render(, { wrapper: createFormWrapper({ kind: 'signal' }) }); - const alertButton = screen.getByText('Alert'); - fireEvent.click(alertButton); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); + + await user.click(checkbox); - expect(alertButton.closest('button')).toHaveClass('euiButtonGroupButton-isSelected'); + expect(checkbox).toBeChecked(); }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.tsx index f4ce54b5126bf..68a9bd2ec5543 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.tsx @@ -6,25 +6,12 @@ */ import React from 'react'; -import { EuiButtonGroup, EuiFormRow } from '@elastic/eui'; +import { EuiCheckableCard, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Controller, useFormContext } from 'react-hook-form'; import type { FormValues } from '../types'; -const KIND_OPTIONS: Array<{ id: FormValues['kind']; label: string }> = [ - { - id: 'alert', - label: i18n.translate('xpack.alertingV2.ruleForm.kindField.alertOption', { - defaultMessage: 'Alert', - }), - }, - { - id: 'signal', - label: i18n.translate('xpack.alertingV2.ruleForm.kindField.monitorOption', { - defaultMessage: 'Monitor', - }), - }, -]; +const CARD_ID = 'ruleV2KindField'; export const KindField: React.FC = () => { const { control } = useFormContext(); @@ -33,34 +20,33 @@ export const KindField: React.FC = () => { ( - -
- { + const isChecked = value === 'alert'; + + return ( + + {i18n.translate('xpack.alertingV2.ruleForm.kindField.checkboxLabel', { + defaultMessage: 'Track active and recovered state over time', + })} + + } + checked={isChecked} + onChange={() => onChange(isChecked ? 'signal' : 'alert')} + data-test-subj="kindField" + > + + {i18n.translate('xpack.alertingV2.ruleForm.kindField.checkboxDescription', { + defaultMessage: + 'Enables lifecycle management: the system will track state transitions across alert events for each series, manage episodes, and dispatch to notification policies. Without this, alert events are observation-only records.', })} - options={KIND_OPTIONS} - idSelected={value} - onChange={(id) => onChange(id)} - buttonSize="m" - color="primary" - isFullWidth - data-test-subj="kindField" - /> -
-
- )} + + + ); + }} /> ); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/name_field.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/name_field.test.tsx index e8eaccfba44bf..9ec1bbabc4ced 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/name_field.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/name_field.test.tsx @@ -15,16 +15,23 @@ import { createFormWrapper, createTestQueryClient, defaultTestFormValues } from import type { FormValues } from '../types'; describe('NameField', () => { - it('renders the name label', () => { + it('renders the default name text when no name is provided', () => { render(, { wrapper: createFormWrapper() }); - expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Untitled rule')).toBeInTheDocument(); }); - it('renders a text input', () => { + it('renders as an inline editable title', () => { render(, { wrapper: createFormWrapper() }); - expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByTestId('ruleNameInlineEdit')).toBeInTheDocument(); + }); + + it('renders the inline edit read mode button with pencil icon', () => { + render(, { wrapper: createFormWrapper() }); + + // EuiInlineEditTitle renders a button to activate edit mode + expect(screen.getByTestId('euiInlineReadModeButton')).toBeInTheDocument(); }); it('displays initial value from form context', () => { @@ -37,17 +44,21 @@ describe('NameField', () => { render(, { wrapper: Wrapper }); - expect(screen.getByRole('textbox')).toHaveValue('My Test Rule'); + expect(screen.getByText('My Test Rule')).toBeInTheDocument(); }); - it('updates value when user types', async () => { + it('enters edit mode when read mode button is clicked', async () => { const user = userEvent.setup(); render(, { wrapper: createFormWrapper() }); - const input = screen.getByRole('textbox'); - await user.type(input, 'New Rule Name'); + // Click the inline edit read mode button to enter edit mode + const readModeButton = screen.getByTestId('euiInlineReadModeButton'); + await user.click(readModeButton); - expect(input).toHaveValue('New Rule Name'); + // Now a text input should be visible in edit mode + await waitFor(() => { + expect(screen.getByLabelText('Edit rule name')).toBeInTheDocument(); + }); }); it('shows required error when submitted with empty value', async () => { diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/name_field.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/name_field.tsx index 7ab719b4cd066..390d7e24c8485 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/name_field.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/name_field.tsx @@ -7,14 +7,30 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { EuiInlineEditTitle, EuiFormRow, useEuiTheme, useGeneratedHtmlId } from '@elastic/eui'; +import { css } from '@emotion/react'; import { Controller, useFormContext } from 'react-hook-form'; import type { FormValues } from '../types'; -const NAME_ROW_ID = 'ruleV2FormNameField'; +const DEFAULT_NAME = i18n.translate('xpack.alertingV2.ruleForm.defaultRuleName', { + defaultMessage: 'Untitled rule', +}); export const NameField: React.FC = () => { const { control } = useFormContext(); + const { euiTheme } = useEuiTheme(); + const editTitleId = useGeneratedHtmlId({ prefix: 'ruleNameInlineEdit' }); + + const titleStyles = css` + .euiInlineEditForm { + .euiFieldText { + font-size: ${euiTheme.size.l}; + font-weight: ${euiTheme.font.weight.bold}; + height: auto; + padding: ${euiTheme.size.xs} ${euiTheme.size.s}; + } + } + `; return ( { defaultMessage: 'Name is required.', }), }} - render={({ field: { ref, ...field }, fieldState: { error } }) => ( - - - - )} + render={({ field: { value, onChange }, fieldState: { error } }) => { + const displayValue = value || DEFAULT_NAME; + + return ( + + { + const target = e.currentTarget as HTMLInputElement; + onChange(target.value); + }} + onCancel={(previousValue) => { + onChange(previousValue); + }} + css={titleStyles} + size="m" + isInvalid={!!error} + data-test-subj="ruleNameInlineEdit" + id={editTitleId} + isReadOnly={false} + editModeProps={{ + formRowProps: { + fullWidth: true, + }, + inputProps: { + fullWidth: true, + }, + }} + /> + + ); + }} /> ); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/query_results_grid.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/query_results_grid.test.tsx new file mode 100644 index 0000000000000..1a74c23301e13 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/query_results_grid.test.tsx @@ -0,0 +1,202 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { QueryResultsGrid, type QueryResultsGridProps } from './query_results_grid'; +import type { PreviewColumn } from '../hooks/use_preview'; + +const defaultColumns: PreviewColumn[] = [ + { id: '@timestamp', displayAsText: '@timestamp', esType: 'date' }, + { id: 'message', displayAsText: 'message', esType: 'keyword' }, +]; + +const defaultRows = [ + { '@timestamp': '2024-01-01T00:00:00Z', message: 'Error occurred' }, + { '@timestamp': '2024-01-01T00:01:00Z', message: 'Warning issued' }, +]; + +const defaultProps: QueryResultsGridProps = { + title: 'Test preview', + dataTestSubj: 'testPreviewGrid', + emptyBody: 'Configure something to see results.', + noResultsBody: 'The query returned no results.', + columns: defaultColumns, + rows: defaultRows, + totalRowCount: 2, + isLoading: false, + isError: false, + error: null, +}; + +const renderGrid = (overrides: Partial = {}) => + render( + + + + ); + +describe('QueryResultsGrid', () => { + it('renders the title', () => { + renderGrid(); + expect(screen.getByText('Test preview')).toBeInTheDocument(); + }); + + it('renders the data grid with the provided data-test-subj', () => { + renderGrid(); + expect(screen.getByTestId('testPreviewGrid')).toBeInTheDocument(); + }); + + it('renders row count note for multiple rows', () => { + renderGrid(); + expect(screen.getByText('Query returned 2 rows.')).toBeInTheDocument(); + }); + + it('renders singular row count note for a single row', () => { + renderGrid({ + rows: [defaultRows[0]], + totalRowCount: 1, + }); + expect(screen.getByText('Query returned 1 row.')).toBeInTheDocument(); + }); + + it('renders truncated note when totalRowCount exceeds displayed rows', () => { + renderGrid({ totalRowCount: 200 }); + expect(screen.getByText('Showing 2 of 200 rows returned by the query.')).toBeInTheDocument(); + }); + + it('renders a loading spinner when isLoading is true', () => { + renderGrid({ isLoading: true, columns: [], rows: [], totalRowCount: 0 }); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders the empty prompt when no query data is present', () => { + renderGrid({ columns: [], rows: [], totalRowCount: 0 }); + expect(screen.getByText('No preview available')).toBeInTheDocument(); + expect(screen.getByText('Configure something to see results.')).toBeInTheDocument(); + }); + + it('renders the no-results prompt when columns exist but rows are empty', () => { + renderGrid({ rows: [], totalRowCount: 0 }); + expect(screen.getByText('No results')).toBeInTheDocument(); + expect(screen.getByText('The query returned no results.')).toBeInTheDocument(); + }); + + it('renders the no-results prompt when hasValidQuery is true even with empty columns', () => { + renderGrid({ columns: [], rows: [], totalRowCount: 0, hasValidQuery: true }); + expect(screen.getByText('No results')).toBeInTheDocument(); + expect(screen.getByText('The query returned no results.')).toBeInTheDocument(); + expect(screen.queryByText('No preview available')).not.toBeInTheDocument(); + }); + + it('renders error callout when isError is true', () => { + renderGrid({ + isError: true, + error: 'Syntax error in query', + columns: [], + rows: [], + totalRowCount: 0, + }); + expect(screen.getByText('Preview failed')).toBeInTheDocument(); + expect(screen.getByText('Syntax error in query')).toBeInTheDocument(); + }); + + it('does not render error callout when isError is true but error is null', () => { + renderGrid({ + isError: true, + error: null, + columns: [], + rows: [], + totalRowCount: 0, + }); + expect(screen.queryByText('Preview failed')).not.toBeInTheDocument(); + }); + + describe('grouping annotations', () => { + const groupingColumns: PreviewColumn[] = [ + { id: 'host.name', displayAsText: 'host.name', esType: 'keyword' }, + { id: 'count', displayAsText: 'count', esType: 'long' }, + { id: '@timestamp', displayAsText: '@timestamp', esType: 'date' }, + ]; + + const groupingRows = [ + { 'host.name': 'host-1', count: '10', '@timestamp': '2024-01-01T00:00:00Z' }, + { 'host.name': 'host-2', count: '20', '@timestamp': '2024-01-01T00:01:00Z' }, + ]; + + it('renders the grouping field column name in the header', () => { + renderGrid({ + columns: groupingColumns, + rows: groupingRows, + totalRowCount: 2, + groupingFields: ['host.name'], + uniqueGroupCount: 2, + }); + + expect(screen.getByText('host.name')).toBeInTheDocument(); + }); + + it('renders unique group count badge when uniqueGroupCount is provided', () => { + renderGrid({ + columns: groupingColumns, + rows: groupingRows, + totalRowCount: 2, + groupingFields: ['host.name'], + uniqueGroupCount: 2, + }); + + expect(screen.getByText('2 unique groups')).toBeInTheDocument(); + }); + + it('renders singular group label for one group', () => { + renderGrid({ + columns: groupingColumns, + rows: groupingRows, + totalRowCount: 2, + groupingFields: ['host.name'], + uniqueGroupCount: 1, + }); + + expect(screen.getByText('1 unique group')).toBeInTheDocument(); + }); + + it('does not render unique group count badge when uniqueGroupCount is null', () => { + renderGrid({ + columns: groupingColumns, + rows: groupingRows, + totalRowCount: 2, + groupingFields: ['host.name'], + uniqueGroupCount: null, + }); + + expect(screen.queryByText(/unique group/)).not.toBeInTheDocument(); + }); + + it('does not render unique group count badge when uniqueGroupCount is not provided', () => { + renderGrid({ + columns: groupingColumns, + rows: groupingRows, + totalRowCount: 2, + }); + + expect(screen.queryByText(/unique group/)).not.toBeInTheDocument(); + }); + + it('does not render unique group count badge when groupingFields is empty', () => { + renderGrid({ + columns: groupingColumns, + rows: groupingRows, + totalRowCount: 2, + groupingFields: [], + uniqueGroupCount: null, + }); + + expect(screen.queryByText(/unique group/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/query_results_grid.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/query_results_grid.tsx new file mode 100644 index 0000000000000..59e9d8f19135f --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/query_results_grid.tsx @@ -0,0 +1,291 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { + EuiBadge, + EuiCallOut, + EuiDataGrid, + type EuiDataGridCellValueElementProps, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingSpinner, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import type { PreviewColumn } from '../hooks/use_preview'; + +const DEFAULT_PAGE_SIZE = 10; + +const gridStyles = css` + .euiDataGridHeaderCell__content { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +`; + +export interface QueryResultsGridProps { + /** Panel title displayed above the grid */ + title: string; + /** data-test-subj applied to the EuiDataGrid element */ + dataTestSubj: string; + /** Body text for the empty state (no query configured) */ + emptyBody: string; + /** Body text for the no-results state (query returned 0 rows) */ + noResultsBody: string; + /** Columns derived from the ES|QL response */ + columns: PreviewColumn[]; + /** Row data mapped from the ES|QL response values */ + rows: Array>; + /** Total row count (before truncation) */ + totalRowCount: number; + /** Whether the query is currently loading */ + isLoading: boolean; + /** Whether the query resulted in an error */ + isError: boolean; + /** Error message, if any */ + error: string | null; + /** Field names selected as the grouping key */ + groupingFields?: string[]; + /** Number of unique alert groups, or null if no grouping is configured */ + uniqueGroupCount?: number | null; + /** Whether the current query is syntactically valid (distinguishes "no query" from "valid query with 0 results") */ + hasValidQuery?: boolean; +} + +/** + * Shared query results grid panel. + * + * Renders a titled EuiPanel containing an EuiDataGrid with loading, empty, + * error, and success states. Annotates grouping columns with a key icon + * and displays a unique-group-count badge in the footer. + * + * Used by both the rule preview and recovery preview components. + */ +export const QueryResultsGrid: React.FC = ({ + title, + dataTestSubj, + emptyBody, + noResultsBody, + columns, + rows, + totalRowCount, + isLoading, + isError, + error, + groupingFields = [], + uniqueGroupCount, + hasValidQuery = false, +}) => { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE }); + + const onChangeItemsPerPage = useCallback( + (pageSize: number) => setPagination((prev) => ({ ...prev, pageSize, pageIndex: 0 })), + [] + ); + + const onChangePage = useCallback( + (pageIndex: number) => setPagination((prev) => ({ ...prev, pageIndex })), + [] + ); + + const groupingFieldSet = useMemo(() => new Set(groupingFields), [groupingFields]); + + // Annotate grouping columns with a key icon in the header + const annotatedColumns = useMemo( + () => + columns.map((col) => { + if (!groupingFieldSet.has(col.id)) { + return col; + } + return { + ...col, + display: ( + + + + + + + {col.displayAsText} + + ), + }; + }), + [columns, groupingFieldSet] + ); + + // Pin group key columns to the left of the grid + const visibleColumns = useMemo(() => { + const groupKeyCols = columns.filter((c) => groupingFieldSet.has(c.id)).map((c) => c.id); + const otherCols = columns.filter((c) => !groupingFieldSet.has(c.id)).map((c) => c.id); + return [...groupKeyCols, ...otherCols]; + }, [columns, groupingFieldSet]); + + const renderCellValue = useCallback( + ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { + const row = rows[rowIndex]; + if (!row) { + return null; + } + const value = row[columnId]; + return value ?? '-'; + }, + [rows] + ); + + const hasQuery = hasValidQuery || columns.length > 0 || rows.length > 0 || isLoading || isError; + + return ( + + + + +

{title}

+
+
+ {isLoading && ( + + + + )} +
+ + + + {isError && error && ( + <> + + {error} + + + + )} + + {!hasQuery && ( + + {i18n.translate('xpack.alertingV2.ruleForm.queryResultsGrid.emptyTitle', { + defaultMessage: 'No preview available', + })} + + } + body={ + + {emptyBody} + + } + /> + )} + + {hasQuery && !isError && rows.length === 0 && !isLoading && ( + + {i18n.translate('xpack.alertingV2.ruleForm.queryResultsGrid.noResultsTitle', { + defaultMessage: 'No results', + })} + + } + body={ + + {noResultsBody} + + } + /> + )} + + {rows.length > 0 && ( + <> + + {}, + }} + rowCount={rows.length} + gridStyle={{ + border: 'horizontal', + rowHover: 'none', + }} + renderCellValue={renderCellValue} + pagination={{ + ...pagination, + onChangeItemsPerPage, + onChangePage, + }} + toolbarVisibility={false} + /> + + + + + + {totalRowCount > rows.length + ? i18n.translate('xpack.alertingV2.ruleForm.queryResultsGrid.truncatedNote', { + defaultMessage: 'Showing {displayed} of {total} rows returned by the query.', + values: { displayed: rows.length, total: totalRowCount }, + }) + : i18n.translate('xpack.alertingV2.ruleForm.queryResultsGrid.rowCountNote', { + defaultMessage: + 'Query returned {count} {count, plural, one {row} other {rows}}.', + values: { count: totalRowCount }, + })} + + + {uniqueGroupCount != null && ( + + + {i18n.translate('xpack.alertingV2.ruleForm.queryResultsGrid.uniqueGroupCount', { + defaultMessage: '{count} unique {count, plural, one {group} other {groups}}', + values: { count: uniqueGroupCount }, + })} + + + )} + + + )} +
+ ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_delay_field.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_delay_field.test.tsx new file mode 100644 index 0000000000000..e0180e16995ac --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_delay_field.test.tsx @@ -0,0 +1,105 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { RecoveryDelayField } from './recovery_delay_field'; +import { createFormWrapper } from '../../test_utils'; + +describe('RecoveryDelayField', () => { + it('renders the recovery delay form row', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + expect(screen.getByTestId('recoveryDelayFormRow')).toBeInTheDocument(); + }); + + it('renders with label "Recovery delay"', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + expect(screen.getByText('Recovery delay')).toBeInTheDocument(); + }); + + it('defaults to immediate mode', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + expect(screen.getByTestId('recoveryDelayImmediateDescription')).toBeInTheDocument(); + expect(screen.getByText('No delay - Recovers on first non-breach')).toBeInTheDocument(); + }); + + it('derives breaches mode from form state with recoveringCount', () => { + render(, { + wrapper: createFormWrapper({ + kind: 'alert', + stateTransition: { recoveringCount: 4 }, + }), + }); + + expect(screen.getByTestId('recoveryTransitionCountInput')).toBeInTheDocument(); + }); + + it('derives duration mode from form state with recoveringTimeframe', () => { + render(, { + wrapper: createFormWrapper({ + kind: 'alert', + stateTransition: { recoveringTimeframe: '20m' }, + }), + }); + + expect(screen.getByTestId('recoveryTransitionTimeframeNumberInput')).toBeInTheDocument(); + }); + + it('switches to breaches mode when Breaches button is clicked', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + fireEvent.click(screen.getByText('Breaches')); + + expect(screen.getByTestId('recoveryTransitionCountInput')).toBeInTheDocument(); + expect(screen.queryByTestId('recoveryDelayImmediateDescription')).not.toBeInTheDocument(); + }); + + it('switches to duration mode when Duration button is clicked', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + fireEvent.click(screen.getByText('Duration')); + + expect(screen.getByTestId('recoveryTransitionTimeframeNumberInput')).toBeInTheDocument(); + expect(screen.queryByTestId('recoveryDelayImmediateDescription')).not.toBeInTheDocument(); + }); + + it('switches back to immediate mode', () => { + render(, { + wrapper: createFormWrapper({ + kind: 'alert', + stateTransition: { recoveringCount: 4 }, + }), + }); + + // Start in breaches mode, switch to immediate + fireEvent.click(screen.getByText('Immediate')); + + expect(screen.getByTestId('recoveryDelayImmediateDescription')).toBeInTheDocument(); + expect(screen.queryByTestId('recoveryTransitionCountInput')).not.toBeInTheDocument(); + }); + + it('renders the mode toggle button group', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + expect(screen.getByTestId('recoveryDelayMode')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_delay_field.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_delay_field.tsx new file mode 100644 index 0000000000000..6192e5d4f690a --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_delay_field.tsx @@ -0,0 +1,134 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { EuiButtonGroup, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useFormContext, useWatch } from 'react-hook-form'; +import type { FormValues } from '../types'; +import { StateTransitionCountField } from './state_transition_count_field'; +import { StateTransitionTimeframeField } from './state_transition_timeframe_field'; + +type DelayMode = 'immediate' | 'breaches' | 'duration'; + +const MODE_OPTIONS = [ + { + id: 'immediate' as const, + label: i18n.translate('xpack.alertingV2.ruleForm.recoveryDelay.delayModeImmediate', { + defaultMessage: 'Immediate', + }), + }, + { + id: 'breaches' as const, + label: i18n.translate('xpack.alertingV2.ruleForm.recoveryDelay.delayModeBreaches', { + defaultMessage: 'Breaches', + }), + }, + { + id: 'duration' as const, + label: i18n.translate('xpack.alertingV2.ruleForm.recoveryDelay.delayModeDuration', { + defaultMessage: 'Duration', + }), + }, +]; + +const DEFAULT_RECOVERING_COUNT = 2; +const DEFAULT_RECOVERING_TIMEFRAME = '2m'; + +const deriveMode = (stateTransition?: { + recoveringTimeframe?: string; + recoveringCount?: number; +}): DelayMode => { + if (stateTransition?.recoveringTimeframe != null) return 'duration'; + if (stateTransition?.recoveringCount != null) return 'breaches'; + return 'immediate'; +}; + +export const RecoveryDelayField: React.FC = () => { + const { control, setValue } = useFormContext(); + const stateTransition = useWatch({ control, name: 'stateTransition' }); + const [selectedMode, setSelectedMode] = useState(deriveMode(stateTransition)); + + const onModeChange = useCallback( + (mode: string) => { + switch (mode as DelayMode) { + case 'immediate': + setSelectedMode('immediate'); + setValue('stateTransition.recoveringCount', undefined); + setValue('stateTransition.recoveringTimeframe', undefined); + break; + case 'breaches': + setSelectedMode('breaches'); + setValue( + 'stateTransition.recoveringCount', + stateTransition?.recoveringCount ?? DEFAULT_RECOVERING_COUNT + ); + setValue('stateTransition.recoveringTimeframe', undefined); + break; + case 'duration': + setSelectedMode('duration'); + setValue('stateTransition.recoveringCount', undefined); + setValue( + 'stateTransition.recoveringTimeframe', + stateTransition?.recoveringTimeframe ?? DEFAULT_RECOVERING_TIMEFRAME + ); + break; + } + }, + [setValue, stateTransition?.recoveringCount, stateTransition?.recoveringTimeframe] + ); + + return ( + + <> + + + {selectedMode === 'immediate' && ( + + {i18n.translate('xpack.alertingV2.ruleForm.recoveryDelay.immediateDescription', { + defaultMessage: 'No delay - Recovers on first non-breach', + })} + + )} + {selectedMode === 'breaches' && ( + + )} + {selectedMode === 'duration' && ( + + )} + + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_results_preview.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_results_preview.test.tsx new file mode 100644 index 0000000000000..ee8a300de6076 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_results_preview.test.tsx @@ -0,0 +1,218 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { createFormWrapper } from '../../test_utils'; +import { RecoveryResultsPreview } from './recovery_results_preview'; +import * as useRecoveryPreviewModule from '../hooks/use_recovery_preview'; +import type { PreviewResult } from '../hooks/use_preview'; + +jest.mock('../hooks/use_recovery_preview'); + +const mockUseRecoveryPreview = jest.mocked(useRecoveryPreviewModule.useRecoveryPreview); + +const defaultFormValues = { + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-*', + }, + }, + recoveryPolicy: { + type: 'query' as const, + query: { + base: 'FROM logs-* | STATS count() BY host.name | WHERE count < 5', + }, + }, +}; + +const mockPreviewResult: PreviewResult = { + columns: [ + { id: 'host.name', displayAsText: 'host.name', esType: 'keyword' }, + { id: 'count', displayAsText: 'count', esType: 'long' }, + ], + rows: [ + { 'host.name': 'host-1', count: '3' }, + { 'host.name': 'host-2', count: '1' }, + ], + totalRowCount: 2, + isLoading: false, + isError: false, + error: null, + groupingFields: ['host.name'], + uniqueGroupCount: 2, + hasValidQuery: true, +}; + +describe('RecoveryResultsPreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRecoveryPreview.mockReturnValue(mockPreviewResult); + }); + + it('renders the title', () => { + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('Recovery results preview')).toBeInTheDocument(); + }); + + it('renders the data grid with results', () => { + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByTestId('recoveryResultsPreviewGrid')).toBeInTheDocument(); + }); + + it('renders row count note', () => { + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('Query returned 2 rows.')).toBeInTheDocument(); + }); + + it('renders truncated note when rows exceed max', () => { + mockUseRecoveryPreview.mockReturnValue({ + ...mockPreviewResult, + totalRowCount: 200, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('Showing 2 of 200 rows returned by the query.')).toBeInTheDocument(); + }); + + it('renders loading spinner when loading', () => { + mockUseRecoveryPreview.mockReturnValue({ + ...mockPreviewResult, + isLoading: true, + columns: [], + rows: [], + totalRowCount: 0, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders empty prompt when no query is configured', () => { + mockUseRecoveryPreview.mockReturnValue({ + columns: [], + rows: [], + totalRowCount: 0, + isLoading: false, + isError: false, + error: null, + groupingFields: [], + uniqueGroupCount: null, + hasValidQuery: false, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('No preview available')).toBeInTheDocument(); + expect( + screen.getByText( + 'Configure a recovery query to see a preview of results that would resolve active alerts.' + ) + ).toBeInTheDocument(); + }); + + it('renders no-results prompt when query returns empty results', () => { + mockUseRecoveryPreview.mockReturnValue({ + columns: [{ id: 'host.name', displayAsText: 'host.name', esType: 'keyword' }], + rows: [], + totalRowCount: 0, + isLoading: false, + isError: false, + error: null, + groupingFields: [], + uniqueGroupCount: null, + hasValidQuery: true, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('No results')).toBeInTheDocument(); + expect( + screen.getByText( + 'The recovery query returned no results for the configured lookback window. Try adjusting the recovery query or lookback period.' + ) + ).toBeInTheDocument(); + }); + + it('renders no-results prompt when valid query returns 0 results with no columns', () => { + mockUseRecoveryPreview.mockReturnValue({ + columns: [], + rows: [], + totalRowCount: 0, + isLoading: false, + isError: false, + error: null, + groupingFields: [], + uniqueGroupCount: null, + hasValidQuery: true, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('No results')).toBeInTheDocument(); + expect(screen.queryByText('No preview available')).not.toBeInTheDocument(); + }); + + it('renders error callout when query fails', () => { + mockUseRecoveryPreview.mockReturnValue({ + columns: [], + rows: [], + totalRowCount: 0, + isLoading: false, + isError: true, + error: 'Recovery query syntax error', + groupingFields: [], + uniqueGroupCount: null, + hasValidQuery: false, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('Preview failed')).toBeInTheDocument(); + expect(screen.getByText('Recovery query syntax error')).toBeInTheDocument(); + }); + + describe('grouping annotations', () => { + it('renders unique group count badge when grouping is configured', () => { + mockUseRecoveryPreview.mockReturnValue(mockPreviewResult); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('2 unique groups')).toBeInTheDocument(); + }); + + it('renders singular group label for a single group', () => { + mockUseRecoveryPreview.mockReturnValue({ + ...mockPreviewResult, + uniqueGroupCount: 1, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('1 unique group')).toBeInTheDocument(); + }); + + it('does not render unique group count badge when no grouping is configured', () => { + mockUseRecoveryPreview.mockReturnValue({ + ...mockPreviewResult, + groupingFields: [], + uniqueGroupCount: null, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.queryByText(/unique group/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_results_preview.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_results_preview.tsx new file mode 100644 index 0000000000000..4dda045128272 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/recovery_results_preview.tsx @@ -0,0 +1,61 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { useRecoveryPreview } from '../hooks/use_recovery_preview'; +import { QueryResultsGrid } from './query_results_grid'; + +/** + * Recovery results preview panel. + * + * Displays a live preview of the recovery ES|QL query results. Shown when + * the recovery policy type is `'query'`. Delegates grid rendering to + * `QueryResultsGrid`. + */ +export const RecoveryResultsPreview: React.FC = () => { + const { + columns, + rows, + totalRowCount, + isLoading, + isError, + error, + groupingFields, + uniqueGroupCount, + hasValidQuery, + } = useRecoveryPreview(); + + return ( + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_preview_panel.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_preview_panel.test.tsx new file mode 100644 index 0000000000000..f858955976611 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_preview_panel.test.tsx @@ -0,0 +1,158 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { createFormWrapper } from '../../test_utils'; +import { RulePreviewPanel } from './rule_preview_panel'; +import * as useRulePreviewModule from '../hooks/use_rule_preview'; +import type { RulePreviewResult } from '../hooks/use_rule_preview'; + +jest.mock('../hooks/use_rule_preview'); +jest.mock('./recovery_results_preview', () => ({ + RecoveryResultsPreview: () => ( +
Recovery Preview Mock
+ ), +})); + +const mockUseRulePreview = jest.mocked(useRulePreviewModule.useRulePreview); + +const mockPreviewResult: RulePreviewResult = { + columns: [ + { id: '@timestamp', displayAsText: '@timestamp', esType: 'date' }, + { id: 'message', displayAsText: 'message', esType: 'keyword' }, + ], + rows: [{ '@timestamp': '2024-01-01T00:00:00Z', message: 'Error occurred' }], + totalRowCount: 1, + isLoading: false, + isError: false, + error: null, + groupingFields: [], + uniqueGroupCount: null, + hasValidQuery: true, +}; + +const defaultFormValues = { + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-*', + }, + }, +}; + +describe('RulePreviewPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRulePreview.mockReturnValue(mockPreviewResult); + }); + + describe('page layout', () => { + it('renders the preview inline', () => { + render(, { + wrapper: createFormWrapper(defaultFormValues, undefined, { layout: 'page' }), + }); + + expect(screen.getByText('Rule results preview')).toBeInTheDocument(); + expect(screen.getByTestId('ruleResultsPreviewGrid')).toBeInTheDocument(); + expect(screen.queryByTestId('rulePreviewTriggerButton')).not.toBeInTheDocument(); + }); + + it('does not render recovery preview when recovery type is no_breach', () => { + render(, { + wrapper: createFormWrapper( + { ...defaultFormValues, recoveryPolicy: { type: 'no_breach' } }, + undefined, + { layout: 'page' } + ), + }); + + expect(screen.queryByTestId('recoveryResultsPreview')).not.toBeInTheDocument(); + }); + + it('renders recovery preview when recovery type is query', () => { + render(, { + wrapper: createFormWrapper( + { ...defaultFormValues, recoveryPolicy: { type: 'query' } }, + undefined, + { layout: 'page' } + ), + }); + + expect(screen.getByText('Rule results preview')).toBeInTheDocument(); + expect(screen.getByTestId('recoveryResultsPreview')).toBeInTheDocument(); + expect(screen.getByText('Recovery Preview Mock')).toBeInTheDocument(); + }); + }); + + describe('flyout layout', () => { + it('renders a trigger button instead of the preview', () => { + render(, { + wrapper: createFormWrapper(defaultFormValues, undefined, { layout: 'flyout' }), + }); + + expect(screen.getByTestId('rulePreviewTriggerButton')).toBeInTheDocument(); + expect(screen.getByText('Preview results')).toBeInTheDocument(); + expect(screen.queryByTestId('ruleResultsPreviewGrid')).not.toBeInTheDocument(); + }); + + it('opens a nested flyout when the trigger button is clicked', () => { + render(, { + wrapper: createFormWrapper(defaultFormValues, undefined, { layout: 'flyout' }), + }); + + fireEvent.click(screen.getByTestId('rulePreviewTriggerButton')); + + expect(screen.getByTestId('rulePreviewNestedFlyout')).toBeInTheDocument(); + expect(screen.getByTestId('ruleResultsPreviewGrid')).toBeInTheDocument(); + }); + + it('closes the nested flyout when the close button is clicked', () => { + render(, { + wrapper: createFormWrapper(defaultFormValues, undefined, { layout: 'flyout' }), + }); + + fireEvent.click(screen.getByTestId('rulePreviewTriggerButton')); + expect(screen.getByTestId('rulePreviewNestedFlyout')).toBeInTheDocument(); + + // Close the flyout via the EuiFlyout close button + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(screen.queryByTestId('rulePreviewNestedFlyout')).not.toBeInTheDocument(); + }); + + it('does not render recovery preview in flyout when recovery type is no_breach', () => { + render(, { + wrapper: createFormWrapper( + { ...defaultFormValues, recoveryPolicy: { type: 'no_breach' } }, + undefined, + { layout: 'flyout' } + ), + }); + + fireEvent.click(screen.getByTestId('rulePreviewTriggerButton')); + + expect(screen.getByTestId('rulePreviewNestedFlyout')).toBeInTheDocument(); + expect(screen.queryByTestId('recoveryResultsPreview')).not.toBeInTheDocument(); + }); + + it('renders recovery preview in flyout when recovery type is query', () => { + render(, { + wrapper: createFormWrapper( + { ...defaultFormValues, recoveryPolicy: { type: 'query' } }, + undefined, + { layout: 'flyout' } + ), + }); + + fireEvent.click(screen.getByTestId('rulePreviewTriggerButton')); + + expect(screen.getByTestId('rulePreviewNestedFlyout')).toBeInTheDocument(); + expect(screen.getByTestId('recoveryResultsPreview')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_preview_panel.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_preview_panel.tsx new file mode 100644 index 0000000000000..0506e543a751f --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_preview_panel.tsx @@ -0,0 +1,105 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { EuiButton, EuiFlyout, EuiFlyoutBody, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useFormContext, useWatch } from 'react-hook-form'; +import type { FormValues } from '../types'; +import { useRuleFormMeta } from '../contexts'; +import { RuleResultsPreview } from './rule_results_preview'; +import { RecoveryResultsPreview } from './recovery_results_preview'; + +/** + * Layout-aware wrapper for the rule and recovery results previews. + * + * - **Page layout**: Renders both previews inline (for side-by-side placement). + * The recovery preview is only shown when the recovery policy type is `'query'`. + * - **Flyout layout**: Renders a trigger button that opens a nested flyout + * containing both previews. + */ +export const RulePreviewPanel: React.FC = () => { + const { layout } = useRuleFormMeta(); + const { control } = useFormContext(); + const recoveryType = useWatch({ control, name: 'recoveryPolicy.type' }); + const showRecoveryPreview = recoveryType === 'query'; + + if (layout === 'page') { + return ( + <> + + {showRecoveryPreview && ( + <> + + + + )} + + ); + } + + return ; +}; + +const FlyoutPreview: React.FC<{ showRecoveryPreview: boolean }> = ({ showRecoveryPreview }) => { + const [isOpen, setIsOpen] = useState(false); + + const openFlyout = useCallback(() => setIsOpen(true), []); + const closeFlyout = useCallback(() => setIsOpen(false), []); + + return ( + <> + + +

+ {i18n.translate('xpack.alertingV2.ruleForm.rulePreviewPanel.sectionTitle', { + defaultMessage: 'Preview Rule results', + })} +

+
+ + + {i18n.translate('xpack.alertingV2.ruleForm.rulePreviewPanel.triggerButton', { + defaultMessage: 'Preview results', + })} + + + {isOpen && ( + + + + {showRecoveryPreview && ( + <> + + + + )} + + + )} + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_results_preview.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_results_preview.test.tsx new file mode 100644 index 0000000000000..d68fc14b04d1d --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_results_preview.test.tsx @@ -0,0 +1,265 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { createFormWrapper } from '../../test_utils'; +import { RuleResultsPreview } from './rule_results_preview'; +import * as useRulePreviewModule from '../hooks/use_rule_preview'; +import type { RulePreviewResult } from '../hooks/use_rule_preview'; + +jest.mock('../hooks/use_rule_preview'); + +const mockUseRulePreview = jest.mocked(useRulePreviewModule.useRulePreview); + +const defaultFormValues = { + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-*', + }, + }, +}; + +const mockPreviewResult: RulePreviewResult = { + columns: [ + { id: '@timestamp', displayAsText: '@timestamp', esType: 'date' }, + { id: 'message', displayAsText: 'message', esType: 'keyword' }, + ], + rows: [ + { '@timestamp': '2024-01-01T00:00:00Z', message: 'Error occurred' }, + { '@timestamp': '2024-01-01T00:01:00Z', message: 'Warning issued' }, + ], + totalRowCount: 2, + isLoading: false, + isError: false, + error: null, + groupingFields: [], + uniqueGroupCount: null, + hasValidQuery: true, +}; + +describe('RuleResultsPreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRulePreview.mockReturnValue(mockPreviewResult); + }); + + it('renders the title', () => { + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('Rule results preview')).toBeInTheDocument(); + }); + + it('renders the data grid with results', () => { + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByTestId('ruleResultsPreviewGrid')).toBeInTheDocument(); + }); + + it('renders row count note', () => { + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('Query returned 2 rows.')).toBeInTheDocument(); + }); + + it('renders truncated note when rows exceed max', () => { + mockUseRulePreview.mockReturnValue({ + ...mockPreviewResult, + totalRowCount: 200, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('Showing 2 of 200 rows returned by the query.')).toBeInTheDocument(); + }); + + it('renders loading spinner when loading', () => { + mockUseRulePreview.mockReturnValue({ + ...mockPreviewResult, + isLoading: true, + columns: [], + rows: [], + totalRowCount: 0, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + // The spinner is rendered in the header area + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders empty prompt when no query is configured', () => { + mockUseRulePreview.mockReturnValue({ + columns: [], + rows: [], + totalRowCount: 0, + isLoading: false, + isError: false, + error: null, + groupingFields: [], + uniqueGroupCount: null, + hasValidQuery: false, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('No preview available')).toBeInTheDocument(); + expect( + screen.getByText( + 'Configure a base query, time field, and lookback window to see a preview of matching results.' + ) + ).toBeInTheDocument(); + }); + + it('renders no-results prompt when query returns empty results', () => { + mockUseRulePreview.mockReturnValue({ + columns: [{ id: '@timestamp', displayAsText: '@timestamp', esType: 'date' }], + rows: [], + totalRowCount: 0, + isLoading: false, + isError: false, + error: null, + groupingFields: [], + uniqueGroupCount: null, + hasValidQuery: true, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('No results')).toBeInTheDocument(); + expect( + screen.getByText( + 'The query returned no results for the configured lookback window. Try adjusting the query or lookback period.' + ) + ).toBeInTheDocument(); + }); + + it('renders no-results prompt when valid query returns 0 results with no columns', () => { + mockUseRulePreview.mockReturnValue({ + columns: [], + rows: [], + totalRowCount: 0, + isLoading: false, + isError: false, + error: null, + groupingFields: [], + uniqueGroupCount: null, + hasValidQuery: true, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('No results')).toBeInTheDocument(); + expect(screen.queryByText('No preview available')).not.toBeInTheDocument(); + }); + + it('renders error callout when query fails', () => { + mockUseRulePreview.mockReturnValue({ + columns: [], + rows: [], + totalRowCount: 0, + isLoading: false, + isError: true, + error: 'Query syntax error', + groupingFields: [], + uniqueGroupCount: null, + hasValidQuery: false, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('Preview failed')).toBeInTheDocument(); + expect(screen.getByText('Query syntax error')).toBeInTheDocument(); + }); + + it('renders singular row count note for one row', () => { + mockUseRulePreview.mockReturnValue({ + ...mockPreviewResult, + rows: [{ '@timestamp': '2024-01-01T00:00:00Z', message: 'Error occurred' }], + totalRowCount: 1, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('Query returned 1 row.')).toBeInTheDocument(); + }); + + describe('grouping annotations', () => { + const groupingPreviewResult: RulePreviewResult = { + columns: [ + { id: 'host.name', displayAsText: 'host.name', esType: 'keyword' }, + { id: 'count', displayAsText: 'count', esType: 'long' }, + { id: '@timestamp', displayAsText: '@timestamp', esType: 'date' }, + ], + rows: [ + { 'host.name': 'host-1', count: '10', '@timestamp': '2024-01-01T00:00:00Z' }, + { 'host.name': 'host-2', count: '20', '@timestamp': '2024-01-01T00:01:00Z' }, + ], + totalRowCount: 2, + isLoading: false, + isError: false, + error: null, + groupingFields: ['host.name'], + uniqueGroupCount: 2, + hasValidQuery: true, + }; + + it('renders a key icon for grouping field columns', () => { + mockUseRulePreview.mockReturnValue(groupingPreviewResult); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + // The key icon should be rendered as part of the grouping column header + const keyIcons = screen.getAllByTestId('ruleResultsPreviewGrid'); + expect(keyIcons.length).toBeGreaterThan(0); + + // Check that "Group key field" tooltip content exists + expect(screen.getByText('host.name')).toBeInTheDocument(); + }); + + it('renders unique group count badge when grouping is configured', () => { + mockUseRulePreview.mockReturnValue(groupingPreviewResult); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('2 unique groups')).toBeInTheDocument(); + }); + + it('renders singular group label for a single group', () => { + mockUseRulePreview.mockReturnValue({ + ...groupingPreviewResult, + uniqueGroupCount: 1, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.getByText('1 unique group')).toBeInTheDocument(); + }); + + it('does not render unique group count badge when no grouping is configured', () => { + mockUseRulePreview.mockReturnValue(mockPreviewResult); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.queryByText(/unique group/)).not.toBeInTheDocument(); + }); + + it('does not render unique group count badge when uniqueGroupCount is null', () => { + mockUseRulePreview.mockReturnValue({ + ...groupingPreviewResult, + groupingFields: ['host.name'], + uniqueGroupCount: null, + }); + + render(, { wrapper: createFormWrapper(defaultFormValues) }); + + expect(screen.queryByText(/unique group/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_results_preview.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_results_preview.tsx new file mode 100644 index 0000000000000..15ff791c3a2b7 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/rule_results_preview.tsx @@ -0,0 +1,57 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { useRulePreview } from '../hooks/use_rule_preview'; +import { QueryResultsGrid } from './query_results_grid'; + +/** + * Rule results preview panel. + * + * Displays a live preview of the evaluation ES|QL query results as the user + * configures the rule form. Delegates grid rendering to `QueryResultsGrid`. + */ +export const RuleResultsPreview: React.FC = () => { + const { + columns, + rows, + totalRowCount, + isLoading, + isError, + error, + groupingFields, + uniqueGroupCount, + hasValidQuery, + } = useRulePreview(); + + return ( + + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_count_field.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_count_field.test.tsx index df4b8c458c1b3..937ebffdd1019 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_count_field.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_count_field.test.tsx @@ -50,4 +50,37 @@ describe('StateTransitionCountField', () => { expect(screen.getByTestId('stateTransitionCountInput')).toHaveValue(5); }); + + describe('variant="recovering"', () => { + it('renders with the recovering test subject', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + expect(screen.getByTestId('recoveryTransitionCountInput')).toBeInTheDocument(); + }); + + it('accepts a positive integer for recovering count', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + const input = screen.getByTestId('recoveryTransitionCountInput'); + fireEvent.change(input, { target: { value: '4' } }); + expect(input).toHaveValue(4); + }); + + it('renders with pre-filled recovering count from form state', () => { + render(, { + wrapper: createFormWrapper({ + kind: 'alert', + stateTransition: { + recoveringCount: 7, + }, + }), + }); + + expect(screen.getByTestId('recoveryTransitionCountInput')).toHaveValue(7); + }); + }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_count_field.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_count_field.tsx index e1059916ca07e..d600a8c254061 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_count_field.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_count_field.tsx @@ -12,23 +12,43 @@ import { i18n } from '@kbn/i18n'; import { Controller, useFormContext } from 'react-hook-form'; import type { FormValues } from '../types'; import { INVALID_NUMBER_KEYS, parsePositiveIntegerInput } from '../utils'; -const DEFAULT_PENDING_COUNT = 2; +const DEFAULT_COUNT = 2; + +export type StateTransitionCountVariant = 'pending' | 'recovering'; interface StateTransitionCountFieldProps { prependLabel?: string; + /** Which state transition field to bind to. Defaults to 'pending'. */ + variant?: StateTransitionCountVariant; } +const FIELD_NAMES: Record< + StateTransitionCountVariant, + 'stateTransition.pendingCount' | 'stateTransition.recoveringCount' +> = { + pending: 'stateTransition.pendingCount', + recovering: 'stateTransition.recoveringCount', +}; + +const TEST_SUBJS: Record = { + pending: 'stateTransitionCountInput', + recovering: 'recoveryTransitionCountInput', +}; + export const StateTransitionCountField: React.FC = ({ prependLabel, + variant = 'pending', }) => { const { control, getValues, setValue } = useFormContext(); + const fieldName = FIELD_NAMES[variant]; + const testSubj = TEST_SUBJS[variant]; useEffect(() => { - const currentCount = getValues('stateTransition.pendingCount'); + const currentCount = getValues(fieldName); if (currentCount == null) { - setValue('stateTransition.pendingCount', DEFAULT_PENDING_COUNT); + setValue(fieldName, DEFAULT_COUNT); } - }, [getValues, setValue]); + }, [getValues, setValue, fieldName]); const onKeyDown = useCallback((e: React.KeyboardEvent) => { if (INVALID_NUMBER_KEYS.includes(e.key)) { @@ -38,7 +58,7 @@ export const StateTransitionCountField: React.FC return ( }} render={({ field: { value, onChange, ref }, fieldState: { error } }) => ( { const parsedValue = parsePositiveIntegerInput(e.target.value); if (parsedValue != null && parsedValue <= MAX_CONSECUTIVE_BREACHES) { @@ -69,7 +89,7 @@ export const StateTransitionCountField: React.FC max={MAX_CONSECUTIVE_BREACHES} step={1} isInvalid={!!error} - data-test-subj="stateTransitionCountInput" + data-test-subj={testSubj} inputRef={ref} fullWidth prepend={prependLabel ? [prependLabel] : undefined} diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_timeframe_field.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_timeframe_field.test.tsx index 4b98cec494178..b6358d6f18458 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_timeframe_field.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_timeframe_field.test.tsx @@ -91,4 +91,56 @@ describe('StateTransitionTimeframeField', () => { expect(screen.getByTestId('stateTransitionTimeframeNumberInput')).toHaveValue(15); expect(screen.getByTestId('stateTransitionTimeframeUnitInput')).toHaveValue('m'); }); + + describe('variant="recovering"', () => { + it('renders with the recovering test subjects', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + expect(screen.getByTestId('recoveryTransitionTimeframeNumberInput')).toBeInTheDocument(); + expect(screen.getByTestId('recoveryTransitionTimeframeUnitInput')).toBeInTheDocument(); + }); + + it('defaults recovering timeframe to 2 minutes', () => { + render(, { + wrapper: createFormWrapper({ kind: 'alert' }), + }); + + const numberInput = screen.getByTestId( + 'recoveryTransitionTimeframeNumberInput' + ) as HTMLInputElement; + expect(numberInput.value).toBe('2'); + + const unitSelect = screen.getByTestId('recoveryTransitionTimeframeUnitInput'); + expect(unitSelect).toHaveValue('m'); + }); + + it('renders with pre-filled recovering timeframe from form state', () => { + render(, { + wrapper: createFormWrapper({ + kind: 'alert', + stateTransition: { + recoveringTimeframe: '30m', + }, + }), + }); + + expect(screen.getByTestId('recoveryTransitionTimeframeNumberInput')).toHaveValue(30); + expect(screen.getByTestId('recoveryTransitionTimeframeUnitInput')).toHaveValue('m'); + }); + + it('updates recovering timeframe unit when changed', () => { + render(, { + wrapper: createFormWrapper({ + kind: 'alert', + stateTransition: { recoveringTimeframe: '5m' }, + }), + }); + + const unitSelect = screen.getByTestId('recoveryTransitionTimeframeUnitInput'); + fireEvent.change(unitSelect, { target: { value: 'h' } }); + expect(unitSelect).toHaveValue('h'); + }); + }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_timeframe_field.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_timeframe_field.tsx index 51ca021162d9e..90aaa9e6164f4 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_timeframe_field.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/state_transition_timeframe_field.tsx @@ -18,25 +18,49 @@ import { } from '../utils'; import type { FormValues } from '../types'; +export type StateTransitionTimeframeVariant = 'pending' | 'recovering'; + interface StateTransitionTimeframeFieldProps { numberPrependLabel?: string; + /** Which state transition field to bind to. Defaults to 'pending'. */ + variant?: StateTransitionTimeframeVariant; } +const FIELD_NAMES: Record< + StateTransitionTimeframeVariant, + 'stateTransition.pendingTimeframe' | 'stateTransition.recoveringTimeframe' +> = { + pending: 'stateTransition.pendingTimeframe', + recovering: 'stateTransition.recoveringTimeframe', +}; + +const NUMBER_TEST_SUBJS: Record = { + pending: 'stateTransitionTimeframeNumberInput', + recovering: 'recoveryTransitionTimeframeNumberInput', +}; + +const UNIT_TEST_SUBJS: Record = { + pending: 'stateTransitionTimeframeUnitInput', + recovering: 'recoveryTransitionTimeframeUnitInput', +}; + export const StateTransitionTimeframeField: React.FC = ({ numberPrependLabel, + variant = 'pending', }) => { const { control, getValues, setValue } = useFormContext(); + const fieldName = FIELD_NAMES[variant]; useEffect(() => { - const currentTimeframe = getValues('stateTransition.pendingTimeframe'); + const currentTimeframe = getValues(fieldName); if (currentTimeframe == null) { - setValue('stateTransition.pendingTimeframe', '2m'); + setValue(fieldName, '2m'); } - }, [getValues, setValue]); + }, [getValues, setValue, fieldName]); return ( )} /> @@ -65,6 +91,8 @@ interface StateTransitionTimeframeInputProps { errors?: string; inputRef?: React.Ref; numberPrependLabel?: string; + numberTestSubj?: string; + unitTestSubj?: string; } const StateTransitionTimeframeInput: React.FC = ({ @@ -73,6 +101,8 @@ const StateTransitionTimeframeInput: React.FC { const intervalNumber = useMemo(() => getDurationNumberInItsUnit(value || '2m'), [value]); @@ -112,7 +142,7 @@ const StateTransitionTimeframeInput: React.FC @@ -123,7 +153,7 @@ const StateTransitionTimeframeInput: React.FC { return ( void; @@ -27,11 +27,11 @@ export interface GuiRuleFormProps { * GUI-based rule form with standard form fields. * * This component renders the visual form interface with field groups for: - * - Rule details (name, description, tags, etc.) + * - Rule details (name, tags, description — no wrapper panel) * - Rule evaluation (ES|QL query + WHERE clause trigger condition) - * - Rule execution settings (schedule, time field, grouping) - * - Alert delay / state transition (immediate, breaches, duration) - * - Alert conditions (recovery policy) + * - Rule execution settings (schedule, lookback) + * - Rule kind (alert vs monitor) + * - Alert conditions (alert delay, recovery policy, recovery delay) * * Requires a FormProvider context with FormValues type to be present in the component tree. */ @@ -50,7 +50,7 @@ export const GuiRuleForm: React.FC = ({ - + diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/query_key_factory.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/query_key_factory.ts index af601b14ffaa1..edcfc47b7ac1d 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/query_key_factory.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/query_key_factory.ts @@ -5,8 +5,23 @@ * 2.0. */ +/** + * React Query key factory for the rule form. + * + * Centralises cache keys so queries can be invalidated or matched consistently. + * + * The preview key intentionally does **not** include a caller-specific segment + * (e.g. 'rulePreview' vs 'recoveryPreview'). When two previews resolve to the + * same ES|QL query + timeField + lookback, they share a single cache entry so + * React Query deduplicates the request and both consumers receive identical + * data. This prevents the subtle bug where two independent fetches of the same + * query compute slightly different `Date.now()` time windows and return + * different rows. + */ export const ruleFormKeys = { all: ['ruleForm'] as const, + preview: (query: string, timeField: string, lookback: string) => + [...ruleFormKeys.all, 'preview', query, timeField, lookback] as const, queryColumns: (query: string) => [...ruleFormKeys.all, 'queryColumns', query] as const, dataFields: (query: string) => [...ruleFormKeys.all, 'dataFields', query] as const, }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_create_rule.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_create_rule.test.tsx index 271a43f9866f8..ccdaa007aa368 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_create_rule.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_create_rule.test.tsx @@ -22,7 +22,6 @@ describe('useCreateRule', () => { useCreateRule({ http, notifications, - onSuccess, }), { wrapper: createQueryClientWrapper() } ); @@ -76,7 +75,6 @@ describe('useCreateRule', () => { it('calls the correct API endpoint', async () => { const http = httpServiceMock.createStartContract(); const notifications = notificationServiceMock.createStartContract(); - const onSuccess = jest.fn(); http.post.mockResolvedValue({ id: 'rule-123', metadata: { name: 'Test Rule' } }); @@ -85,7 +83,6 @@ describe('useCreateRule', () => { useCreateRule({ http, notifications, - onSuccess, }), { wrapper: createQueryClientWrapper() } ); @@ -102,7 +99,6 @@ describe('useCreateRule', () => { it('sends the form data as JSON in the request body', async () => { const http = httpServiceMock.createStartContract(); const notifications = notificationServiceMock.createStartContract(); - const onSuccess = jest.fn(); http.post.mockResolvedValue({ id: 'rule-123', metadata: { name: 'Test Rule' } }); @@ -111,7 +107,6 @@ describe('useCreateRule', () => { useCreateRule({ http, notifications, - onSuccess, }), { wrapper: createQueryClientWrapper() } ); @@ -139,13 +134,12 @@ describe('useCreateRule', () => { useCreateRule({ http, notifications, - onSuccess, }), { wrapper: createQueryClientWrapper() } ); await act(async () => { - result.current.createRule(validFormData); + result.current.createRule(validFormData, { onSuccess }); }); await waitFor(() => { @@ -171,13 +165,12 @@ describe('useCreateRule', () => { useCreateRule({ http, notifications, - onSuccess, }), { wrapper: createQueryClientWrapper() } ); await act(async () => { - result.current.createRule(validFormData); + result.current.createRule(validFormData, { onSuccess }); }); await waitFor(() => { @@ -325,7 +318,6 @@ describe('useCreateRule', () => { it('includes all form fields in the request payload', async () => { const http = httpServiceMock.createStartContract(); const notifications = notificationServiceMock.createStartContract(); - const onSuccess = jest.fn(); http.post.mockResolvedValue({ id: 'rule-456', metadata: { name: 'Complex Rule' } }); @@ -334,7 +326,6 @@ describe('useCreateRule', () => { useCreateRule({ http, notifications, - onSuccess, }), { wrapper: createQueryClientWrapper() } ); @@ -390,11 +381,10 @@ describe('useCreateRule', () => { it('maps recovery_policy with condition-only mode using evaluation base query', async () => { const http = httpServiceMock.createStartContract(); const notifications = notificationServiceMock.createStartContract(); - const onSuccess = jest.fn(); http.post.mockResolvedValue({ id: 'rule-789', metadata: { name: 'Recovery Rule' } }); - const { result } = renderHook(() => useCreateRule({ http, notifications, onSuccess }), { + const { result } = renderHook(() => useCreateRule({ http, notifications }), { wrapper: createQueryClientWrapper(), }); @@ -438,11 +428,10 @@ describe('useCreateRule', () => { it('maps recovery_policy with full base query when no condition is set', async () => { const http = httpServiceMock.createStartContract(); const notifications = notificationServiceMock.createStartContract(); - const onSuccess = jest.fn(); http.post.mockResolvedValue({ id: 'rule-790', metadata: { name: 'Full Recovery Rule' } }); - const { result } = renderHook(() => useCreateRule({ http, notifications, onSuccess }), { + const { result } = renderHook(() => useCreateRule({ http, notifications }), { wrapper: createQueryClientWrapper(), }); @@ -484,11 +473,10 @@ describe('useCreateRule', () => { it('omits recovery_policy query when type is no_breach', async () => { const http = httpServiceMock.createStartContract(); const notifications = notificationServiceMock.createStartContract(); - const onSuccess = jest.fn(); http.post.mockResolvedValue({ id: 'rule-791', metadata: { name: 'No Breach Rule' } }); - const { result } = renderHook(() => useCreateRule({ http, notifications, onSuccess }), { + const { result } = renderHook(() => useCreateRule({ http, notifications }), { wrapper: createQueryClientWrapper(), }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_create_rule.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_create_rule.ts index 9b300f2a20cd8..6f301b7709593 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_create_rule.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_create_rule.ts @@ -14,10 +14,9 @@ import { mapFormValuesToCreateRequest } from '../utils/rule_request_mappers'; interface UseCreateRuleProps { http: HttpStart; notifications: NotificationsStart; - onSuccess?: () => void; } -export const useCreateRule = ({ http, notifications, onSuccess }: UseCreateRuleProps) => { +export const useCreateRule = ({ http, notifications }: UseCreateRuleProps) => { const mutation = useMutation( (formValues: FormValues) => { return http.post('/internal/alerting/v2/rule', { @@ -27,7 +26,6 @@ export const useCreateRule = ({ http, notifications, onSuccess }: UseCreateRuleP { onSuccess: (data: RuleResponse) => { notifications.toasts.addSuccess(`Rule '${data.metadata.name}' was created successfully`); - onSuccess?.(); }, onError: (error: Error) => { notifications.toasts.addDanger(`Error creating rule: ${error.message}`); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_preview.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_preview.test.tsx new file mode 100644 index 0000000000000..4894b291c64e6 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_preview.test.tsx @@ -0,0 +1,149 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { getESQLResults } from '@kbn/esql-utils'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { RuleFormProvider, type RuleFormServices } from '../contexts'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { applicationServiceMock } from '@kbn/core/public/mocks'; +import { usePreview, type UsePreviewParams } from './use_preview'; + +jest.mock('@kbn/esql-utils'); + +let mockUseDebouncedValue: jest.Mock; + +jest.mock('@kbn/react-hooks', () => ({ + useDebouncedValue: (...args: unknown[]) => mockUseDebouncedValue(...args), +})); + +const mockGetESQLResults = jest.mocked(getESQLResults); + +const mockESQLResponse = { + response: { + columns: [ + { name: '@timestamp', type: 'date' }, + { name: 'message', type: 'keyword' }, + ], + values: [ + ['2024-01-01T00:00:00Z', 'Error occurred'], + ['2024-01-01T00:01:00Z', 'Warning issued'], + ], + }, +}; + +const createServices = (): RuleFormServices => ({ + http: httpServiceMock.createStartContract(), + data: dataPluginMock.createStartContract(), + dataViews: dataViewPluginMocks.createStartContract(), + notifications: notificationServiceMock.createStartContract(), + application: applicationServiceMock.createStartContract(), +}); + +const createWrapper = (services: RuleFormServices = createServices()) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + logger: { log: () => {}, warn: () => {}, error: () => {} }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); +}; + +const defaultParams: UsePreviewParams = { + query: 'FROM logs-* | LIMIT 100', + timeField: '@timestamp', + lookback: '1m', + groupingFields: [], +}; + +describe('usePreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetESQLResults.mockResolvedValue(mockESQLResponse as any); + // Default: no debounce delay (pass value straight through) + mockUseDebouncedValue = jest.fn((value: unknown) => value); + }); + + describe('debouncing', () => { + it('reports isLoading while the debounce timer is pending', () => { + // Simulate debounce in-flight: return stale value while query has changed + mockUseDebouncedValue = jest.fn(() => ''); + + const wrapper = createWrapper(); + const { result } = renderHook(() => usePreview(defaultParams), { wrapper }); + + // query is non-empty but debouncedQuery is still '' → isDebouncing is true + expect(result.current.isLoading).toBe(true); + }); + + it('does not execute the query while debounce is pending', () => { + // Debounced value hasn't settled yet + mockUseDebouncedValue = jest.fn(() => ''); + + const wrapper = createWrapper(); + renderHook(() => usePreview(defaultParams), { wrapper }); + + expect(mockGetESQLResults).not.toHaveBeenCalled(); + }); + + it('executes the query once debounce settles', async () => { + // Debounce returns the current value (settled) + mockUseDebouncedValue = jest.fn((value: unknown) => value); + + const wrapper = createWrapper(); + const { result } = renderHook(() => usePreview(defaultParams), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockGetESQLResults).toHaveBeenCalledTimes(1); + expect(result.current.columns).toHaveLength(2); + expect(result.current.rows).toHaveLength(2); + }); + + it('keeps previous results visible while debounce is pending after initial load', async () => { + // Start with debounce settled + mockUseDebouncedValue = jest.fn((value: unknown) => value); + + const wrapper = createWrapper(); + const { result, rerender } = renderHook((props: UsePreviewParams) => usePreview(props), { + wrapper, + initialProps: defaultParams, + }); + + // Wait for initial query to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.rows).toHaveLength(2); + + // Now simulate the user typing a new query (debounce returns old value) + mockUseDebouncedValue = jest.fn(() => defaultParams.query); + rerender({ ...defaultParams, query: 'FROM logs-* | LIMIT 50' }); + + // isLoading should be true (debounce pending) + expect(result.current.isLoading).toBe(true); + // Previous results should still be visible (keepPreviousData) + expect(result.current.rows).toHaveLength(2); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_preview.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_preview.ts new file mode 100644 index 0000000000000..ade2176134b87 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_preview.ts @@ -0,0 +1,219 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useMemo } from 'react'; +import { useQuery } from '@kbn/react-query'; +import { getESQLResults } from '@kbn/esql-utils'; +import { useDebouncedValue } from '@kbn/react-hooks'; +import type { EuiDataGridColumn } from '@elastic/eui'; +import { validateEsqlQuery } from '@kbn/alerting-v2-schemas'; +import { useRuleFormServices } from '../contexts'; +import { parseDuration } from '../utils'; +import { ruleFormKeys } from './query_key_factory'; + +/** Maximum number of preview rows to display */ +const MAX_PREVIEW_ROWS = 100; + +/** Debounce wait time in milliseconds */ +const DEBOUNCE_WAIT = 2000; + +export interface PreviewColumn extends EuiDataGridColumn { + /** The ES|QL column type (e.g. 'keyword', 'long', 'date') */ + esType: string; +} + +export interface PreviewResult { + /** Columns derived from the ES|QL response */ + columns: PreviewColumn[]; + /** Row data mapped from the ES|QL response values */ + rows: Array>; + /** Total row count (before truncation) */ + totalRowCount: number; + /** Whether the query is currently loading */ + isLoading: boolean; + /** Whether the query resulted in an error */ + isError: boolean; + /** Error message, if any */ + error: string | null; + /** Field names selected as the grouping key */ + groupingFields: string[]; + /** Number of unique alert groups based on grouping field values, or null if no grouping is configured */ + uniqueGroupCount: number | null; + /** Whether the current query is syntactically valid ES|QL (used to distinguish "no query" from "valid query with 0 results") */ + hasValidQuery: boolean; +} + +export interface UsePreviewParams { + /** The assembled ES|QL query string to execute */ + query: string; + /** The time field name for the range filter */ + timeField: string; + /** The lookback duration string (e.g. '5m', '1h') */ + lookback: string; + /** Fields selected as the grouping key */ + groupingFields: string[]; + /** Whether the preview is enabled (defaults to true) */ + enabled?: boolean; +} + +/** + * Constructs a time range filter for the ES|QL query preview. + * Uses the same pattern as the existing ES query rule expression. + */ +const getTimeFilter = (timeField: string, lookback: string) => { + const timeWindow = parseDuration(lookback); + const now = Date.now(); + const dateEnd = new Date(now).toISOString(); + const dateStart = new Date(now - timeWindow).toISOString(); + return { + timeRange: { + from: dateStart, + to: dateEnd, + }, + timeFilter: { + bool: { + filter: [ + { + range: { + [timeField]: { + lte: dateEnd, + gt: dateStart, + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + }; +}; + +/** + * Formats a cell value as a display string. + */ +const formatCellValue = (value: unknown): string | null => { + if (value === null || value === undefined) { + return null; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); +}; + +/** + * Generic hook that executes an ES|QL query and returns preview results. + * + * Handles debouncing, time filtering, column/row mapping, and unique group + * computation. Specialised hooks (rule preview, recovery preview) compose + * this hook by watching the relevant form fields and assembling the query + * before delegating here. + */ +export const usePreview = ({ + query, + timeField, + lookback, + groupingFields, + enabled = true, +}: UsePreviewParams): PreviewResult => { + const { data } = useRuleFormServices(); + + // Debounced query to avoid re-fetching on every keystroke. + // useDebouncedValue properly cancels stale timers so only the latest value + // ever commits, regardless of the debounce duration. + const debouncedQuery = useDebouncedValue(query, DEBOUNCE_WAIT); + + // Determine if we have enough inputs to run the query + const canExecute = + enabled && Boolean(debouncedQuery?.trim() && timeField?.trim() && lookback?.trim()); + + const fetchPreview = useCallback(async () => { + if (!canExecute) { + return { columns: [], values: [] }; + } + + const { timeFilter, timeRange } = getTimeFilter(timeField, lookback); + + const result = await getESQLResults({ + esqlQuery: debouncedQuery, + search: data.search.search, + dropNullColumns: true, + timeRange, + filter: timeFilter, + }); + + return result.response; + }, [canExecute, debouncedQuery, timeField, lookback, data.search.search]); + + const { + data: response, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ruleFormKeys.preview(debouncedQuery, timeField, lookback), + queryFn: fetchPreview, + enabled: canExecute, + keepPreviousData: true, + refetchOnWindowFocus: false, + retry: false, + }); + + // Map response into columns and rows for EuiDataGrid + const columns: PreviewColumn[] = (response?.columns ?? []).map((col) => ({ + id: col.name, + displayAsText: col.name, + esType: col.type, + })); + + const allRows = (response?.values ?? []).map((row) => { + const record: Record = {}; + (response?.columns ?? []).forEach((col, idx) => { + record[col.name] = formatCellValue(row[idx]); + }); + return record; + }); + + const totalRowCount = allRows.length; + const rows = allRows.slice(0, MAX_PREVIEW_ROWS); + + const uniqueGroupCount = useMemo(() => { + if (groupingFields.length === 0 || allRows.length === 0) { + return null; + } + const seen = new Set(); + for (const row of allRows) { + const key = groupingFields.map((f) => row[f] ?? '').join('|'); + seen.add(key); + } + return seen.size; + }, [groupingFields, allRows]); + + const errorMessage = isError && error instanceof Error ? error.message : null; + + // True while the debounce timer is pending (user is still typing) + const isDebouncing = query !== debouncedQuery; + + // A query is considered valid when it is non-empty, syntactically correct, + // and all required inputs (timeField, lookback) are provided. + const hasValidQuery = useMemo( + () => Boolean(query?.trim()) && !validateEsqlQuery(query) && canExecute, + [query, canExecute] + ); + + return { + columns, + rows, + totalRowCount, + isLoading: isDebouncing || (canExecute && isLoading), + isError, + error: errorMessage, + groupingFields, + uniqueGroupCount, + hasValidQuery, + }; +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_recovery_preview.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_recovery_preview.test.tsx new file mode 100644 index 0000000000000..f7a4be81ff1d4 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_recovery_preview.test.tsx @@ -0,0 +1,359 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { getESQLResults } from '@kbn/esql-utils'; +import { createFormWrapper } from '../../test_utils'; +import { useRecoveryPreview } from './use_recovery_preview'; + +jest.mock('@kbn/esql-utils'); +jest.mock('@kbn/react-hooks', () => ({ + useDebouncedValue: (value: T) => value, +})); + +const mockGetESQLResults = jest.mocked(getESQLResults); + +const mockESQLResponse = { + response: { + columns: [ + { name: 'host.name', type: 'keyword' }, + { name: 'count', type: 'long' }, + ], + values: [ + ['host-1', '3'], + ['host-2', '1'], + ], + }, +}; + +describe('useRecoveryPreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetESQLResults.mockResolvedValue(mockESQLResponse as any); + }); + + describe('split mode (evaluation has condition)', () => { + it('falls back to evaluation condition when recovery condition is empty', async () => { + const wrapper = createFormWrapper({ + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'WHERE count > 100', + }, + }, + recoveryPolicy: { + type: 'query' as const, + query: { + // No recovery condition set — should fall back to evaluation condition + }, + }, + }); + + renderHook(() => useRecoveryPreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + // Should include the evaluation condition as fallback + expect(call.esqlQuery).toBe('FROM logs-* | STATS count() BY host.name | WHERE count > 100'); + }); + + it('uses recovery condition when explicitly set', async () => { + const wrapper = createFormWrapper({ + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'WHERE count > 100', + }, + }, + recoveryPolicy: { + type: 'query' as const, + query: { + condition: 'WHERE count <= 50', + }, + }, + }); + + renderHook(() => useRecoveryPreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + expect(call.esqlQuery).toBe('FROM logs-* | STATS count() BY host.name | WHERE count <= 50'); + }); + + it('uses custom recovery base when provided', async () => { + const wrapper = createFormWrapper({ + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'WHERE count > 100', + }, + }, + recoveryPolicy: { + type: 'query' as const, + query: { + base: 'FROM metrics-* | STATS avg(cpu) BY host.name', + condition: 'WHERE avg < 20', + }, + }, + }); + + renderHook(() => useRecoveryPreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + expect(call.esqlQuery).toBe('FROM metrics-* | STATS avg(cpu) BY host.name | WHERE avg < 20'); + }); + + it('falls back to evaluation base when custom recovery base is empty', async () => { + const wrapper = createFormWrapper({ + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'WHERE count > 100', + }, + }, + recoveryPolicy: { + type: 'query' as const, + query: { + base: '', + condition: 'WHERE count <= 50', + }, + }, + }); + + renderHook(() => useRecoveryPreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + expect(call.esqlQuery).toBe('FROM logs-* | STATS count() BY host.name | WHERE count <= 50'); + }); + + it('handles condition without WHERE prefix', async () => { + const wrapper = createFormWrapper({ + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'count > 100', + }, + }, + recoveryPolicy: { + type: 'query' as const, + query: { + condition: 'count <= 50', + }, + }, + }); + + renderHook(() => useRecoveryPreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + expect(call.esqlQuery).toBe('FROM logs-* | STATS count() BY host.name | WHERE count <= 50'); + }); + }); + + describe('non-split mode (no evaluation condition)', () => { + it('uses standalone recovery base query', async () => { + const wrapper = createFormWrapper({ + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + // No condition — non-split mode + }, + }, + recoveryPolicy: { + type: 'query' as const, + query: { + base: 'FROM logs-* | STATS count() BY host.name | WHERE count < 5', + }, + }, + }); + + renderHook(() => useRecoveryPreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + expect(call.esqlQuery).toBe('FROM logs-* | STATS count() BY host.name | WHERE count < 5'); + }); + + it('does not execute when recovery base query is empty', async () => { + const wrapper = createFormWrapper({ + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + }, + }, + recoveryPolicy: { + type: 'query' as const, + query: { + base: '', + }, + }, + }); + + const { result } = renderHook(() => useRecoveryPreview(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(mockGetESQLResults).not.toHaveBeenCalled(); + }); + }); + + describe('disabled state', () => { + it('does not execute when recovery type is no_breach', async () => { + const wrapper = createFormWrapper({ + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'WHERE count > 100', + }, + }, + recoveryPolicy: { + type: 'no_breach' as const, + query: { + condition: 'WHERE count <= 50', + }, + }, + }); + + const { result } = renderHook(() => useRecoveryPreview(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(mockGetESQLResults).not.toHaveBeenCalled(); + }); + }); + + describe('result mapping', () => { + it('maps columns and rows from ES|QL response', async () => { + const wrapper = createFormWrapper({ + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'WHERE count > 100', + }, + }, + recoveryPolicy: { + type: 'query' as const, + query: { + condition: 'WHERE count <= 50', + }, + }, + }); + + const { result } = renderHook(() => useRecoveryPreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.columns).toEqual([ + { id: 'host.name', displayAsText: 'host.name', esType: 'keyword' }, + { id: 'count', displayAsText: 'count', esType: 'long' }, + ]); + + expect(result.current.rows).toEqual([ + { 'host.name': 'host-1', count: '3' }, + { 'host.name': 'host-2', count: '1' }, + ]); + + expect(result.current.totalRowCount).toBe(2); + }); + + it('passes grouping fields through', async () => { + const wrapper = createFormWrapper({ + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'WHERE count > 100', + }, + }, + recoveryPolicy: { + type: 'query' as const, + query: { + condition: 'WHERE count <= 50', + }, + }, + grouping: { fields: ['host.name'] }, + }); + + const { result } = renderHook(() => useRecoveryPreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.groupingFields).toEqual(['host.name']); + expect(result.current.uniqueGroupCount).toBe(2); + }); + + it('handles query errors gracefully', async () => { + mockGetESQLResults.mockRejectedValue(new Error('Recovery query syntax error')); + + const wrapper = createFormWrapper({ + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'WHERE count > 100', + }, + }, + recoveryPolicy: { + type: 'query' as const, + query: { + condition: 'WHERE count <= 50', + }, + }, + }); + + const { result } = renderHook(() => useRecoveryPreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBe('Recovery query syntax error'); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_recovery_preview.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_recovery_preview.ts new file mode 100644 index 0000000000000..50fae00be58f8 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_recovery_preview.ts @@ -0,0 +1,84 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; +import type { FormValues } from '../types'; +import { assembleFullQuery } from '../utils/assemble_full_query'; +import { usePreview } from './use_preview'; +import type { PreviewResult } from './use_preview'; + +export type { PreviewResult as RecoveryPreviewResult } from './use_preview'; + +/** + * Recovery preview hook. + * + * Watches the recovery policy form fields and assembles the recovery query + * using the same logic as `useRecoveryValidation`: + * + * - **Split mode** (evaluation has a WHERE condition): The recovery query is + * `assembleFullQuery(effectiveBase, recoveryCondition)` where + * `effectiveBase` falls back to the evaluation base when no override is set, + * and `recoveryCondition` falls back to the evaluation condition when the + * user hasn't entered a custom condition yet. This fallback mirrors the + * seeding logic in `RecoveryBaseAndConditionField` but applies synchronously + * so the preview never fires a base-only query before the seeding + * `useEffect` runs. + * + * - **Non-split mode** (no evaluation condition): The recovery query is the + * standalone `recoveryPolicy.query.base` value. + * + * Delegates to `usePreview` for ES|QL execution and result mapping. + * Disabled when recovery type is not `'query'`. + */ +export const useRecoveryPreview = (): PreviewResult => { + const { control } = useFormContext(); + + const evaluationBase = useWatch({ control, name: 'evaluation.query.base' }); + const evaluationCondition = useWatch({ control, name: 'evaluation.query.condition' }); + const recoveryBase = useWatch({ control, name: 'recoveryPolicy.query.base' }); + const formRecoveryCondition = useWatch({ control, name: 'recoveryPolicy.query.condition' }); + const recoveryType = useWatch({ control, name: 'recoveryPolicy.type' }); + const timeField = useWatch({ control, name: 'timeField' }); + const lookback = useWatch({ control, name: 'schedule.lookback' }); + const groupingFields = useWatch({ control, name: 'grouping.fields' }) ?? []; + + const hasEvaluationCondition = Boolean(evaluationCondition?.trim()); + + // In split mode, fall back to the evaluation condition when the recovery + // condition hasn't been set yet. This mirrors the seeding logic in + // RecoveryBaseAndConditionField, but is applied synchronously so the + // preview never fires a base-only query before the useEffect seed runs. + const recoveryCondition = useMemo(() => { + if (!hasEvaluationCondition) return undefined; + return formRecoveryCondition?.trim() ? formRecoveryCondition : evaluationCondition; + }, [hasEvaluationCondition, formRecoveryCondition, evaluationCondition]); + + // Mirror the effective base query logic from useRecoveryValidation + const effectiveBase = useMemo(() => { + if (hasEvaluationCondition) { + return recoveryBase?.trim() ? recoveryBase : evaluationBase || ''; + } + return ''; + }, [hasEvaluationCondition, recoveryBase, evaluationBase]); + + // Assemble the full recovery query + const recoveryQuery = useMemo(() => { + if (hasEvaluationCondition) { + return assembleFullQuery(effectiveBase, recoveryCondition); + } + return recoveryBase?.trim() ?? ''; + }, [hasEvaluationCondition, effectiveBase, recoveryCondition, recoveryBase]); + + return usePreview({ + query: recoveryQuery, + timeField, + lookback, + groupingFields, + enabled: recoveryType === 'query', + }); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_recovery_validation.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_recovery_validation.ts index 8334fe5e1077c..6a987bab42d79 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_recovery_validation.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_recovery_validation.ts @@ -11,6 +11,7 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { validateEsqlQuery } from '@kbn/alerting-v2-schemas'; import type { ISearchGeneric } from '@kbn/search-types'; import type { FormValues } from '../types'; +import { assembleFullQuery } from '../utils/assemble_full_query'; import { useRecoveryQueryGroupingValidation } from './use_recovery_query_grouping_validation'; interface UseRecoveryValidationProps { @@ -18,15 +19,6 @@ interface UseRecoveryValidationProps { search: ISearchGeneric; } -/** Assemble a full ES|QL query from a base query and an optional condition (pipe segment). */ -const assembleQuery = (base?: string, condition?: string): string => { - const b = base?.trim() ?? ''; - const c = condition?.trim() ?? ''; - if (!b) return ''; - if (c) return `${b} | ${c}`; - return b; -}; - const QUERIES_MATCH_ERROR = i18n.translate( 'xpack.alertingV2.ruleForm.recoveryQuerySameAsEvaluation', { @@ -75,13 +67,13 @@ export const useRecoveryValidation = ({ search }: UseRecoveryValidationProps) => // Assemble full queries from base + condition const assembledEvaluationQuery = useMemo( - () => assembleQuery(evaluationBaseQuery, evaluationCondition), + () => assembleFullQuery(evaluationBaseQuery, evaluationCondition), [evaluationBaseQuery, evaluationCondition] ); const assembledRecoveryQuery = useMemo( () => - assembleQuery( + assembleFullQuery( hasEvaluationCondition ? effectiveBaseQuery : recoveryBaseQuery, // use effective base query if evaluation has a condition, otherwise use recovery base query hasEvaluationCondition ? recoveryCondition : undefined // use recovery condition if evaluation has a condition, otherwise use undefined ), diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_rule_preview.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_rule_preview.test.tsx new file mode 100644 index 0000000000000..41d1d3d86f081 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_rule_preview.test.tsx @@ -0,0 +1,439 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { getESQLResults } from '@kbn/esql-utils'; +import { createFormWrapper } from '../../test_utils'; +import { useRulePreview } from './use_rule_preview'; + +jest.mock('@kbn/esql-utils'); +jest.mock('@kbn/react-hooks', () => ({ + useDebouncedValue: (value: T) => value, +})); + +const mockGetESQLResults = jest.mocked(getESQLResults); + +const mockESQLResponse = { + response: { + columns: [ + { name: '@timestamp', type: 'date' }, + { name: 'message', type: 'keyword' }, + { name: 'host.name', type: 'keyword' }, + ], + values: [ + ['2024-01-01T00:00:00Z', 'Error occurred', 'host-1'], + ['2024-01-01T00:01:00Z', 'Warning issued', 'host-2'], + ['2024-01-01T00:02:00Z', 'Info log', 'host-3'], + ], + }, +}; + +const defaultFormValues = { + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | LIMIT 100', + }, + }, +}; + +describe('useRulePreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetESQLResults.mockResolvedValue(mockESQLResponse as any); + }); + + it('returns loading state initially when inputs are valid', async () => { + const wrapper = createFormWrapper(defaultFormValues); + + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + // Should start loading since all inputs are provided + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('fetches and maps ESQL results to columns and rows', async () => { + const wrapper = createFormWrapper(defaultFormValues); + + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Check columns + expect(result.current.columns).toEqual([ + { id: '@timestamp', displayAsText: '@timestamp', esType: 'date' }, + { id: 'message', displayAsText: 'message', esType: 'keyword' }, + { id: 'host.name', displayAsText: 'host.name', esType: 'keyword' }, + ]); + + // Check rows + expect(result.current.rows).toEqual([ + { '@timestamp': '2024-01-01T00:00:00Z', message: 'Error occurred', 'host.name': 'host-1' }, + { '@timestamp': '2024-01-01T00:01:00Z', message: 'Warning issued', 'host.name': 'host-2' }, + { '@timestamp': '2024-01-01T00:02:00Z', message: 'Info log', 'host.name': 'host-3' }, + ]); + + expect(result.current.totalRowCount).toBe(3); + }); + + it('calls getESQLResults with correct parameters', async () => { + const wrapper = createFormWrapper(defaultFormValues); + + renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + expect(call.esqlQuery).toBe('FROM logs-* | LIMIT 100'); + expect(call.dropNullColumns).toBe(true); + expect(call.timeRange).toBeDefined(); + expect(call.filter).toBeDefined(); + }); + + it('does not execute when query is empty', async () => { + const wrapper = createFormWrapper({ + ...defaultFormValues, + evaluation: { query: { base: '' } }, + }); + + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + // Should not be loading since the query is empty + expect(result.current.isLoading).toBe(false); + expect(result.current.columns).toEqual([]); + expect(result.current.rows).toEqual([]); + expect(mockGetESQLResults).not.toHaveBeenCalled(); + }); + + it('does not execute when timeField is empty', async () => { + const wrapper = createFormWrapper({ + ...defaultFormValues, + timeField: '', + }); + + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(mockGetESQLResults).not.toHaveBeenCalled(); + }); + + it('handles null values in response', async () => { + mockGetESQLResults.mockResolvedValue({ + response: { + columns: [ + { name: '@timestamp', type: 'date' }, + { name: 'message', type: 'keyword' }, + ], + values: [['2024-01-01T00:00:00Z', null]], + }, + } as any); + + const wrapper = createFormWrapper(defaultFormValues); + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.rows[0].message).toBeNull(); + }); + + it('handles query errors gracefully', async () => { + mockGetESQLResults.mockRejectedValue(new Error('Query syntax error')); + + const wrapper = createFormWrapper(defaultFormValues); + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBe('Query syntax error'); + expect(result.current.columns).toEqual([]); + expect(result.current.rows).toEqual([]); + }); + + it('handles object values by serializing to JSON', async () => { + mockGetESQLResults.mockResolvedValue({ + response: { + columns: [{ name: 'data', type: 'object' }], + values: [[{ key: 'value' }]], + }, + } as any); + + const wrapper = createFormWrapper(defaultFormValues); + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.rows[0].data).toBe('{"key":"value"}'); + }); + + describe('query assembly with condition', () => { + it('includes condition with WHERE prefix in the executed query', async () => { + const wrapper = createFormWrapper({ + ...defaultFormValues, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'WHERE count > 100', + }, + }, + }); + + renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + expect(call.esqlQuery).toBe('FROM logs-* | STATS count() BY host.name | WHERE count > 100'); + }); + + it('adds WHERE keyword when condition lacks the prefix', async () => { + const wrapper = createFormWrapper({ + ...defaultFormValues, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'count > 100', + }, + }, + }); + + renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + expect(call.esqlQuery).toBe('FROM logs-* | STATS count() BY host.name | WHERE count > 100'); + }); + + it('uses only the base query when condition is empty', async () => { + const wrapper = createFormWrapper({ + ...defaultFormValues, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: '', + }, + }, + }); + + renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + expect(call.esqlQuery).toBe('FROM logs-* | STATS count() BY host.name'); + }); + + it('uses only the base query when condition is undefined', async () => { + const wrapper = createFormWrapper({ + ...defaultFormValues, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + }, + }, + }); + + renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + expect(call.esqlQuery).toBe('FROM logs-* | STATS count() BY host.name'); + }); + + it('handles condition with lowercase where prefix', async () => { + const wrapper = createFormWrapper({ + ...defaultFormValues, + evaluation: { + query: { + base: 'FROM logs-*', + condition: 'where status >= 500', + }, + }, + }); + + renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(mockGetESQLResults).toHaveBeenCalled(); + }); + + const call = mockGetESQLResults.mock.calls[0][0]; + expect(call.esqlQuery).toBe('FROM logs-* | where status >= 500'); + }); + + it('does not execute when base query is empty even if condition exists', async () => { + const wrapper = createFormWrapper({ + ...defaultFormValues, + evaluation: { + query: { + base: '', + condition: 'WHERE count > 100', + }, + }, + }); + + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(mockGetESQLResults).not.toHaveBeenCalled(); + }); + }); + + describe('grouping fields and unique group count', () => { + const groupingResponse = { + response: { + columns: [ + { name: 'host.name', type: 'keyword' }, + { name: 'count', type: 'long' }, + ], + values: [ + ['host-1', '10'], + ['host-2', '20'], + ['host-1', '30'], + ['host-3', '5'], + ], + }, + }; + + it('returns groupingFields from form state', async () => { + mockGetESQLResults.mockResolvedValue(groupingResponse as any); + + const wrapper = createFormWrapper({ + ...defaultFormValues, + grouping: { fields: ['host.name'] }, + }); + + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.groupingFields).toEqual(['host.name']); + }); + + it('returns empty groupingFields when no grouping is configured', async () => { + const wrapper = createFormWrapper(defaultFormValues); + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.groupingFields).toEqual([]); + }); + + it('computes uniqueGroupCount from distinct grouping field values', async () => { + mockGetESQLResults.mockResolvedValue(groupingResponse as any); + + const wrapper = createFormWrapper({ + ...defaultFormValues, + grouping: { fields: ['host.name'] }, + }); + + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // host-1 appears twice, host-2 and host-3 once each → 3 unique groups + expect(result.current.uniqueGroupCount).toBe(3); + }); + + it('returns null uniqueGroupCount when no grouping is configured', async () => { + mockGetESQLResults.mockResolvedValue(groupingResponse as any); + + const wrapper = createFormWrapper(defaultFormValues); + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.uniqueGroupCount).toBeNull(); + }); + + it('returns null uniqueGroupCount when there are no rows', async () => { + mockGetESQLResults.mockResolvedValue({ + response: { + columns: [{ name: 'host.name', type: 'keyword' }], + values: [], + }, + } as any); + + const wrapper = createFormWrapper({ + ...defaultFormValues, + grouping: { fields: ['host.name'] }, + }); + + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.uniqueGroupCount).toBeNull(); + }); + + it('computes uniqueGroupCount with multiple grouping fields', async () => { + mockGetESQLResults.mockResolvedValue({ + response: { + columns: [ + { name: 'host.name', type: 'keyword' }, + { name: 'service.name', type: 'keyword' }, + { name: 'count', type: 'long' }, + ], + values: [ + ['host-1', 'svc-a', '10'], + ['host-1', 'svc-b', '20'], + ['host-1', 'svc-a', '30'], // duplicate of first + ['host-2', 'svc-a', '5'], + ], + }, + } as any); + + const wrapper = createFormWrapper({ + ...defaultFormValues, + grouping: { fields: ['host.name', 'service.name'] }, + }); + + const { result } = renderHook(() => useRulePreview(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // (host-1,svc-a), (host-1,svc-b), (host-2,svc-a) → 3 unique groups + expect(result.current.uniqueGroupCount).toBe(3); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_rule_preview.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_rule_preview.ts new file mode 100644 index 0000000000000..b1a30aaefd125 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_rule_preview.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useFormContext, useWatch } from 'react-hook-form'; +import type { FormValues } from '../types'; +import { assembleFullQuery } from '../utils/assemble_full_query'; +import { usePreview } from './use_preview'; + +// Re-export shared types for backward compatibility +export type { PreviewResult as RulePreviewResult, PreviewColumn } from './use_preview'; + +/** + * Rule preview hook. + * + * Watches the evaluation form fields (base query + optional condition), + * assembles the full query, and delegates to the generic `usePreview` hook + * for ES|QL execution, debouncing, and result mapping. + */ +export const useRulePreview = () => { + const { control } = useFormContext(); + + const baseQuery = useWatch({ control, name: 'evaluation.query.base' }); + const condition = useWatch({ control, name: 'evaluation.query.condition' }); + const timeField = useWatch({ control, name: 'timeField' }); + const lookback = useWatch({ control, name: 'schedule.lookback' }); + const groupingFields = useWatch({ control, name: 'grouping.fields' }) ?? []; + + const fullQuery = assembleFullQuery(baseQuery, condition); + + return usePreview({ + query: fullQuery, + timeField, + lookback, + groupingFields, + }); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_update_rule.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_update_rule.test.tsx index 439fbc617916b..a798d2ab9595d 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_update_rule.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_update_rule.test.tsx @@ -25,7 +25,6 @@ describe('useUpdateRule', () => { http, notifications, ruleId, - onSuccess, }), { wrapper: createQueryClientWrapper() } ); @@ -258,7 +257,7 @@ describe('useUpdateRule', () => { http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'My Updated Rule' } }); await act(async () => { - result.current.updateRule(validFormData); + result.current.updateRule(validFormData, { onSuccess }); }); await waitFor(() => { @@ -278,7 +277,7 @@ describe('useUpdateRule', () => { }); await act(async () => { - result.current.updateRule(validFormData); + result.current.updateRule(validFormData, { onSuccess }); }); await waitFor(() => { diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_update_rule.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_update_rule.ts index cfa86720864ea..b3c9241a8ead0 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_update_rule.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_update_rule.ts @@ -15,10 +15,9 @@ interface UseUpdateRuleProps { http: HttpStart; notifications: NotificationsStart; ruleId: string; - onSuccess?: () => void; } -export const useUpdateRule = ({ http, notifications, ruleId, onSuccess }: UseUpdateRuleProps) => { +export const useUpdateRule = ({ http, notifications, ruleId }: UseUpdateRuleProps) => { const mutation = useMutation( (formValues: FormValues) => { return http.patch(`/internal/alerting/v2/rule/${encodeURIComponent(ruleId)}`, { @@ -28,7 +27,6 @@ export const useUpdateRule = ({ http, notifications, ruleId, onSuccess }: UseUpd { onSuccess: (data: RuleResponse) => { notifications.toasts.addSuccess(`Rule '${data.metadata.name}' was updated successfully`); - onSuccess?.(); }, onError: (error: Error) => { notifications.toasts.addDanger(`Error updating rule: ${error.message}`); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/index.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/index.tsx index 962d05e2c94ae..eae14a0e5d1b1 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/index.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/index.tsx @@ -35,13 +35,24 @@ export const StandaloneRuleForm: React.FC = (props) => ); +// Lazy load preview component +const LazyRuleResultsPreview = React.lazy(() => + import('./fields/rule_results_preview').then((module) => ({ + default: module.RuleResultsPreview, + })) +); + +export const RuleResultsPreview: React.FC = () => ( + }> + + +); + export type { FormValues } from './types'; export type { DynamicRuleFormProps } from './dynamic_rule_form'; export type { StandaloneRuleFormProps } from './standalone_rule_form'; -export type { RuleFormServices } from './contexts'; -export { RuleFormServicesProvider, useRuleFormServices } from './contexts'; - -// Mappers +export type { RuleFormServices, RuleFormMeta, RuleFormLayout } from './contexts'; +export { RuleFormProvider, useRuleFormServices, useRuleFormMeta } from './contexts'; export { mapFormValuesToRuleRequest, mapFormValuesToCreateRequest, diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.test.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.test.tsx index 9773654d2f451..abd8dae412ae1 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.test.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.test.tsx @@ -12,6 +12,15 @@ import { RuleForm } from './rule_form'; import { RULE_FORM_ID } from './constants'; import { createFormWrapper, createMockServices } from '../test_utils'; +// Mock RulePreviewPanel to avoid rendering the full preview +jest.mock('./fields/rule_preview_panel', () => ({ + RulePreviewPanel: () =>
Preview Panel
, +})); + +// Mock NameField to avoid rendering inline edit title setup +jest.mock('./fields/name_field', () => ({ + NameField: () =>
Rule Name
, +})); const mockCreateRule = jest.fn(); const mockUpdateRule = jest.fn(); jest.mock('./hooks/use_create_rule', () => ({ @@ -270,7 +279,7 @@ describe('RuleForm', () => { }); describe('services context', () => { - it('provides services via RuleFormServicesProvider (tested implicitly)', () => { + it('provides services via RuleFormProvider (tested implicitly)', () => { // Child components use useRuleFormServices and would throw if context was not provided render(, { wrapper: createFormWrapper() }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.tsx index 323d414ef3d3e..978a6c8266b83 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.tsx @@ -5,22 +5,39 @@ * 2.0. */ -import React, { useCallback, useMemo, useState } from 'react'; -import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React, { useCallback, useRef, useMemo, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiSpacer, +} from '@elastic/eui'; import { useFormContext } from 'react-hook-form'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import { FormattedMessage } from '@kbn/i18n-react'; import type { FormValues } from './types'; import { EditModeToggle, type EditMode } from './components/edit_mode_toggle'; -import { RuleFormServicesProvider, useRuleFormServices, type RuleFormServices } from './contexts'; +import { + RuleFormProvider, + useRuleFormServices, + useRuleFormMeta, + type RuleFormServices, + type RuleFormLayout, +} from './contexts'; import { YamlRuleForm } from './yaml_rule_form'; import { GuiRuleForm } from './gui_rule_form'; +import { RulePreviewPanel } from './fields/rule_preview_panel'; +import { NameField } from './fields/name_field'; import { useCreateRule } from './hooks/use_create_rule'; import { useUpdateRule } from './hooks/use_update_rule'; import { RULE_FORM_ID } from './constants'; export interface RuleFormProps { services: RuleFormServices; + /** Layout mode: 'page' renders the preview side-by-side; 'flyout' uses a nested flyout. Default: 'page'. */ + layout?: RuleFormLayout; /** * External submit handler. When provided, form submission delegates to this callback. * When omitted and `includeSubmission` is true, the form uses `useCreateRule` internally. @@ -67,19 +84,8 @@ const SubmissionButtons: React.FC = ({ return ( <> - - - - - {submitLabel ?? defaultSubmitLabel} - - + + {onCancel && ( = ({ )} + + + {submitLabel ?? defaultSubmitLabel} + + ); @@ -119,6 +137,7 @@ const RuleFormContent: React.FC = ({ }) => { const { reset } = useFormContext(); const services = useRuleFormServices(); + const { layout } = useRuleFormMeta(); const { http, notifications } = services; const [editMode, setEditMode] = useState('form'); @@ -127,19 +146,27 @@ const RuleFormContent: React.FC = ({ const { createRule, isLoading: isCreating } = useCreateRule({ http, notifications, - onSuccess, }); const { updateRule, isLoading: isUpdating } = useUpdateRule({ http, notifications, ruleId: ruleId ?? '', - onSuccess, }); + // Keep a stable ref so the internalSubmit callback doesn't re-create on every render + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; + // Resolve the effective submit handler: external callback takes precedence, // otherwise use updateRule for edits (ruleId present) or createRule for new rules. - const internalSubmit = ruleId ? updateRule : createRule; + const internalSubmit = useCallback( + (values: FormValues) => { + const mutate = ruleId ? updateRule : createRule; + mutate(values, { onSuccess: onSuccessRef.current }); + }, + [ruleId, createRule, updateRule] + ); const onSubmit = externalOnSubmit ?? internalSubmit; const isSubmitting = externalIsSubmitting || isCreating || isUpdating; @@ -162,11 +189,22 @@ const RuleFormContent: React.FC = ({ const isYamlMode = editMode === 'yaml'; - return ( + const formContent = ( <> - {includeYaml && ( - <> - + {isYamlMode ? ( + includeYaml && ( + + ) + ) : ( + + + + + {includeYaml && ( = ({ disabled={isDisabled || isSubmitting} /> - - - + )} + )} + {isYamlMode && includeYaml ? ( = ({ )} ); + + if (layout === 'page') { + return ( + + {formContent} + + + + + ); + } + + // Flyout layout: form with nested flyout preview + return ( + <> + {formContent} + + + ); }; /** @@ -214,9 +271,9 @@ const RuleFormContent: React.FC = ({ * calls `onSuccess` after a successful API save. * * Includes its own QueryClientProvider for react-query hooks used by field components. - * Services are provided via RuleFormServicesProvider context, eliminating prop drilling. + * Services and layout metadata are provided via RuleFormProvider context, eliminating prop drilling. */ -export const RuleForm: React.FC = (props) => { +export const RuleForm: React.FC = ({ layout = 'page', ...props }) => { const queryClient = useMemo( () => new QueryClient({ @@ -230,11 +287,13 @@ export const RuleForm: React.FC = (props) => { [] ); + const meta = useMemo(() => ({ layout }), [layout]); + return ( - + - + ); }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/standalone_rule_form.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/standalone_rule_form.tsx index 474cef18130d2..de4947defbbaf 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/standalone_rule_form.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/standalone_rule_form.tsx @@ -9,13 +9,15 @@ import React, { useMemo } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import type { FormValues } from './types'; import { RuleForm } from './rule_form'; -import type { RuleFormServices } from './contexts'; +import type { RuleFormServices, RuleFormLayout } from './contexts'; import { useFormDefaults } from './hooks/use_form_defaults'; export interface StandaloneRuleFormProps { /** Initial query for the rule */ query: string; services: RuleFormServices; + /** Layout mode: 'page' renders the preview side-by-side; 'flyout' uses a nested flyout. Default: 'page'. */ + layout?: RuleFormLayout; /** * External submit handler. When provided, form submission delegates to this callback. * When omitted (and `includeSubmission` is true), the form uses `useCreateRule` internally. @@ -60,6 +62,7 @@ export interface StandaloneRuleFormProps { export const StandaloneRuleForm: React.FC = ({ query, services, + layout, onSubmit, onSuccess, includeYaml = false, @@ -113,6 +116,7 @@ export const StandaloneRuleForm: React.FC = ({ { + it('returns an empty string when base is empty', () => { + expect(assembleFullQuery('', 'WHERE count > 100')).toBe(''); + }); + + it('returns an empty string when base is undefined', () => { + expect(assembleFullQuery(undefined, 'WHERE count > 100')).toBe(''); + }); + + it('returns only the base when condition is empty', () => { + expect(assembleFullQuery('FROM logs-*', '')).toBe('FROM logs-*'); + }); + + it('returns only the base when condition is undefined', () => { + expect(assembleFullQuery('FROM logs-*', undefined)).toBe('FROM logs-*'); + }); + + it('returns only the base when condition is whitespace', () => { + expect(assembleFullQuery('FROM logs-*', ' ')).toBe('FROM logs-*'); + }); + + it('pipes condition that already has a WHERE prefix', () => { + expect(assembleFullQuery('FROM logs-* | STATS count() BY host', 'WHERE count > 100')).toBe( + 'FROM logs-* | STATS count() BY host | WHERE count > 100' + ); + }); + + it('adds WHERE keyword when condition lacks the prefix', () => { + expect(assembleFullQuery('FROM logs-* | STATS count() BY host', 'count > 100')).toBe( + 'FROM logs-* | STATS count() BY host | WHERE count > 100' + ); + }); + + it('handles lowercase where prefix', () => { + expect(assembleFullQuery('FROM logs-*', 'where status >= 500')).toBe( + 'FROM logs-* | where status >= 500' + ); + }); + + it('handles mixed case WHERE prefix', () => { + expect(assembleFullQuery('FROM logs-*', 'Where status >= 500')).toBe( + 'FROM logs-* | Where status >= 500' + ); + }); + + it('trims whitespace from base and condition', () => { + expect(assembleFullQuery(' FROM logs-* ', ' WHERE x > 1 ')).toBe( + 'FROM logs-* | WHERE x > 1' + ); + }); + + it('returns empty string when both base and condition are empty', () => { + expect(assembleFullQuery('', '')).toBe(''); + }); + + it('returns empty string when both base and condition are undefined', () => { + expect(assembleFullQuery(undefined, undefined)).toBe(''); + }); + + it('does not treat WHERE inside the condition expression as a prefix', () => { + // A condition like "field LIKE '%WHERE%'" should get WHERE prepended + expect(assembleFullQuery('FROM logs-*', "field LIKE '%WHERE%'")).toBe( + "FROM logs-* | WHERE field LIKE '%WHERE%'" + ); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/assemble_full_query.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/assemble_full_query.ts new file mode 100644 index 0000000000000..80ed5618f0dc0 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/assemble_full_query.ts @@ -0,0 +1,25 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Assembles the full ES|QL query from a base query and an optional condition. + * + * The condition may be stored with or without a `WHERE` prefix depending on + * whether the user has interacted with the WhereClauseEditor: + * - Initial defaults from `splitQueryAndCondition`: `count > 100` + * - After editor interaction: `WHERE count > 100` + * + * This utility normalises both forms so callers don't need to care. + */ +export const assembleFullQuery = (base?: string, condition?: string): string => { + const b = base?.trim() ?? ''; + const c = condition?.trim() ?? ''; + if (!b) return ''; + if (!c) return b; + if (/^WHERE\s/i.test(c)) return `${b} | ${c}`; + return `${b} | WHERE ${c}`; +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.test.ts index f8f42f3ed740e..48c43dc48b356 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.test.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.test.ts @@ -222,6 +222,54 @@ describe('rule_request_mappers', () => { expect(result.state_transition).toBeUndefined(); }); + it('maps state_transition with recovering count and timeframe', () => { + const formValues: FormValues = { + ...baseFormValues, + kind: 'alert', + stateTransition: { recoveringCount: 4, recoveringTimeframe: '15m' }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.state_transition).toEqual({ + recovering_count: 4, + recovering_timeframe: '15m', + }); + }); + + it('maps state_transition with only recovering count (no timeframe)', () => { + const formValues: FormValues = { + ...baseFormValues, + kind: 'alert', + stateTransition: { recoveringCount: 3 }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.state_transition).toEqual({ recovering_count: 3 }); + expect(result.state_transition).not.toHaveProperty('recovering_timeframe'); + }); + + it('maps state_transition with both pending and recovering fields', () => { + const formValues: FormValues = { + ...baseFormValues, + kind: 'alert', + stateTransition: { + pendingCount: 2, + recoveringCount: 5, + recoveringTimeframe: '10m', + }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.state_transition).toEqual({ + pending_count: 2, + recovering_count: 5, + recovering_timeframe: '10m', + }); + }); + it('strips enabled and description from metadata (API does not accept them)', () => { const formValues: FormValues = { ...baseFormValues, @@ -466,6 +514,44 @@ describe('rule_request_mappers', () => { expect(result.stateTransition).toEqual({ pendingCount: 3, pendingTimeframe: '10m', + recoveringCount: undefined, + recoveringTimeframe: undefined, + }); + }); + + it('maps state_transition with recovering fields', () => { + const rule = { + ...baseRuleResponse, + state_transition: { recovering_count: 5, recovering_timeframe: '15m' }, + } as RuleResponse; + + const result = mapRuleResponseToFormValues(rule); + + expect(result.stateTransition).toEqual({ + pendingCount: undefined, + pendingTimeframe: undefined, + recoveringCount: 5, + recoveringTimeframe: '15m', + }); + }); + + it('maps state_transition with both pending and recovering fields', () => { + const rule = { + ...baseRuleResponse, + state_transition: { + pending_count: 2, + recovering_count: 4, + recovering_timeframe: '20m', + }, + } as RuleResponse; + + const result = mapRuleResponseToFormValues(rule); + + expect(result.stateTransition).toEqual({ + pendingCount: 2, + pendingTimeframe: undefined, + recoveringCount: 4, + recoveringTimeframe: '20m', }); }); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.ts index 58b9cf4e669b2..74a20a3f5904d 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.ts @@ -87,17 +87,27 @@ const mapStateTransition = ( kind: FormValues['kind'], stateTransition: FormValues['stateTransition'] ) => { - const hasStateTransition = - kind === 'alert' && - stateTransition != null && - (stateTransition.pendingCount != null || stateTransition.pendingTimeframe != null); + if (kind !== 'alert' || stateTransition == null) return undefined; - if (!hasStateTransition) return undefined; + const hasPending = + stateTransition.pendingCount != null || stateTransition.pendingTimeframe != null; + const hasRecovering = + stateTransition.recoveringCount != null || stateTransition.recoveringTimeframe != null; + + if (!hasPending && !hasRecovering) return undefined; return { - pending_count: stateTransition!.pendingCount, - ...(stateTransition!.pendingTimeframe != null - ? { pending_timeframe: stateTransition!.pendingTimeframe } + ...(stateTransition.pendingCount != null + ? { pending_count: stateTransition.pendingCount } + : {}), + ...(stateTransition.pendingTimeframe != null + ? { pending_timeframe: stateTransition.pendingTimeframe } + : {}), + ...(stateTransition.recoveringCount != null + ? { recovering_count: stateTransition.recoveringCount } + : {}), + ...(stateTransition.recoveringTimeframe != null + ? { recovering_timeframe: stateTransition.recoveringTimeframe } : {}), }; }; @@ -113,7 +123,12 @@ export interface RuleRequestCommon { evaluation: { query: { base: string; condition?: string } }; grouping?: { fields: string[] }; recovery_policy?: { type: RecoveryPolicyType; query?: { base?: string; condition?: string } }; - state_transition?: { pending_count?: number; pending_timeframe?: string }; + state_transition?: { + pending_count?: number; + pending_timeframe?: string; + recovering_count?: number; + recovering_timeframe?: string; + }; } /** @@ -219,6 +234,8 @@ export const mapRuleResponseToFormValues = (rule: RuleResponse): Partial = {}, - services: RuleFormServices = createMockServices() + services: RuleFormServices = createMockServices(), + meta: RuleFormMeta = { layout: 'page' } ) => { const queryClient = createTestQueryClient(); @@ -99,7 +100,9 @@ export const createFormWrapper = ( - {children} + + {children} +