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 new file mode 100644 index 0000000000000..af601b14ffaa1 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/query_key_factory.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export const ruleFormKeys = { + all: ['ruleForm'] 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 207b1ce299932..271a43f9866f8 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 @@ -59,11 +59,11 @@ describe('useCreateRule', () => { // Note: empty condition field is omitted from the payload const expectedApiPayload = { kind: 'signal', - time_field: '@timestamp', metadata: { name: 'Test Rule', labels: ['tag1', 'tag2'], }, + time_field: '@timestamp', schedule: { every: '5m', lookback: '1m' }, evaluation: { query: { @@ -362,11 +362,11 @@ describe('useCreateRule', () => { // Note: empty condition field is omitted from the payload const expectedPayload = { kind: 'signal', - time_field: 'event.timestamp', metadata: { name: 'Complex Rule', labels: ['production', 'critical'], }, + time_field: 'event.timestamp', schedule: { every: '1m', lookback: '1m' }, evaluation: { query: { 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 33953e2d3ae86..9b300f2a20cd8 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 @@ -7,8 +7,9 @@ import { useMutation } from '@kbn/react-query'; import type { HttpStart, NotificationsStart } from '@kbn/core/public'; -import type { CreateRuleData, RuleResponse } from '@kbn/alerting-v2-schemas'; +import type { RuleResponse } from '@kbn/alerting-v2-schemas'; import type { FormValues } from '../types'; +import { mapFormValuesToCreateRequest } from '../utils/rule_request_mappers'; interface UseCreateRuleProps { http: HttpStart; @@ -16,110 +17,11 @@ interface UseCreateRuleProps { onSuccess?: () => void; } -/** - * Builds the `recovery_policy.query` portion of the API payload. - * - * Two modes: - * 1. **Condition mode** – The user specified an evaluation condition (WHERE clause) - * and wrote a recovery condition. The base query for recovery is the same - * evaluation base query. - * 2. **Full-query mode** – The user wrote a standalone recovery base query - * (no evaluation condition was split out). - */ -const buildRecoveryQuery = ( - recoveryPolicy: NonNullable, - evaluation: FormValues['evaluation'] -): { query: { base: string; condition?: string } } | Record => { - const { query } = recoveryPolicy; - - // Condition-only mode: recovery WHERE clause applied to the evaluation base query - if (query?.condition) { - return { - query: { - base: query.base || evaluation.query.base, - condition: query.condition, - }, - }; - } - - // Full-query mode: user provided a standalone recovery base query - if (query?.base) { - return { query: { base: query.base } }; - } - - return {}; -}; - -/** - * Maps form values to the API request payload. - * This function serves as the boundary between the form contract (FormValues) - * and the API contract (CreateRuleData). - */ -const mapFormValuesToCreateRuleData = (formValues: FormValues): CreateRuleData => { - const { - kind, - metadata, - timeField, - schedule, - evaluation, - grouping, - recoveryPolicy, - stateTransition, - } = formValues; - - const hasStateTransition = - kind === 'alert' && - stateTransition != null && - (stateTransition.pendingCount != null || stateTransition.pendingTimeframe != null); - - return { - kind, - time_field: timeField, - metadata: { - name: metadata.name, - owner: metadata.owner, - labels: metadata.labels, - }, - schedule: { - every: schedule.every, - lookback: schedule.lookback, - }, - evaluation: { - query: { - base: evaluation.query.base, - ...(evaluation.query.condition ? { condition: evaluation.query.condition } : {}), - }, - }, - ...(grouping?.fields?.length ? { grouping: { fields: grouping.fields } } : {}), - ...(recoveryPolicy - ? { - recovery_policy: { - type: recoveryPolicy.type, - ...(recoveryPolicy.type === 'query' - ? buildRecoveryQuery(recoveryPolicy, evaluation) - : {}), - }, - } - : {}), - ...(hasStateTransition - ? { - state_transition: { - pending_count: stateTransition!.pendingCount, - ...(stateTransition!.pendingTimeframe != null - ? { pending_timeframe: stateTransition!.pendingTimeframe } - : {}), - }, - } - : {}), - }; -}; - export const useCreateRule = ({ http, notifications, onSuccess }: UseCreateRuleProps) => { const mutation = useMutation( (formValues: FormValues) => { - const ruleData = mapFormValuesToCreateRuleData(formValues); return http.post('/internal/alerting/v2/rule', { - body: JSON.stringify(ruleData), + body: JSON.stringify(mapFormValuesToCreateRequest(formValues)), }); }, { diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_data_fields.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_data_fields.ts index 655d5f8f45ae2..aed9b2b8db93c 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_data_fields.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_data_fields.ts @@ -11,6 +11,7 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { DataViewFieldMap } from '@kbn/data-views-plugin/common'; import { useQuery } from '@kbn/react-query'; import { getESQLAdHocDataview } from '@kbn/esql-utils'; +import { ruleFormKeys } from './query_key_factory'; interface UseDataFieldsProps { query: string; @@ -24,7 +25,7 @@ export const useDataFields = ({ query, http, dataViews, onSuccess }: UseDataFiel onSuccessRef.current = onSuccess; const fieldsQuery = useQuery({ - queryKey: ['dataFields', query], + queryKey: ruleFormKeys.dataFields(query), queryFn: async () => { const dataView = await getESQLAdHocDataview({ dataViewsService: dataViews, diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_query_columns.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_query_columns.ts index 49ecf528cbad5..2f533a744f838 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_query_columns.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_query_columns.ts @@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react'; import type { ISearchGeneric } from '@kbn/search-types'; import { useQuery } from '@kbn/react-query'; import { getESQLQueryColumnsRaw } from '@kbn/esql-utils'; +import { ruleFormKeys } from './query_key_factory'; export interface QueryColumn { name: string; @@ -26,7 +27,7 @@ export const useQueryColumns = ({ query, search, onSuccess }: UseQueryColumnsPro onSuccessRef.current = onSuccess; const columnsQuery = useQuery({ - queryKey: ['queryColumns', query], + queryKey: ruleFormKeys.queryColumns(query), queryFn: async ({ signal }) => { return getESQLQueryColumnsRaw({ esqlQuery: query, 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 new file mode 100644 index 0000000000000..439fbc617916b --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_update_rule.test.tsx @@ -0,0 +1,315 @@ +/* + * 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, act, waitFor } from '@testing-library/react'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; +import { createQueryClientWrapper } from '../../test_utils'; +import { useUpdateRule } from './use_update_rule'; +import type { FormValues } from '../types'; + +describe('useUpdateRule', () => { + const ruleId = 'rule-abc-123'; + + const setupUseUpdateRule = () => { + const http = httpServiceMock.createStartContract(); + const notifications = notificationServiceMock.createStartContract(); + const onSuccess = jest.fn(); + const hook = renderHook( + () => + useUpdateRule({ + http, + notifications, + ruleId, + onSuccess, + }), + { wrapper: createQueryClientWrapper() } + ); + + return { http, notifications, onSuccess, ...hook }; + }; + + const getLastPatchedBody = (http: ReturnType) => { + const lastCallArgs = http.patch.mock.calls[http.patch.mock.calls.length - 1]; + const requestOptions = lastCallArgs[lastCallArgs.length - 1] as { body: string }; + return JSON.parse(requestOptions.body); + }; + + const validFormData: FormValues = { + kind: 'signal', + metadata: { + name: 'Updated Rule', + enabled: true, + labels: ['tag1', 'tag2'], + }, + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs | LIMIT 10', + condition: '', + }, + }, + grouping: { fields: ['host.name'] }, + }; + + it('calls the correct API endpoint with encoded ruleId', async () => { + const { http, result } = setupUseUpdateRule(); + + http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Updated Rule' } }); + + await act(async () => { + result.current.updateRule(validFormData); + }); + + await waitFor(() => { + expect(http.patch).toHaveBeenCalledWith( + `/internal/alerting/v2/rule/${encodeURIComponent(ruleId)}`, + expect.any(Object) + ); + }); + }); + + it('does not include kind in the update payload', async () => { + const { http, result } = setupUseUpdateRule(); + + http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Updated Rule' } }); + + await act(async () => { + result.current.updateRule(validFormData); + }); + + await waitFor(() => { + const body = getLastPatchedBody(http); + expect(body).not.toHaveProperty('kind'); + }); + }); + + it('coerces absent optional fields to null for explicit removal', async () => { + const { http, result } = setupUseUpdateRule(); + + http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Minimal Rule' } }); + + const minimalFormData: FormValues = { + kind: 'signal', + metadata: { name: 'Minimal Rule', enabled: true }, + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { query: { base: 'FROM logs | LIMIT 10' } }, + }; + + await act(async () => { + result.current.updateRule(minimalFormData); + }); + + await waitFor(() => { + const body = getLastPatchedBody(http); + expect(body.grouping).toBeNull(); + expect(body.recovery_policy).toBeNull(); + expect(body.state_transition).toBeNull(); + }); + }); + + it('sends the correctly mapped form data as JSON', async () => { + const { http, result } = setupUseUpdateRule(); + + http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Updated Rule' } }); + + await act(async () => { + result.current.updateRule(validFormData); + }); + + // Note: empty condition field is omitted, absent optional fields are null + const expectedPayload = { + metadata: { name: 'Updated Rule', labels: ['tag1', 'tag2'] }, + time_field: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { query: { base: 'FROM logs | LIMIT 10' } }, + grouping: { fields: ['host.name'] }, + recovery_policy: null, + state_transition: null, + }; + + await waitFor(() => { + expect(http.patch).toHaveBeenCalledWith( + `/internal/alerting/v2/rule/${encodeURIComponent(ruleId)}`, + { body: JSON.stringify(expectedPayload) } + ); + }); + }); + + it('includes evaluation condition when non-empty', async () => { + const { http, result } = setupUseUpdateRule(); + + http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Condition Rule' } }); + + const formData: FormValues = { + ...validFormData, + evaluation: { + query: { + base: 'FROM logs | STATS count() BY host', + condition: 'WHERE count > 100', + }, + }, + }; + + await act(async () => { + result.current.updateRule(formData); + }); + + await waitFor(() => { + const body = getLastPatchedBody(http); + expect(body.evaluation.query).toEqual({ + base: 'FROM logs | STATS count() BY host', + condition: 'WHERE count > 100', + }); + }); + }); + + it('includes recovery_policy with condition-only mode using evaluation base', async () => { + const { http, result } = setupUseUpdateRule(); + + http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Recovery Rule' } }); + + const formData: FormValues = { + ...validFormData, + kind: 'alert', + evaluation: { + query: { + base: 'FROM logs | STATS count() BY host', + condition: 'WHERE count > 100', + }, + }, + recoveryPolicy: { + type: 'query', + query: { condition: 'WHERE count <= 50' }, + }, + }; + + await act(async () => { + result.current.updateRule(formData); + }); + + await waitFor(() => { + const body = getLastPatchedBody(http); + expect(body.recovery_policy).toEqual({ + type: 'query', + query: { + base: 'FROM logs | STATS count() BY host', + condition: 'WHERE count <= 50', + }, + }); + }); + }); + + it('includes state_transition for alert kind', async () => { + const { http, result } = setupUseUpdateRule(); + + http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Alert Rule' } }); + + const formData: FormValues = { + ...validFormData, + kind: 'alert', + stateTransition: { pendingCount: 3, pendingTimeframe: '10m' }, + }; + + await act(async () => { + result.current.updateRule(formData); + }); + + await waitFor(() => { + const body = getLastPatchedBody(http); + expect(body.state_transition).toEqual({ + pending_count: 3, + pending_timeframe: '10m', + }); + }); + }); + + it('nullifies state_transition for signal kind even when stateTransition is provided', async () => { + const { http, result } = setupUseUpdateRule(); + + http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Signal Rule' } }); + + const formData: FormValues = { + ...validFormData, + kind: 'signal', + stateTransition: { pendingCount: 3, pendingTimeframe: '10m' }, + }; + + await act(async () => { + result.current.updateRule(formData); + }); + + await waitFor(() => { + const body = getLastPatchedBody(http); + // signal kind → mapStateTransition returns undefined → coerced to null + expect(body.state_transition).toBeNull(); + }); + }); + + it('shows success toast and calls onSuccess callback on successful update', async () => { + const { http, notifications, onSuccess, result } = setupUseUpdateRule(); + + http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'My Updated Rule' } }); + + await act(async () => { + result.current.updateRule(validFormData); + }); + + await waitFor(() => { + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith( + "Rule 'My Updated Rule' was updated successfully" + ); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it('shows error toast on failure and does not call onSuccess', async () => { + const { http, notifications, onSuccess, result } = setupUseUpdateRule(); + + http.patch.mockRejectedValue({ + body: { message: 'Conflict' }, + message: 'Conflict', + }); + + await act(async () => { + result.current.updateRule(validFormData); + }); + + await waitFor(() => { + expect(notifications.toasts.addDanger).toHaveBeenCalledWith('Error updating rule: Conflict'); + expect(onSuccess).not.toHaveBeenCalled(); + }); + }); + + it('works without an onSuccess callback', async () => { + const http = httpServiceMock.createStartContract(); + const notifications = notificationServiceMock.createStartContract(); + + http.patch.mockResolvedValue({ id: ruleId, metadata: { name: 'Test Rule' } }); + + const { result } = renderHook( + () => + useUpdateRule({ + http, + notifications, + ruleId, + }), + { wrapper: createQueryClientWrapper() } + ); + + await act(async () => { + result.current.updateRule(validFormData); + }); + + await waitFor(() => { + expect(notifications.toasts.addSuccess).toHaveBeenCalled(); + // No onSuccess callback — should not throw + }); + }); +}); 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 new file mode 100644 index 0000000000000..cfa86720864ea --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_update_rule.ts @@ -0,0 +1,43 @@ +/* + * 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 { useMutation } from '@kbn/react-query'; +import type { HttpStart, NotificationsStart } from '@kbn/core/public'; +import type { RuleResponse } from '@kbn/alerting-v2-schemas'; +import type { FormValues } from '../types'; +import { mapFormValuesToUpdateRequest } from '../utils/rule_request_mappers'; + +interface UseUpdateRuleProps { + http: HttpStart; + notifications: NotificationsStart; + ruleId: string; + onSuccess?: () => void; +} + +export const useUpdateRule = ({ http, notifications, ruleId, onSuccess }: UseUpdateRuleProps) => { + const mutation = useMutation( + (formValues: FormValues) => { + return http.patch(`/internal/alerting/v2/rule/${encodeURIComponent(ruleId)}`, { + body: JSON.stringify(mapFormValuesToUpdateRequest(formValues)), + }); + }, + { + 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}`); + }, + } + ); + + return { + ...mutation, + updateRule: mutation.mutate, + }; +}; 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 b5a65058e2f1d..962d05e2c94ae 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 @@ -40,3 +40,12 @@ 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 { + mapFormValuesToRuleRequest, + mapFormValuesToCreateRequest, + mapFormValuesToUpdateRequest, + mapRuleResponseToFormValues, +} from './utils/rule_request_mappers'; +export type { RuleRequestCommon } from './utils/rule_request_mappers'; 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 ac894e6351aa5..9773654d2f451 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,21 @@ import { RuleForm } from './rule_form'; import { RULE_FORM_ID } from './constants'; import { createFormWrapper, createMockServices } from '../test_utils'; +const mockCreateRule = jest.fn(); +const mockUpdateRule = jest.fn(); +jest.mock('./hooks/use_create_rule', () => ({ + useCreateRule: () => ({ + createRule: mockCreateRule, + isLoading: false, + }), +})); +jest.mock('./hooks/use_update_rule', () => ({ + useUpdateRule: () => ({ + updateRule: mockUpdateRule, + isLoading: false, + }), +})); + // Mock GuiRuleForm to avoid rendering complex form fields jest.mock('./gui_rule_form', () => ({ GuiRuleForm: ({ @@ -262,4 +277,59 @@ describe('RuleForm', () => { expect(screen.getByTestId('mockGuiRuleForm')).toBeInTheDocument(); }); }); + + describe('internal submission (includeSubmission)', () => { + it('calls createRule on submit when no ruleId is provided', async () => { + render(, { + wrapper: createFormWrapper(), + }); + + const form = document.getElementById(RULE_FORM_ID); + expect(form).toBeInTheDocument(); + form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + + expect(mockCreateRule).toHaveBeenCalled(); + expect(mockUpdateRule).not.toHaveBeenCalled(); + }); + + it('calls updateRule on submit when ruleId is provided', async () => { + render( + , + { + wrapper: createFormWrapper(), + } + ); + + const form = document.getElementById(RULE_FORM_ID); + expect(form).toBeInTheDocument(); + form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + + expect(mockUpdateRule).toHaveBeenCalled(); + expect(mockCreateRule).not.toHaveBeenCalled(); + }); + + it('prefers external onSubmit over internal hooks', async () => { + const externalOnSubmit = jest.fn(); + + render( + , + { + wrapper: createFormWrapper(), + } + ); + + const form = document.getElementById(RULE_FORM_ID); + expect(form).toBeInTheDocument(); + form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + + expect(externalOnSubmit).toHaveBeenCalled(); + expect(mockCreateRule).not.toHaveBeenCalled(); + expect(mockUpdateRule).not.toHaveBeenCalled(); + }); + }); }); 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 ea8ac1513ae95..323d414ef3d3e 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 @@ -16,6 +16,7 @@ import { RuleFormServicesProvider, useRuleFormServices, type RuleFormServices } import { YamlRuleForm } from './yaml_rule_form'; import { GuiRuleForm } from './gui_rule_form'; import { useCreateRule } from './hooks/use_create_rule'; +import { useUpdateRule } from './hooks/use_update_rule'; import { RULE_FORM_ID } from './constants'; export interface RuleFormProps { @@ -39,6 +40,8 @@ export interface RuleFormProps { includeSubmission?: boolean; submitLabel?: React.ReactNode; cancelLabel?: React.ReactNode; + /** When provided, the form operates in edit mode and uses PATCH instead of POST on submission. */ + ruleId?: string; } interface SubmissionButtonsProps { @@ -97,8 +100,9 @@ const SubmissionButtons: React.FC = ({ * Inner content component that renders the appropriate form based on edit mode. * * When an external `onSubmit` is provided, form submission delegates to it. - * Otherwise, the component uses `useCreateRule` internally to persist the rule - * via the API and calls `onSuccess` after a successful save. + * Otherwise, the component uses `useCreateRule` or `useUpdateRule` internally + * (depending on whether `ruleId` is present) to persist the rule via the API + * and calls `onSuccess` after a successful save. */ const RuleFormContent: React.FC = ({ onSubmit: externalOnSubmit, @@ -111,24 +115,33 @@ const RuleFormContent: React.FC = ({ onCancel, submitLabel, cancelLabel, + ruleId, }) => { const { reset } = useFormContext(); const services = useRuleFormServices(); const { http, notifications } = services; const [editMode, setEditMode] = useState('form'); - // Internal submission via useCreateRule — always initialised so hooks are stable, - // but only used when no external onSubmit is provided. + // Internal submission hooks — always initialised so hooks are stable, + // but only the appropriate one is used when no external onSubmit is provided. const { createRule, isLoading: isCreating } = useCreateRule({ http, notifications, onSuccess, }); + const { updateRule, isLoading: isUpdating } = useUpdateRule({ + http, + notifications, + ruleId: ruleId ?? '', + onSuccess, + }); + // Resolve the effective submit handler: external callback takes precedence, - // otherwise fall back to the internal createRule mutation. - const onSubmit = externalOnSubmit ?? createRule; - const isSubmitting = externalIsSubmitting || isCreating; + // otherwise use updateRule for edits (ruleId present) or createRule for new rules. + const internalSubmit = ruleId ? updateRule : createRule; + const onSubmit = externalOnSubmit ?? internalSubmit; + const isSubmitting = externalIsSubmitting || isCreating || isUpdating; const handleModeChange = useCallback( (newMode: EditMode) => { 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 ed55836b3e6ed..474cef18130d2 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import type { FormValues } from './types'; import { RuleForm } from './rule_form'; @@ -34,6 +34,13 @@ export interface StandaloneRuleFormProps { includeSubmission?: boolean; submitLabel?: React.ReactNode; cancelLabel?: React.ReactNode; + /** + * Optional initial form values to populate the form with (e.g. when editing an existing rule). + * These are shallow-merged over the query-derived defaults. + */ + initialValues?: Partial; + /** When provided, the form operates in edit mode and uses PATCH instead of POST on submission. */ + ruleId?: string; } /** @@ -45,6 +52,7 @@ export interface StandaloneRuleFormProps { * When `onSubmit` is provided, form submission delegates to that callback. * When `onSubmit` is omitted and `includeSubmission` is true, the form * automatically persists the rule via the API and calls `onSuccess` afterwards. + * If `ruleId` is provided the internal submission uses PATCH (update) instead of POST (create). * * Uses react-hook-form's `defaultValues` for static initialization. * Time field is auto-selected by TimeFieldSelect based on available date fields. @@ -61,8 +69,40 @@ export const StandaloneRuleForm: React.FC = ({ onCancel, submitLabel, cancelLabel, + initialValues, + ruleId, }) => { - const defaultValues = useFormDefaults({ query }); + const queryDefaults = useFormDefaults({ query }); + + const defaultValues = useMemo( + () => ({ + ...queryDefaults, + ...initialValues, + metadata: { + ...queryDefaults.metadata, + ...initialValues?.metadata, + }, + schedule: { + ...queryDefaults.schedule, + ...initialValues?.schedule, + }, + evaluation: { + ...queryDefaults.evaluation, + query: { + ...queryDefaults.evaluation.query, + ...initialValues?.evaluation?.query, + }, + }, + ...(initialValues?.grouping !== undefined ? { grouping: initialValues.grouping } : {}), + ...(initialValues?.recoveryPolicy !== undefined + ? { recoveryPolicy: initialValues.recoveryPolicy } + : {}), + ...(initialValues?.stateTransition !== undefined + ? { stateTransition: initialValues.stateTransition } + : {}), + }), + [queryDefaults, initialValues] + ); const methods = useForm({ mode: 'onBlur', @@ -82,6 +122,7 @@ export const StandaloneRuleForm: React.FC = ({ onCancel={onCancel} submitLabel={submitLabel} cancelLabel={cancelLabel} + ruleId={ruleId} /> ); 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 new file mode 100644 index 0000000000000..f8f42f3ed740e --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.test.ts @@ -0,0 +1,519 @@ +/* + * 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 { RuleResponse } from '@kbn/alerting-v2-schemas'; +import type { FormValues } from '../types'; +import { + mapFormValuesToRuleRequest, + mapFormValuesToCreateRequest, + mapFormValuesToUpdateRequest, + mapRuleResponseToFormValues, +} from './rule_request_mappers'; + +describe('rule_request_mappers', () => { + const baseFormValues: FormValues = { + kind: 'signal', + metadata: { + name: 'Test Rule', + enabled: true, + owner: 'test-owner', + labels: ['tag1', 'tag2'], + }, + timeField: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { + query: { + base: 'FROM logs-* | LIMIT 10', + }, + }, + }; + + describe('mapFormValuesToRuleRequest', () => { + it('maps basic form values to the common API shape', () => { + const result = mapFormValuesToRuleRequest(baseFormValues); + + expect(result).toEqual({ + metadata: { name: 'Test Rule', owner: 'test-owner', labels: ['tag1', 'tag2'] }, + time_field: '@timestamp', + schedule: { every: '5m', lookback: '1m' }, + evaluation: { query: { base: 'FROM logs-* | LIMIT 10' } }, + grouping: undefined, + recovery_policy: undefined, + state_transition: undefined, + }); + }); + + it('does not include kind in the common shape', () => { + const result = mapFormValuesToRuleRequest(baseFormValues); + + expect(result).not.toHaveProperty('kind'); + }); + + it('omits empty condition from evaluation query', () => { + const formValues: FormValues = { + ...baseFormValues, + evaluation: { query: { base: 'FROM logs', condition: '' } }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.evaluation.query).toEqual({ base: 'FROM logs' }); + expect(result.evaluation.query).not.toHaveProperty('condition'); + }); + + it('includes non-empty condition in evaluation query', () => { + const formValues: FormValues = { + ...baseFormValues, + evaluation: { + query: { base: 'FROM logs | STATS count() BY host', condition: 'WHERE count > 100' }, + }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.evaluation.query).toEqual({ + base: 'FROM logs | STATS count() BY host', + condition: 'WHERE count > 100', + }); + }); + + it('maps grouping fields when present', () => { + const formValues: FormValues = { + ...baseFormValues, + grouping: { fields: ['host.name', 'service.name'] }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.grouping).toEqual({ fields: ['host.name', 'service.name'] }); + }); + + it('returns undefined grouping when fields array is empty', () => { + const formValues: FormValues = { + ...baseFormValues, + grouping: { fields: [] }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.grouping).toBeUndefined(); + }); + + it('maps recovery_policy type no_breach without query', () => { + const formValues: FormValues = { + ...baseFormValues, + recoveryPolicy: { type: 'no_breach' }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.recovery_policy).toEqual({ type: 'no_breach' }); + expect(result.recovery_policy!.query).toBeUndefined(); + }); + + it('maps recovery_policy type query with condition-only mode', () => { + const formValues: FormValues = { + ...baseFormValues, + evaluation: { + query: { base: 'FROM logs | STATS count() BY host', condition: 'WHERE count > 100' }, + }, + recoveryPolicy: { + type: 'query', + query: { condition: 'WHERE count <= 50' }, + }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.recovery_policy).toEqual({ + type: 'query', + query: { + base: 'FROM logs | STATS count() BY host', + condition: 'WHERE count <= 50', + }, + }); + }); + + it('maps recovery_policy type query with full base query', () => { + const formValues: FormValues = { + ...baseFormValues, + recoveryPolicy: { + type: 'query', + query: { base: 'FROM logs | WHERE status = "ok"' }, + }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.recovery_policy).toEqual({ + type: 'query', + query: { base: 'FROM logs | WHERE status = "ok"' }, + }); + }); + + it('maps recovery_policy type query with explicit recovery base overriding evaluation base', () => { + const formValues: FormValues = { + ...baseFormValues, + evaluation: { query: { base: 'FROM logs | STATS count() BY host' } }, + recoveryPolicy: { + type: 'query', + query: { base: 'FROM other_index', condition: 'WHERE recovered = true' }, + }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.recovery_policy).toEqual({ + type: 'query', + query: { base: 'FROM other_index', condition: 'WHERE recovered = true' }, + }); + }); + + it('maps state_transition for alert kind with pending count and timeframe', () => { + const formValues: FormValues = { + ...baseFormValues, + kind: 'alert', + stateTransition: { pendingCount: 3, pendingTimeframe: '10m' }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.state_transition).toEqual({ pending_count: 3, pending_timeframe: '10m' }); + }); + + it('maps state_transition with only pending count (no timeframe)', () => { + const formValues: FormValues = { + ...baseFormValues, + kind: 'alert', + stateTransition: { pendingCount: 5 }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.state_transition).toEqual({ pending_count: 5 }); + expect(result.state_transition).not.toHaveProperty('pending_timeframe'); + }); + + it('returns undefined state_transition for signal kind even with stateTransition data', () => { + const formValues: FormValues = { + ...baseFormValues, + kind: 'signal', + stateTransition: { pendingCount: 3, pendingTimeframe: '10m' }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.state_transition).toBeUndefined(); + }); + + it('returns undefined state_transition for alert kind when stateTransition is empty', () => { + const formValues: FormValues = { + ...baseFormValues, + kind: 'alert', + stateTransition: {}, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.state_transition).toBeUndefined(); + }); + + it('strips enabled and description from metadata (API does not accept them)', () => { + const formValues: FormValues = { + ...baseFormValues, + metadata: { + name: 'My Rule', + enabled: false, + description: 'A description', + owner: 'owner', + labels: [], + }, + }; + + const result = mapFormValuesToRuleRequest(formValues); + + expect(result.metadata).toEqual({ name: 'My Rule', owner: 'owner', labels: [] }); + expect(result.metadata).not.toHaveProperty('enabled'); + expect(result.metadata).not.toHaveProperty('description'); + }); + }); + + describe('mapFormValuesToCreateRequest', () => { + it('includes kind along with the common request shape', () => { + const result = mapFormValuesToCreateRequest(baseFormValues); + + expect(result.kind).toBe('signal'); + expect(result.metadata).toEqual({ + name: 'Test Rule', + owner: 'test-owner', + labels: ['tag1', 'tag2'], + }); + expect(result.time_field).toBe('@timestamp'); + }); + + it('produces a superset of mapFormValuesToRuleRequest', () => { + const common = mapFormValuesToRuleRequest(baseFormValues); + const create = mapFormValuesToCreateRequest(baseFormValues); + + // Every key in common should be present in create with the same value + for (const key of Object.keys(common) as Array) { + expect(create[key]).toEqual(common[key]); + } + }); + }); + + describe('mapFormValuesToUpdateRequest', () => { + it('coerces undefined optional fields to null for explicit removal', () => { + const result = mapFormValuesToUpdateRequest(baseFormValues); + + expect(result.grouping).toBeNull(); + expect(result.recovery_policy).toBeNull(); + expect(result.state_transition).toBeNull(); + }); + + it('does not include kind in the update payload', () => { + const result = mapFormValuesToUpdateRequest(baseFormValues); + + expect(result).not.toHaveProperty('kind'); + }); + + it('passes through present optional fields without coercion', () => { + const formValues: FormValues = { + ...baseFormValues, + kind: 'alert', + grouping: { fields: ['host.name'] }, + recoveryPolicy: { type: 'no_breach' }, + stateTransition: { pendingCount: 2, pendingTimeframe: '5m' }, + }; + + const result = mapFormValuesToUpdateRequest(formValues); + + expect(result.grouping).toEqual({ fields: ['host.name'] }); + expect(result.recovery_policy).toEqual({ type: 'no_breach' }); + expect(result.state_transition).toEqual({ pending_count: 2, pending_timeframe: '5m' }); + }); + + it('nullifies empty grouping fields instead of leaving as undefined', () => { + const formValues: FormValues = { + ...baseFormValues, + grouping: { fields: [] }, + }; + + const result = mapFormValuesToUpdateRequest(formValues); + + // Empty fields → mapGrouping returns undefined → coerced to null + expect(result.grouping).toBeNull(); + }); + + it('includes required fields from the common mapper', () => { + const result = mapFormValuesToUpdateRequest(baseFormValues); + + expect(result.metadata).toEqual({ + name: 'Test Rule', + owner: 'test-owner', + labels: ['tag1', 'tag2'], + }); + expect(result.time_field).toBe('@timestamp'); + expect(result.schedule).toEqual({ every: '5m', lookback: '1m' }); + expect(result.evaluation).toEqual({ query: { base: 'FROM logs-* | LIMIT 10' } }); + }); + }); + + describe('mapRuleResponseToFormValues', () => { + const baseRuleResponse: RuleResponse = { + id: 'rule-1', + kind: 'alert', + enabled: true, + metadata: { + name: 'Test Rule', + owner: 'test-owner', + labels: ['tag1'], + }, + time_field: '@timestamp', + schedule: { + every: '5m', + lookback: '2m', + }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host', + condition: 'WHERE count > 100', + }, + }, + } as RuleResponse; + + it('maps basic required fields', () => { + const result = mapRuleResponseToFormValues(baseRuleResponse); + + expect(result.kind).toBe('alert'); + expect(result.timeField).toBe('@timestamp'); + expect(result.metadata).toEqual({ + name: 'Test Rule', + enabled: true, + owner: 'test-owner', + labels: ['tag1'], + }); + }); + + it('maps schedule with existing lookback', () => { + const result = mapRuleResponseToFormValues(baseRuleResponse); + + expect(result.schedule).toEqual({ every: '5m', lookback: '2m' }); + }); + + it('defaults lookback to 1m when not present in response', () => { + const rule = { + ...baseRuleResponse, + schedule: { every: '10m' }, + } as RuleResponse; + + const result = mapRuleResponseToFormValues(rule); + + expect(result.schedule).toEqual({ every: '10m', lookback: '1m' }); + }); + + it('maps evaluation query with condition', () => { + const result = mapRuleResponseToFormValues(baseRuleResponse); + + expect(result.evaluation).toEqual({ + query: { + base: 'FROM logs-* | STATS count() BY host', + condition: 'WHERE count > 100', + }, + }); + }); + + it('maps evaluation query without condition', () => { + const rule = { + ...baseRuleResponse, + evaluation: { query: { base: 'FROM logs-* | LIMIT 10' } }, + } as RuleResponse; + + const result = mapRuleResponseToFormValues(rule); + + expect(result.evaluation).toEqual({ + query: { + base: 'FROM logs-* | LIMIT 10', + condition: undefined, + }, + }); + }); + + it('maps grouping when present', () => { + const rule = { + ...baseRuleResponse, + grouping: { fields: ['host.name', 'service.name'] }, + } as RuleResponse; + + const result = mapRuleResponseToFormValues(rule); + + expect(result.grouping).toEqual({ fields: ['host.name', 'service.name'] }); + }); + + it('omits grouping when not present in response', () => { + const result = mapRuleResponseToFormValues(baseRuleResponse); + + expect(result).not.toHaveProperty('grouping'); + }); + + it('maps recovery_policy with query', () => { + const rule = { + ...baseRuleResponse, + recovery_policy: { + type: 'query', + query: { base: 'FROM logs', condition: 'WHERE recovered = true' }, + }, + } as RuleResponse; + + const result = mapRuleResponseToFormValues(rule); + + expect(result.recoveryPolicy).toEqual({ + type: 'query', + query: { base: 'FROM logs', condition: 'WHERE recovered = true' }, + }); + }); + + it('maps recovery_policy without query (no_breach)', () => { + const rule = { + ...baseRuleResponse, + recovery_policy: { type: 'no_breach' }, + } as RuleResponse; + + const result = mapRuleResponseToFormValues(rule); + + expect(result.recoveryPolicy).toEqual({ type: 'no_breach' }); + expect(result.recoveryPolicy!.query).toBeUndefined(); + }); + + it('omits recoveryPolicy when not present in response', () => { + const result = mapRuleResponseToFormValues(baseRuleResponse); + + expect(result).not.toHaveProperty('recoveryPolicy'); + }); + + it('maps state_transition when present', () => { + const rule = { + ...baseRuleResponse, + state_transition: { pending_count: 3, pending_timeframe: '10m' }, + } as RuleResponse; + + const result = mapRuleResponseToFormValues(rule); + + expect(result.stateTransition).toEqual({ + pendingCount: 3, + pendingTimeframe: '10m', + }); + }); + + it('omits stateTransition when not present in response', () => { + const result = mapRuleResponseToFormValues(baseRuleResponse); + + expect(result).not.toHaveProperty('stateTransition'); + }); + + it('roundtrips: create request from mapped response matches original API payload', () => { + const fullRule = { + ...baseRuleResponse, + grouping: { fields: ['host.name'] }, + recovery_policy: { + type: 'query', + query: { base: 'FROM logs-* | STATS count() BY host', condition: 'WHERE count <= 50' }, + }, + state_transition: { pending_count: 3, pending_timeframe: '10m' }, + } as RuleResponse; + + const formValues = mapRuleResponseToFormValues(fullRule); + + // Fill in required fields that mapRuleResponseToFormValues returns + const completeFormValues: FormValues = { + kind: formValues.kind!, + metadata: formValues.metadata!, + timeField: formValues.timeField!, + schedule: formValues.schedule as FormValues['schedule'], + evaluation: formValues.evaluation!, + grouping: formValues.grouping, + recoveryPolicy: formValues.recoveryPolicy, + stateTransition: formValues.stateTransition, + }; + + const createPayload = mapFormValuesToCreateRequest(completeFormValues); + + expect(createPayload.kind).toBe('alert'); + expect(createPayload.evaluation.query.base).toBe('FROM logs-* | STATS count() BY host'); + expect(createPayload.evaluation.query.condition).toBe('WHERE count > 100'); + expect(createPayload.grouping).toEqual({ fields: ['host.name'] }); + expect(createPayload.recovery_policy).toEqual({ + type: 'query', + query: { base: 'FROM logs-* | STATS count() BY host', condition: 'WHERE count <= 50' }, + }); + expect(createPayload.state_transition).toEqual({ + pending_count: 3, + pending_timeframe: '10m', + }); + }); + }); +}); 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 new file mode 100644 index 0000000000000..58b9cf4e669b2 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.ts @@ -0,0 +1,225 @@ +/* + * 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 { + RuleResponse, + RecoveryPolicyType, + CreateRuleData, + UpdateRuleData, +} from '@kbn/alerting-v2-schemas'; +import type { FormValues } from '../types'; + +// --------------------------------------------------------------------------- +// FormValues → API request +// --------------------------------------------------------------------------- + +/** + * Builds the `recovery_policy.query` portion of the API payload. + * + * Two modes: + * 1. **Condition mode** – The user specified an evaluation condition (WHERE clause) + * and wrote a recovery condition. The base query for recovery is the same + * evaluation base query. + * 2. **Full-query mode** – The user wrote a standalone recovery base query + * (no evaluation condition was split out). + */ +const buildRecoveryQuery = ( + recoveryPolicy: NonNullable, + evaluation: FormValues['evaluation'] +): { query: { base: string; condition?: string } } | Record => { + const { query } = recoveryPolicy; + + // Condition-only mode: recovery WHERE clause applied to the evaluation base query + if (query?.condition) { + return { + query: { + base: query.base || evaluation.query.base, + condition: query.condition, + }, + }; + } + + // Full-query mode: user provided a standalone recovery base query + if (query?.base) { + return { query: { base: query.base } }; + } + + return {}; +}; + +const mapMetadata = (metadata: FormValues['metadata']) => ({ + name: metadata.name, + owner: metadata.owner, + labels: metadata.labels, +}); + +const mapSchedule = (schedule: FormValues['schedule']) => ({ + every: schedule.every, + lookback: schedule.lookback, +}); + +const mapEvaluation = (evaluation: FormValues['evaluation']) => ({ + query: { + base: evaluation.query.base, + ...(evaluation.query.condition ? { condition: evaluation.query.condition } : {}), + }, +}); + +const mapGrouping = (grouping: FormValues['grouping']) => + grouping?.fields?.length ? { fields: grouping.fields } : undefined; + +const mapRecoveryPolicy = ( + recoveryPolicy: FormValues['recoveryPolicy'], + evaluation: FormValues['evaluation'] +) => { + if (!recoveryPolicy) return undefined; + return { + type: recoveryPolicy.type, + ...(recoveryPolicy.type === 'query' ? buildRecoveryQuery(recoveryPolicy, evaluation) : {}), + }; +}; + +const mapStateTransition = ( + kind: FormValues['kind'], + stateTransition: FormValues['stateTransition'] +) => { + const hasStateTransition = + kind === 'alert' && + stateTransition != null && + (stateTransition.pendingCount != null || stateTransition.pendingTimeframe != null); + + if (!hasStateTransition) return undefined; + + return { + pending_count: stateTransition!.pendingCount, + ...(stateTransition!.pendingTimeframe != null + ? { pending_timeframe: stateTransition!.pendingTimeframe } + : {}), + }; +}; + +/** + * Common rule request shape shared between create and update payloads. + * Contains all fields except `kind` (only required for create). + */ +export interface RuleRequestCommon { + metadata: { name: string; owner?: string; labels?: string[] }; + time_field: string; + schedule: { every: string; lookback?: string }; + 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 }; +} + +/** + * Maps `FormValues` to the common API request shape (snake_case) shared by + * both create and update endpoints. Does not include `kind`. + */ +export const mapFormValuesToRuleRequest = (formValues: FormValues): RuleRequestCommon => { + const { + metadata, + timeField, + schedule, + evaluation, + grouping, + recoveryPolicy, + stateTransition, + kind, + } = formValues; + + return { + metadata: mapMetadata(metadata), + time_field: timeField, + schedule: mapSchedule(schedule), + evaluation: mapEvaluation(evaluation), + grouping: mapGrouping(grouping), + recovery_policy: mapRecoveryPolicy(recoveryPolicy, evaluation), + state_transition: mapStateTransition(kind, stateTransition), + }; +}; + +/** + * Maps `FormValues` to the create API request payload. + * Adds `kind` on top of the common request shape since it is required for creation. + */ +export const mapFormValuesToCreateRequest = (formValues: FormValues): CreateRuleData => ({ + kind: formValues.kind, + ...mapFormValuesToRuleRequest(formValues), +}); + +/** + * Maps `FormValues` to the update API request payload. + * Coerces absent optional fields to `null` so the API interprets them as + * explicit removals (as opposed to `undefined` which omits the key entirely). + */ +export const mapFormValuesToUpdateRequest = (formValues: FormValues): UpdateRuleData => { + const { grouping, recovery_policy, state_transition, ...rest } = + mapFormValuesToRuleRequest(formValues); + + return { + ...rest, + grouping: grouping ?? null, + recovery_policy: recovery_policy ?? null, + state_transition: state_transition ?? null, + }; +}; + +// --------------------------------------------------------------------------- +// API response → FormValues +// --------------------------------------------------------------------------- + +/** + * Maps a `RuleResponse` (API shape, snake_case) to `Partial` (form shape, camelCase). + * + * Only fields present in the response are included so the form defaults fill in the rest. + * Use this when populating the edit form with an existing rule's data. + */ +export const mapRuleResponseToFormValues = (rule: RuleResponse): Partial => ({ + kind: rule.kind, + metadata: { + name: rule.metadata.name, + enabled: rule.enabled, + owner: rule.metadata.owner, + labels: rule.metadata.labels, + }, + timeField: rule.time_field, + schedule: { + every: rule.schedule.every, + lookback: rule.schedule.lookback ?? '1m', + }, + evaluation: { + query: { + base: rule.evaluation.query.base, + condition: rule.evaluation.query.condition, + }, + }, + ...(rule.grouping ? { grouping: { fields: rule.grouping.fields } } : {}), + ...(rule.recovery_policy + ? { + recoveryPolicy: { + type: rule.recovery_policy.type, + ...(rule.recovery_policy.query + ? { + query: { + base: rule.recovery_policy.query.base, + condition: rule.recovery_policy.query.condition, + }, + } + : {}), + }, + } + : {}), + ...(rule.state_transition + ? { + stateTransition: { + pendingCount: rule.state_transition.pending_count, + pendingTimeframe: rule.state_transition.pending_timeframe, + }, + } + : {}), +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts index af1e417439b5d..cc02c8bb2937b 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts @@ -24,12 +24,21 @@ export { DynamicRuleForm, StandaloneRuleForm } from './form'; // Context - for consumers who need custom integrations export { RuleFormServicesProvider, useRuleFormServices } from './form'; +// Mappers +export { + mapFormValuesToRuleRequest, + mapFormValuesToCreateRequest, + mapFormValuesToUpdateRequest, + mapRuleResponseToFormValues, +} from './form'; + // Types export type { FormValues, DynamicRuleFormProps, StandaloneRuleFormProps, RuleFormServices, + RuleRequestCommon, } from './form'; export type { diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx index 217c624f4a5e8..c69a814da981d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { I18nProvider } from '@kbn/i18n-react'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import { MemoryRouter } from 'react-router-dom'; @@ -18,21 +18,19 @@ jest.mock('../../hooks/use_fetch_rule', () => ({ useFetchRule: (id: string | undefined) => mockUseFetchRule(id), })); -const mockCreateMutate = jest.fn(); -const mockUseCreateRule = jest.fn(); -jest.mock('../../hooks/use_create_rule', () => ({ - useCreateRule: () => mockUseCreateRule(), -})); - -const mockUpdateMutate = jest.fn(); -const mockUseUpdateRule = jest.fn(); -jest.mock('../../hooks/use_update_rule', () => ({ - useUpdateRule: () => mockUseUpdateRule(), -})); - -const mockDataPlugin = { - search: { search: jest.fn() }, -}; +// Mock StandaloneRuleForm to avoid monaco-editor resolution and verify props. +// We keep the real mapRuleResponseToFormValues so the page logic is exercised. +let capturedStandaloneProps: Record = {}; +jest.mock('@kbn/alerting-v2-rule-form', () => { + const actual = jest.requireActual('@kbn/alerting-v2-rule-form'); + return { + ...actual, + StandaloneRuleForm: (props: Record) => { + capturedStandaloneProps = props; + return
; + }, + }; +}); const mockNavigateToUrl = jest.fn(); @@ -45,7 +43,7 @@ jest.mock('@kbn/core-di-browser', () => ({ return { navigateToUrl: mockNavigateToUrl }; } if (token === 'data') { - return mockDataPlugin; + return { search: { search: jest.fn() } }; } return {}; }, @@ -56,29 +54,6 @@ jest.mock('@kbn/core-di', () => ({ PluginStart: (key: string) => key, })); -jest.mock('@kbn/yaml-rule-editor', () => ({ - YamlRuleEditor: ({ - value, - onChange, - dataTestSubj, - }: { - value: string; - onChange: (v: string) => void; - dataTestSubj: string; - }) => ( -