From d411211c29a176ced5ded44646eb1fb4c229fa76 Mon Sep 17 00:00:00 2001 From: Dominique Belcher Date: Mon, 9 Mar 2026 13:22:47 -0400 Subject: [PATCH 1/2] wire up edit flow --- .../form/hooks/query_key_factory.ts | 12 + .../form/hooks/use_create_rule.test.tsx | 4 +- .../form/hooks/use_create_rule.ts | 104 +------- .../form/hooks/use_data_fields.ts | 3 +- .../form/hooks/use_query_columns.ts | 3 +- .../form/hooks/use_update_rule.ts | 43 ++++ .../alerting-v2-rule-form/form/index.tsx | 9 + .../form/rule_form.test.tsx | 70 ++++++ .../alerting-v2-rule-form/form/rule_form.tsx | 27 ++- .../form/standalone_rule_form.tsx | 45 +++- .../form/utils/rule_request_mappers.ts | 225 ++++++++++++++++++ .../alerting-v2-rule-form/index.ts | 9 + .../rule_form_page/rule_form_page.test.tsx | 187 ++++++++++----- .../pages/rule_form_page/rule_form_page.tsx | 17 +- 14 files changed, 583 insertions(+), 175 deletions(-) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/query_key_factory.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/hooks/use_update_rule.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/utils/rule_request_mappers.ts 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.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.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; - }) => ( -