diff --git a/public/app/features/alerting/unified/components/AlertRuleDrawerForm.test.tsx b/public/app/features/alerting/unified/components/AlertRuleDrawerForm.test.tsx new file mode 100644 index 00000000000..f9174222bea --- /dev/null +++ b/public/app/features/alerting/unified/components/AlertRuleDrawerForm.test.tsx @@ -0,0 +1,270 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from 'test/test-utils'; + +import { setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; +import { AccessControlAction } from 'app/types/accessControl'; + +import { GrafanaGroupUpdatedResponse } from '../api/alertRuleModel'; +import { ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form'; + +import { AlertRuleDrawerForm, AlertRuleDrawerFormProps } from './AlertRuleDrawerForm'; + +setupMswServer(); + +// Mock the hooks +const mockExecute = jest.fn(); +jest.mock('../hooks/ruleGroup/useUpsertRuleFromRuleGroup', () => ({ + useAddRuleToRuleGroup: () => [{ execute: mockExecute }], +})); + +// Mock notification hooks +const mockError = jest.fn(); +const mockSuccess = jest.fn(); +jest.mock('app/core/copy/appNotification', () => ({ + useAppNotification: () => ({ + error: mockError, + success: mockSuccess, + }), +})); + +const defaultProps: AlertRuleDrawerFormProps = { + isOpen: true, + onClose: jest.fn(), +}; + +const renderDrawer = (props: Partial = {}) => { + return render(); +}; + +describe('AlertRuleDrawerForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + grantUserPermissions([ + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + ]); + }); + + describe('Rendering', () => { + it('should not render when isOpen is false', () => { + renderDrawer({ isOpen: false }); + expect(screen.queryByRole('button', { name: /Create/i })).not.toBeInTheDocument(); + }); + + it('should render "Continue in Alerting" button when callback is provided', () => { + renderDrawer({ onContinueInAlerting: jest.fn() }); + expect(screen.getByRole('button', { name: /Continue in Alerting/i })).toBeInTheDocument(); + }); + }); + + describe('Cancel button', () => { + it('should call onClose when Cancel is clicked', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + renderDrawer({ onClose }); + + await user.click(screen.getByRole('button', { name: /Cancel/i })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should reset form when Cancel is clicked with prefill', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + const prefill: Partial = { + name: 'Prefilled Rule Name', + }; + const { rerender } = renderDrawer({ onClose, prefill }); + + // Verify prefilled value is present + const nameInput = screen.getByLabelText(/Name/i); + expect(nameInput).toHaveValue('Prefilled Rule Name'); + + // Modify the field + await user.clear(nameInput); + await user.type(nameInput, 'Changed Name'); + expect(nameInput).toHaveValue('Changed Name'); + + // Click cancel - this triggers reset to prefill + await user.click(screen.getByRole('button', { name: /Cancel/i })); + expect(onClose).toHaveBeenCalled(); + + // Reopen to verify reset happened + rerender(); + expect(screen.getByLabelText(/Name/i)).toHaveValue('Prefilled Rule Name'); + }); + }); + + describe('Continue in Alerting button', () => { + it('should call onContinueInAlerting with current form values', async () => { + const user = userEvent.setup(); + const onContinueInAlerting = jest.fn(); + const onClose = jest.fn(); + renderDrawer({ onContinueInAlerting, onClose }); + + // Fill in a field + const nameInput = screen.getByLabelText(/Name/i); + await user.type(nameInput, 'Test Rule'); + + // Click Continue in Alerting + await user.click(screen.getByRole('button', { name: /Continue in Alerting/i })); + + await waitFor(() => { + expect(onContinueInAlerting).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Test Rule', + }) + ); + }); + }); + + it('should normalize contact points when calling onContinueInAlerting', async () => { + const user = userEvent.setup(); + const onContinueInAlerting = jest.fn(); + const onClose = jest.fn(); + + // Provide prefill with partial contact point data + // We intentionally create an incomplete ContactPoint to test that normalizeContactPoints fills in the missing optional fields with defaults + const prefill: Partial = { + name: 'Test', + contactPoints: { + grafana: { + selectedContactPoint: 'test-contact', + } as ContactPoint, + }, + }; + + renderDrawer({ onContinueInAlerting, onClose, prefill }); + + // Click Continue in Alerting + await user.click(screen.getByRole('button', { name: /Continue in Alerting/i })); + + await waitFor(() => { + expect(onContinueInAlerting).toHaveBeenCalledWith( + expect.objectContaining({ + contactPoints: expect.objectContaining({ + grafana: expect.objectContaining({ + selectedContactPoint: 'test-contact', + // Verify normalization added default values + overrideGrouping: false, + groupBy: [], + overrideTimings: false, + groupWaitValue: '', + groupIntervalValue: '', + repeatIntervalValue: '', + muteTimeIntervals: [], + activeTimeIntervals: [], + }), + }), + }) + ); + }); + }); + + it('should close drawer after calling onContinueInAlerting', async () => { + const user = userEvent.setup(); + const onContinueInAlerting = jest.fn(); + const onClose = jest.fn(); + renderDrawer({ onContinueInAlerting, onClose }); + + // Click Continue in Alerting + await user.click(screen.getByRole('button', { name: /Continue in Alerting/i })); + + await waitFor(() => { + expect(onContinueInAlerting).toHaveBeenCalled(); + }); + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe('Prefill behavior', () => { + it('should initialize form with prefill values', () => { + const prefill: Partial = { + name: 'Prefilled Rule', + type: RuleFormType.grafana, + }; + renderDrawer({ prefill }); + + expect(screen.getByLabelText(/Name/i)).toHaveValue('Prefilled Rule'); + }); + + it('should reset form when prefill changes', async () => { + const prefill1: Partial = { + name: 'First Rule', + }; + const { rerender } = render(); + + expect(screen.getByLabelText(/Name/i)).toHaveValue('First Rule'); + + // Update prefill + const prefill2: Partial = { + name: 'Second Rule', + }; + rerender(); + + // Wait for the useEffect to trigger the reset + await waitFor(() => { + expect(screen.getByLabelText(/Name/i)).toHaveValue('Second Rule'); + }); + }); + + it('should reset to defaults when prefill becomes undefined', async () => { + const prefill: Partial = { + name: 'Prefilled Rule', + }; + const { rerender } = render(); + + expect(screen.getByLabelText(/Name/i)).toHaveValue('Prefilled Rule'); + + // Clear prefill + rerender(); + + // Wait for the useEffect to trigger the reset + await waitFor(() => { + expect(screen.getByLabelText(/Name/i)).toHaveValue(''); + }); + }); + }); + + describe('Create button and submission', () => { + it('should close drawer on successful rule creation', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + const mockResponse: GrafanaGroupUpdatedResponse = { + message: 'Rule created successfully', + created: ['rule-uid'], + }; + mockExecute.mockResolvedValue(mockResponse); + + renderDrawer({ onClose }); + + // Fill in required field + const nameInput = screen.getByLabelText(/Name/i); + await user.type(nameInput, 'Test Alert Rule'); + + // Click Create + await user.click(screen.getByRole('button', { name: /Create/i })); + + // Drawer should close on success + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('should show validation error when form is invalid', async () => { + const user = userEvent.setup(); + renderDrawer(); + + // Try to submit without filling required fields (name is required) + await user.click(screen.getByRole('button', { name: /Create/i })); + + await waitFor(() => { + expect(mockError).toHaveBeenCalledWith('There are errors in the form. Please correct them and try again!'); + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/AlertRuleDrawerForm.tsx b/public/app/features/alerting/unified/components/AlertRuleDrawerForm.tsx index 2d4260b26d8..9596e66a126 100644 --- a/public/app/features/alerting/unified/components/AlertRuleDrawerForm.tsx +++ b/public/app/features/alerting/unified/components/AlertRuleDrawerForm.tsx @@ -9,7 +9,7 @@ import { useAppNotification } from 'app/core/copy/appNotification'; import { getMessageFromError } from 'app/core/utils/errors'; import { RuleDefinitionSection } from 'app/features/alerting/unified/components/RuleDefinitionSection'; -import { isGrafanaGroupUpdatedResponse } from '../api/alertRuleModel'; +import { isCloudGroupUpdatedResponse, isGrafanaGroupUpdatedResponse } from '../api/alertRuleModel'; import { useAddRuleToRuleGroup } from '../hooks/ruleGroup/useUpsertRuleFromRuleGroup'; import { getDefaultFormValues } from '../rule-editor/formDefaults'; import { AlertManagerManualRouting, ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form'; @@ -19,6 +19,13 @@ import { getRuleGroupLocationFromFormValues } from '../utils/rules'; import { RuleConditionSection } from './RuleConditionSection'; import { RuleNotificationSection } from './RuleNotificationSection'; +/** + * Normalizes contact point fields to ensure all properties have defined values. + * This is only needed for the "Continue in Alerting" flow, which passes RuleFormValues + * directly to the rule editor page. The submit flow doesn't need this because + * getNotificationSettingsForDTO (called by formValuesToRulerGrafanaRuleDTO) already + * handles partial/missing fields when building the DTO for the backend. + */ function normalizeContactPoints( contactPoints: AlertManagerManualRouting | undefined ): AlertManagerManualRouting | undefined { @@ -84,6 +91,9 @@ export function AlertRuleDrawerForm({ const submit = async (values: RuleFormValues) => { try { + // The drawer doesn't expose a group field to keep the UX simple. + // We derive the group name from the rule name as a sensible default. + // The 'default' fallback should rarely occur since 'name' is a required field. const groupName = values.group && values.group.trim().length > 0 ? values.group : values.name?.trim() || 'default'; const effectiveValues: RuleFormValues = { ...values, group: groupName }; @@ -97,10 +107,11 @@ export function AlertRuleDrawerForm({ return; } - // Check if result has an error message - if (result && typeof result === 'object' && 'error' in result) { - notifyApp.error('Failed to create rule', String(result.error)); + // Handle cloud rules error response + if (isCloudGroupUpdatedResponse(result)) { + notifyApp.error('Failed to create rule', result.error); } else { + // This should not happen with the current discriminated union types notifyApp.error('Failed to create rule', 'Please review the form and try again.'); } } catch (err) { @@ -147,6 +158,7 @@ export function AlertRuleDrawerForm({ ...currentValues, contactPoints: normalizeContactPoints(currentValues.contactPoints), }); + onClose(); }} > {t('alerting.simplified.continue-in-alerting', 'Continue in Alerting')}