Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@
*/

import React from 'react';
import { EuiCheckableCard, EuiText } from '@elastic/eui';
import { EuiCheckableCard, EuiIconTip, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Controller, useFormContext } from 'react-hook-form';
import type { FormValues } from '../types';

const CARD_ID = 'ruleV2KindField';

export const KindField = () => {
interface KindFieldProps {
disabled?: boolean;
compact?: boolean;
}

const LABEL_TEXT = i18n.translate('xpack.alertingV2.ruleForm.kindField.checkboxLabel', {
defaultMessage: 'Track active and recovered state over time',
});

export const KindField = ({ disabled = false, compact = false }: KindFieldProps) => {
const { control } = useFormContext<FormValues>();

return (
Expand All @@ -23,19 +32,43 @@ export const KindField = () => {
render={({ field: { value, onChange } }) => {
const isChecked = value === 'alert';

if (compact) {
return (
<EuiCheckableCard
id={CARD_ID}
checkableType="checkbox"
label={
<>
{LABEL_TEXT}{' '}
<EuiIconTip
type="info"
position="top"
content={i18n.translate(
'xpack.alertingV2.ruleForm.kindField.tooltipDescription',
{
defaultMessage:
'Enables lifecycle management: the system will track state transitions across alert events for each series, manage episodes, and dispatch to action policies. Without this, alert events are observation-only records.',
}
)}
/>
</>
}
checked={isChecked}
onChange={() => onChange(isChecked ? 'signal' : 'alert')}
disabled={disabled}
data-test-subj="kindField"
/>
);
}

return (
<EuiCheckableCard
id={CARD_ID}
checkableType="checkbox"
label={
<strong>
{i18n.translate('xpack.alertingV2.ruleForm.kindField.checkboxLabel', {
defaultMessage: 'Track active and recovered state over time',
})}
</strong>
}
label={<strong>{LABEL_TEXT}</strong>}
checked={isChecked}
onChange={() => onChange(isChecked ? 'signal' : 'alert')}
disabled={disabled}
data-test-subj="kindField"
>
<EuiText size="s" color="subdued">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface GuiRuleFormProps {
onSubmit: (values: FormValues) => void;
/** Whether to include the ES|QL query editor (default: true) */
includeQueryEditor?: boolean;
/** Whether the form is editing an existing rule (disables immutable fields like kind) */
isEditing?: boolean;
}

/**
Expand All @@ -35,7 +37,11 @@ export interface GuiRuleFormProps {
*
* Requires a FormProvider context with FormValues type to be present in the component tree.
*/
export const GuiRuleForm = ({ onSubmit, includeQueryEditor = true }: GuiRuleFormProps) => {
export const GuiRuleForm = ({
onSubmit,
includeQueryEditor = true,
isEditing = false,
}: GuiRuleFormProps) => {
const { handleSubmit } = useFormContext<FormValues>();

return (
Expand All @@ -46,7 +52,7 @@ export const GuiRuleForm = ({ onSubmit, includeQueryEditor = true }: GuiRuleForm
<EuiSpacer size="m" />
<RuleExecutionFieldGroup />
<EuiSpacer size="m" />
<KindField />
<KindField disabled={isEditing} />
<EuiSpacer size="m" />
<AlertConditionsFieldGroup />
<EuiSpacer size="m" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,10 @@ export {
mapRuleResponseToFormValues,
} from './utils/rule_request_mappers';
export type { RuleRequestCommon } from './utils/rule_request_mappers';

// Field groups — for composing custom form layouts
export { RuleDetailsFieldGroup } from './field_groups/rule_details_field_group';
export { ConditionFieldGroup } from './field_groups/condition_field_group';
export { RuleExecutionFieldGroup } from './field_groups/rule_execution_field_group';
export { AlertConditionsFieldGroup } from './field_groups/alert_conditions_field_group';
export { KindField } from './fields/kind_field';
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,11 @@ const RuleFormContent = ({
setYamlText={setYamlText}
/>
) : (
<GuiRuleForm onSubmit={onSubmit} includeQueryEditor={includeQueryEditor} />
<GuiRuleForm
onSubmit={onSubmit}
includeQueryEditor={includeQueryEditor}
isEditing={Boolean(ruleId)}
/>
)}

{includeSubmission && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ export {
mapRuleResponseToFormValues,
} from './form';

// Field groups — for composing custom form layouts
export {
RuleDetailsFieldGroup,
ConditionFieldGroup,
RuleExecutionFieldGroup,
AlertConditionsFieldGroup,
KindField,
} from './form';

// Types
export type {
FormValues,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@

export { RuleSummaryFlyout } from './rule_summary_flyout';
export type { RuleSummaryFlyoutProps } from './rule_summary_flyout';

export { QuickEditRuleFlyout } from './quick_edit_rule_flyout';
export type { QuickEditRuleFlyoutProps } from './quick_edit_rule_flyout';
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* 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, waitFor } from '@testing-library/react';
import { I18nProvider } from '@kbn/i18n-react';
import { QueryClient, QueryClientProvider } from '@kbn/react-query';
import { QuickEditRuleFlyout } from './quick_edit_rule_flyout';
import type { RuleApiResponse } from '../../../services/rules_api';

const mockMutate = jest.fn();

jest.mock('@kbn/core-di-browser', () => ({
useService: () => ({}),
CoreStart: (key: string) => key,
}));

jest.mock('@kbn/core-di', () => ({
PluginStart: (key: string) => key,
}));

jest.mock('../../../hooks/use_update_rule', () => ({
useUpdateRule: () => ({
mutate: mockMutate,
isLoading: false,
}),
}));

jest.mock('@kbn/alerting-v2-rule-form', () => ({
RuleFormProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
RuleDetailsFieldGroup: () => <div data-test-subj="mockRuleDetailsFieldGroup" />,
ConditionFieldGroup: () => <div data-test-subj="mockConditionFieldGroup" />,
RuleExecutionFieldGroup: () => <div data-test-subj="mockRuleExecutionFieldGroup" />,
AlertConditionsFieldGroup: () => <div data-test-subj="mockAlertConditionsFieldGroup" />,
KindField: ({ disabled, compact }: { disabled?: boolean; compact?: boolean }) => (
<div data-test-subj="mockKindField" data-disabled={disabled} data-compact={compact} />
),
mapRuleResponseToFormValues: (rule: unknown) => ({
kind: 'alert',
metadata: { name: 'Test Rule', enabled: true, description: '', tags: [] },
timeField: '@timestamp',
schedule: { every: '1m', lookback: '5m' },
evaluation: { query: { base: 'FROM logs-*' } },
recoveryPolicy: { type: 'no_breach' },
stateTransitionAlertDelayMode: 'immediate',
stateTransitionRecoveryDelayMode: 'immediate',
}),
mapFormValuesToUpdateRequest: (values: unknown) => values,
}));

const baseRule = {
id: 'rule-1',
kind: 'alert',
enabled: true,
metadata: { name: 'Test Rule' },
} as RuleApiResponse;

const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});

const renderFlyout = (
overrides: Partial<React.ComponentProps<typeof QuickEditRuleFlyout>> = {}
) => {
const props = {
rule: baseRule,
onClose: jest.fn(),
...overrides,
};

const utils = render(
<QueryClientProvider client={queryClient}>
<I18nProvider>
<QuickEditRuleFlyout {...props} />
</I18nProvider>
</QueryClientProvider>
);

return { ...utils, props };
};

describe('QuickEditRuleFlyout', () => {
beforeEach(() => {
mockMutate.mockClear();
});

it('renders the flyout with the title and info tooltip', () => {
renderFlyout();

expect(screen.getByTestId('quickEditRuleFlyout')).toBeInTheDocument();
expect(screen.getByText('Quick Edit Alert Rule')).toBeInTheDocument();
});

it('renders all field groups', () => {
renderFlyout();

expect(screen.getByTestId('mockRuleDetailsFieldGroup')).toBeInTheDocument();
expect(screen.getByTestId('mockConditionFieldGroup')).toBeInTheDocument();
expect(screen.getByTestId('mockRuleExecutionFieldGroup')).toBeInTheDocument();
expect(screen.getByTestId('mockAlertConditionsFieldGroup')).toBeInTheDocument();
});

it('renders KindField as disabled and compact', () => {
renderFlyout();

const kindField = screen.getByTestId('mockKindField');
expect(kindField).toHaveAttribute('data-disabled', 'true');
expect(kindField).toHaveAttribute('data-compact', 'true');
});

it('calls onClose when the close button is clicked', () => {
const { props } = renderFlyout();

fireEvent.click(screen.getByTestId('quickEditRuleFlyoutCloseButton'));

expect(props.onClose).toHaveBeenCalledTimes(1);
});

it('calls onClose when the cancel button is clicked', () => {
const { props } = renderFlyout();

fireEvent.click(screen.getByTestId('quickEditRuleFlyoutCancelButton'));

expect(props.onClose).toHaveBeenCalledTimes(1);
});

it('calls updateRule.mutate with the rule id when the form is submitted', async () => {
renderFlyout();

fireEvent.click(screen.getByTestId('quickEditRuleFlyoutSubmitButton'));

await waitFor(() => {
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ id: 'rule-1' }),
expect.objectContaining({ onSuccess: expect.any(Function) })
);
});
});

it('calls onClose on successful mutation', async () => {
const { props } = renderFlyout();

fireEvent.click(screen.getByTestId('quickEditRuleFlyoutSubmitButton'));

await waitFor(() => {
expect(mockMutate).toHaveBeenCalledTimes(1);
});

const { onSuccess } = mockMutate.mock.calls[0][1];
onSuccess();

expect(props.onClose).toHaveBeenCalledTimes(1);
});

it('does not call onClose when the mutation fails', async () => {
const { props } = renderFlyout();

fireEvent.click(screen.getByTestId('quickEditRuleFlyoutSubmitButton'));

await waitFor(() => {
expect(mockMutate).toHaveBeenCalledTimes(1);
});

expect(props.onClose).not.toHaveBeenCalled();
});
});
Loading
Loading