diff --git a/x-pack/plugins/synthetics/e2e/synthetics/journeys/monitor_form_validation.journey.ts b/x-pack/plugins/synthetics/e2e/synthetics/journeys/monitor_form_validation.journey.ts new file mode 100644 index 00000000000000..c34ea3fa7c8cbd --- /dev/null +++ b/x-pack/plugins/synthetics/e2e/synthetics/journeys/monitor_form_validation.journey.ts @@ -0,0 +1,447 @@ +/* + * 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 { expect, journey, Page, step } from '@elastic/synthetics'; +import { FormMonitorType } from '../../../common/runtime_types'; +import { recordVideo } from '../../helpers/record_video'; +import { syntheticsAppPageProvider } from '../page_objects/synthetics_app'; +import { + isEuiFormFieldInValid, + clearAndType, + typeViaKeyboard, + clickAndBlur, + assertShouldNotExist, +} from '../page_objects/utils'; + +const customLocation = process.env.SYNTHETICS_TEST_LOCATION; + +const basicMonitorDetails = { + location: customLocation || 'US Central', + schedule: '3', +}; +const existingMonitorName = 'https://amazon.com'; + +const apmServiceName = 'apmServiceName'; + +type ValidationAssertionAction = + | 'focus' + | 'click' + | 'assertExists' + | 'assertDoesNotExist' + | 'assertEuiFormFieldInValid' + | 'assertEuiFormFieldValid' + | 'clearAndType' + | 'typeViaKeyboard' + | 'clickAndBlur' + | 'waitForTimeout' + | 'selectMonitorFrequency'; + +interface ValidationAssertionInstruction { + action: ValidationAssertionAction; + selector?: string; + arg?: string | number | object; +} + +const configuration: Record< + FormMonitorType, + { monitorType: FormMonitorType; validationInstructions: ValidationAssertionInstruction[] } +> = { + [FormMonitorType.MULTISTEP]: { + monitorType: FormMonitorType.MULTISTEP, + validationInstructions: [ + // Select monitor type + { action: 'click', selector: '[data-test-subj=syntheticsMonitorTypeMultistep]' }, + + // 'required' field assertions + { action: 'focus', selector: '[data-test-subj=syntheticsMonitorConfigName]' }, + { action: 'click', selector: '[data-test-subj=syntheticsMonitorConfigLocations]' }, + { action: 'click', selector: '[data-test-subj=syntheticsMonitorConfigName]' }, + { action: 'assertExists', selector: 'text=Monitor name is required' }, + { + action: 'assertEuiFormFieldInValid', + selector: '[data-test-subj=syntheticsMonitorConfigLocations]', + }, + + // Name duplication assertion + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigName]', + arg: existingMonitorName, + }, + { action: 'assertExists', selector: 'text=Monitor name already exists' }, + + // Mmonitor script + { action: 'click', selector: '[data-test-subj=syntheticsSourceTab__inline]' }, + { action: 'click', selector: '[aria-labelledby=syntheticsBrowserInlineConfig]' }, + { + action: 'typeViaKeyboard', + selector: '[aria-labelledby=syntheticsBrowserInlineConfig]', + arg: '}', + }, + { + action: 'assertExists', + selector: + 'text=Monitor script is invalid. Inline scripts must contain at least one step definition.', + }, + { + action: 'click', + selector: '[data-test-subj=syntheticsMonitorConfigSubmitButton]', + }, + { action: 'assertExists', selector: 'text=Please address the highlighted errors.' }, + ], + }, + [FormMonitorType.SINGLE]: { + monitorType: FormMonitorType.SINGLE, + validationInstructions: [ + // Select monitor type + { action: 'click', selector: '[data-test-subj=syntheticsMonitorTypeSingle]' }, + + // Name duplication assertion + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigName]', + arg: existingMonitorName, + }, + { + action: 'click', + selector: '[data-test-subj=syntheticsMonitorConfigSubmitButton]', + }, + { action: 'assertExists', selector: 'text=Monitor name already exists' }, + { action: 'assertExists', selector: 'text=Please address the highlighted errors.' }, + ], + }, + [FormMonitorType.HTTP]: { + monitorType: FormMonitorType.HTTP, + validationInstructions: [ + // Select monitor type + { action: 'click', selector: '[data-test-subj=syntheticsMonitorTypeHTTP]' }, + + // 'required' field assertions + { action: 'focus', selector: '[data-test-subj=syntheticsMonitorConfigName]' }, + { action: 'focus', selector: '[data-test-subj=syntheticsMonitorConfigURL]' }, + { action: 'click', selector: '[data-test-subj=syntheticsMonitorConfigLocations]' }, + { action: 'click', selector: '[data-test-subj=syntheticsMonitorConfigMaxRedirects]' }, + { action: 'click', selector: '[data-test-subj=syntheticsMonitorConfigName]' }, + { action: 'assertExists', selector: 'text=Monitor name is required' }, + { + action: 'assertEuiFormFieldInValid', + selector: '[data-test-subj=syntheticsMonitorConfigLocations]', + }, + + // Monitor max redirects + { + action: 'assertEuiFormFieldValid', + selector: '[data-test-subj=syntheticsMonitorConfigMaxRedirects]', + }, + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigMaxRedirects]', + arg: '11', + }, + { + action: 'assertEuiFormFieldInValid', + selector: '[data-test-subj=syntheticsMonitorConfigMaxRedirects]', + }, + { action: 'assertExists', selector: 'text=Max redirects is invalid.' }, + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigMaxRedirects]', + arg: '3', + }, + { action: 'clickAndBlur', selector: '[data-test-subj=syntheticsMonitorConfigMaxRedirects]' }, + { action: 'assertDoesNotExist', selector: 'text=Max redirects is invalid.' }, + + // Monitor timeout + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigTimeout]', + arg: '-1', + }, + { action: 'assertExists', selector: 'text=Timeout must be greater than or equal to 0.' }, + { action: 'selectMonitorFrequency', selector: undefined, arg: { value: 1, unit: 'minute' } }, + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigTimeout]', + arg: '61', + }, + { action: 'assertExists', selector: 'text=Timeout must be less than the monitor frequency.' }, + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigTimeout]', + arg: '60', + }, + { + action: 'assertDoesNotExist', + selector: 'text=Timeout must be less than the monitor frequency.', + }, + + // Name duplication assertion + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigName]', + arg: existingMonitorName, + }, + { action: 'assertExists', selector: 'text=Monitor name already exists' }, + + // Advanced Settings + { action: 'click', selector: 'text=Advanced options' }, + { action: 'click', selector: '[data-test-subj=syntheticsHeaderFieldRequestHeaders__button]' }, + { action: 'assertExists', selector: '[data-test-subj=keyValuePairsKey0]' }, + { + action: 'clearAndType', + selector: '[data-test-subj=keyValuePairsKey0]', + arg: 'K e', + }, + { action: 'clickAndBlur', selector: '[data-test-subj=keyValuePairsKey0]' }, + { action: 'assertExists', selector: 'text=Header key must be a valid HTTP token.' }, + { + action: 'clearAndType', + selector: '[data-test-subj=keyValuePairsKey0]', + arg: 'X-Api-Key', + }, + { + action: 'clearAndType', + selector: '[data-test-subj=keyValuePairsValue0]', + arg: 'V a l u e', + }, + { + action: 'assertDoesNotExist', + selector: 'text=Header key must be a valid HTTP token.', + }, + ], + }, + [FormMonitorType.TCP]: { + monitorType: FormMonitorType.TCP, + validationInstructions: [ + // Select monitor type + { action: 'click', selector: '[data-test-subj=syntheticsMonitorTypeTCP]' }, + + // 'required' field assertions + { action: 'focus', selector: '[data-test-subj=syntheticsMonitorConfigName]' }, + { action: 'click', selector: '[data-test-subj=syntheticsMonitorConfigHost]' }, + { action: 'click', selector: '[data-test-subj=syntheticsMonitorConfigLocations]' }, + { action: 'click', selector: '[data-test-subj=syntheticsMonitorConfigName]' }, + + // Enter a duplicate name + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigName]', + arg: existingMonitorName, + }, + { action: 'assertExists', selector: 'text=Monitor name already exists' }, + + // Clear name + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigName]', + arg: '', + }, + { action: 'assertExists', selector: 'text=Monitor name is required' }, + { + action: 'assertEuiFormFieldInValid', + selector: '[data-test-subj=syntheticsMonitorConfigLocations]', + }, + { + action: 'assertEuiFormFieldInValid', + selector: '[data-test-subj=syntheticsMonitorConfigHost]', + }, + + // Monitor timeout + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigTimeout]', + arg: '-1', + }, + { action: 'assertExists', selector: 'text=Timeout must be greater than or equal to 0.' }, + { action: 'selectMonitorFrequency', selector: undefined, arg: { value: 1, unit: 'minute' } }, + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigTimeout]', + arg: '61', + }, + { action: 'assertExists', selector: 'text=Timeout must be less than the monitor frequency.' }, + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigTimeout]', + arg: '60', + }, + { + action: 'assertDoesNotExist', + selector: 'text=Timeout must be less than the monitor frequency.', + }, + + // Submit form + { + action: 'click', + selector: '[data-test-subj=syntheticsMonitorConfigSubmitButton]', + }, + { action: 'assertExists', selector: 'text=Please address the highlighted errors.' }, + ], + }, + [FormMonitorType.ICMP]: { + monitorType: FormMonitorType.ICMP, + validationInstructions: [ + // Select monitor type + { action: 'click', selector: '[data-test-subj=syntheticsMonitorTypeICMP]' }, + + // 'required' field assertions + { action: 'focus', selector: '[data-test-subj=syntheticsMonitorConfigName]' }, + { action: 'focus', selector: '[data-test-subj=syntheticsMonitorConfigHost]' }, + { action: 'click', selector: '[data-test-subj=syntheticsMonitorConfigLocations]' }, + { action: 'click', selector: '[data-test-subj=syntheticsMonitorConfigName]' }, + + // Enter a duplicate name + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigName]', + arg: existingMonitorName, + }, + { action: 'assertExists', selector: 'text=Monitor name already exists' }, + + // Clear name + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigName]', + arg: '', + }, + { action: 'assertExists', selector: 'text=Monitor name is required' }, + { + action: 'assertEuiFormFieldInValid', + selector: '[data-test-subj=syntheticsMonitorConfigLocations]', + }, + { + action: 'assertEuiFormFieldInValid', + selector: '[data-test-subj=syntheticsMonitorConfigHost]', + }, + + // Monitor timeout + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigTimeout]', + arg: '-1', + }, + { action: 'assertExists', selector: 'text=Timeout must be greater than or equal to 0.' }, + { action: 'selectMonitorFrequency', selector: undefined, arg: { value: 1, unit: 'minute' } }, + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigTimeout]', + arg: '61', + }, + { action: 'assertExists', selector: 'text=Timeout must be less than the monitor frequency.' }, + { + action: 'clearAndType', + selector: '[data-test-subj=syntheticsMonitorConfigTimeout]', + arg: '60', + }, + { + action: 'assertDoesNotExist', + selector: 'text=Timeout must be less than the monitor frequency.', + }, + + // Submit form + { + action: 'click', + selector: '[data-test-subj=syntheticsMonitorConfigSubmitButton]', + }, + { action: 'assertExists', selector: 'text=Please address the highlighted errors.' }, + ], + }, +}; + +const exitingMonitorConfig = { + ...basicMonitorDetails, + name: existingMonitorName, + url: existingMonitorName, + locations: [basicMonitorDetails.location], + apmServiceName, +}; + +journey( + `SyntheticsAddMonitor - Validation Test`, + async ({ page, params }: { page: Page; params: any }) => { + page.setDefaultTimeout(60 * 1000); + recordVideo(page); + + const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl }); + + step('Go to monitor management', async () => { + await syntheticsApp.navigateToMonitorManagement(true); + await syntheticsApp.enableMonitorManagement(); + }); + + step('Ensure all monitors are deleted', async () => { + await syntheticsApp.waitForLoadingToFinish(); + const isSuccessful = await syntheticsApp.deleteMonitors(); + expect(isSuccessful).toBeTruthy(); + }); + + step('Create a monitor to validate duplicate name', async () => { + await syntheticsApp.navigateToAddMonitor(); + await syntheticsApp.ensureIsOnMonitorConfigPage(); + await syntheticsApp.createMonitor({ + monitorConfig: exitingMonitorConfig, + monitorType: FormMonitorType.HTTP, + }); + const isSuccessful = await syntheticsApp.confirmAndSave(); + expect(isSuccessful).toBeTruthy(); + }); + + step(`Goto Add Monitor page`, async () => { + await syntheticsApp.navigateToAddMonitor(); + await syntheticsApp.ensureIsOnMonitorConfigPage(); + }); + + Object.values(configuration).forEach((config) => { + const { monitorType, validationInstructions } = config; + + step(`Test form validation for monitor type [${monitorType}]`, async () => { + for (const instruction of validationInstructions) { + const { action, selector, arg } = instruction; + const locator = page.locator(selector ?? ''); + switch (action) { + case 'focus': + await locator.focus(); + break; + case 'click': + await locator.click(); + break; + case 'assertExists': + await locator.waitFor(); + break; + case 'assertDoesNotExist': + await assertShouldNotExist(locator); + break; + case 'assertEuiFormFieldInValid': + expect(await isEuiFormFieldInValid(locator)).toEqual(true); + break; + case 'assertEuiFormFieldValid': + expect(await isEuiFormFieldInValid(locator)).toEqual(false); + break; + case 'clearAndType': + await clearAndType(locator, arg as string); + break; + case 'typeViaKeyboard': + await typeViaKeyboard(locator, arg as string); + break; + case 'clickAndBlur': + await clickAndBlur(locator); + break; + case 'waitForTimeout': + await page.waitForTimeout(arg as number); + break; + case 'selectMonitorFrequency': + await syntheticsApp.selectFrequencyAddEdit(arg as { value: number; unit: 'minute' }); + break; + default: + throw Error( + `Assertion Instruction ${JSON.stringify(instruction)} is not recognizable` + ); + } + } + }); + }); + } +); diff --git a/x-pack/plugins/synthetics/e2e/synthetics/page_objects/synthetics_app.tsx b/x-pack/plugins/synthetics/e2e/synthetics/page_objects/synthetics_app.tsx index ebcd900c4c2107..4e16479d104343 100644 --- a/x-pack/plugins/synthetics/e2e/synthetics/page_objects/synthetics_app.tsx +++ b/x-pack/plugins/synthetics/e2e/synthetics/page_objects/synthetics_app.tsx @@ -167,6 +167,23 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib } }, + async selectFrequencyAddEdit({ + value, + unit, + }: { + value: number; + unit: 'minute' | 'minutes' | 'hours'; + }) { + await page.click(this.byTestId('syntheticsMonitorConfigSchedule')); + + const optionLocator = page.locator(`text=Every ${value} ${unit}`); + await optionLocator.evaluate((element: HTMLOptionElement) => { + if (element && element.parentElement) { + (element.parentElement as HTMLSelectElement).selectedIndex = element.index; + } + }); + }, + async fillFirstMonitorDetails({ url, locations }: { url: string; locations: string[] }) { await this.fillByTestSubj('urls-input', url); await page.click(this.byTestId('comboBoxInput')); diff --git a/x-pack/plugins/synthetics/e2e/synthetics/page_objects/utils.ts b/x-pack/plugins/synthetics/e2e/synthetics/page_objects/utils.ts new file mode 100644 index 00000000000000..af49c82d3e1053 --- /dev/null +++ b/x-pack/plugins/synthetics/e2e/synthetics/page_objects/utils.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 { expect, Page } from '@elastic/synthetics'; + +export async function isEuiFormFieldInValid(locator: ReturnType) { + const elementHandle = await locator.elementHandle(); + expect(elementHandle).toBeTruthy(); + + const classAttribute = (await elementHandle!.asElement().getAttribute('class')) ?? ''; + const isAriaInvalid = (await elementHandle!.asElement().getAttribute('aria-invalid')) ?? 'false'; + + return classAttribute.indexOf('-isInvalid') > -1 || isAriaInvalid === 'true'; +} + +export async function clearAndType(locator: ReturnType, value: string) { + await locator.fill(''); + await locator.type(value); +} + +export async function typeViaKeyboard(locator: ReturnType, value: string) { + await locator.click(); + await locator.page().keyboard.type(value); +} + +export async function blur(locator: ReturnType) { + await locator.evaluate((e) => { + e.blur(); + }); +} + +export async function clickAndBlur(locator: ReturnType) { + await locator.click(); + await blur(locator); +} + +export async function assertShouldNotExist(locator: ReturnType) { + await locator.waitFor({ state: 'detached' }); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/monitor_inspect.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/monitor_inspect.tsx index a9b28d21af8f1a..32b930ddfee2f7 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/monitor_inspect.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/monitor_inspect.tsx @@ -6,7 +6,6 @@ */ import React, { useState } from 'react'; -import { useFormContext } from 'react-hook-form'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { enableInspectEsQueries } from '@kbn/observability-plugin/common'; import { useFetcher } from '@kbn/observability-shared-plugin/public'; @@ -28,10 +27,14 @@ import { import { ClientPluginsStart } from '../../../../../plugin'; import { useSyntheticsSettingsContext } from '../../../contexts'; import { LoadingState } from '../../monitors_page/overview/overview/monitor_detail_flyout'; -import { DataStream, SyntheticsMonitor } from '../../../../../../common/runtime_types'; +import { DataStream, MonitorFields } from '../../../../../../common/runtime_types'; import { inspectMonitorAPI, MonitorInspectResponse } from '../../../state/monitor_management/api'; -export const MonitorInspectWrapper = () => { +interface InspectorProps { + isValid: boolean; + monitorFields: MonitorFields; +} +export const MonitorInspectWrapper = (props: InspectorProps) => { const { services: { uiSettings }, } = useKibana(); @@ -40,10 +43,10 @@ export const MonitorInspectWrapper = () => { const isInspectorEnabled = uiSettings?.get(enableInspectEsQueries); - return isDev || isInspectorEnabled ? : null; + return isDev || isInspectorEnabled ? : null; }; -const MonitorInspect = () => { +const MonitorInspect = ({ isValid, monitorFields }: InspectorProps) => { const { isDev } = useSyntheticsSettingsContext(); const [hideParams, setHideParams] = useState(() => !isDev); @@ -60,13 +63,11 @@ const MonitorInspect = () => { setIsFlyoutVisible(() => !isFlyoutVisible); }; - const { getValues, formState } = useFormContext(); - const { data, loading, error } = useFetcher(() => { if (isInspecting) { return inspectMonitorAPI({ hideParams, - monitor: getValues() as SyntheticsMonitor, + monitor: monitorFields, }); } }, [isInspecting, hideParams]); @@ -121,9 +122,9 @@ const MonitorInspect = () => { } return ( <> - + ; + onChange: (locations: ServiceLocation[]) => void; }) => { const { locations } = useSelector(selectServiceLocationsState); + const fieldState = control.getFieldState(ConfigKey.LOCATIONS); + const showError = fieldState.isTouched || control._formState.isSubmitted; + return ( { + return value?.length > 0 ? true : SELECT_ONE_OR_MORE_LOCATIONS; + }, + }, + }} render={({ field }) => ( - ({ @@ -51,10 +60,13 @@ export const ServiceLocationsField = ({ isClearable={true} data-test-subj="syntheticsServiceLocations" {...field} - onChange={(selectedOptions) => - field.onChange(selectedOptions.map((loc) => formatLocation(loc as ServiceLocation))) - } - isInvalid={!!errors?.[ConfigKey.LOCATIONS]} + onChange={async (selectedOptions) => { + const updatedLocations = selectedOptions.map((loc) => + formatLocation(loc as ServiceLocation) + ); + field.onChange(updatedLocations); + onChange(updatedLocations as ServiceLocation[]); + }} /> )} /> @@ -62,6 +74,23 @@ export const ServiceLocationsField = ({ ); }; +const ComboBoxWithRef = React.forwardRef>( + (props, ref) => ( + { + if (ref) { + if (typeof ref === 'function') { + ref(element); + } else { + ref.current = element; + } + } + }} + /> + ) +); + const SELECT_ONE_OR_MORE_LOCATIONS = i18n.translate( 'xpack.synthetics.monitorManagement.selectOneOrMoreLocations', { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/getting_started/simple_monitor_form.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/getting_started/simple_monitor_form.tsx index 383e0e01c18f2a..5aa77cf5f42c78 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/getting_started/simple_monitor_form.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/getting_started/simple_monitor_form.tsx @@ -18,7 +18,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { useSimpleMonitor } from './use_simple_monitor'; import { ServiceLocationsField } from './form_fields/service_locations'; -import { ConfigKey, ServiceLocations } from '../../../../../common/runtime_types'; +import { ConfigKey, ServiceLocation, ServiceLocations } from '../../../../../common/runtime_types'; import { useCanEditSynthetics } from '../../../../hooks/use_capabilities'; import { useFormWrapped } from '../../../../hooks/use_form_wrapped'; import { NoPermissionsTooltip } from '../common/components/permissions'; @@ -33,10 +33,12 @@ export const SimpleMonitorForm = () => { control, register, handleSubmit, - formState: { errors, isValid, isSubmitted }, + formState: { isValid, isSubmitted }, + getFieldState, + trigger, } = useFormWrapped({ mode: 'onSubmit', - reValidateMode: 'onChange', + reValidateMode: 'onSubmit', shouldFocusError: true, defaultValues: { urls: '', locations: [] as ServiceLocations }, }); @@ -51,7 +53,8 @@ export const SimpleMonitorForm = () => { const canEditSynthetics = useCanEditSynthetics(); - const hasURLError = !!errors?.[ConfigKey.URLS]; + const urlFieldState = getFieldState(ConfigKey.URLS); + const urlError = isSubmitted || urlFieldState.isTouched ? urlFieldState.error : undefined; return ( { (!Boolean(value.trim()) ? URL_REQUIRED_LABEL : true), + }, + })} + isInvalid={!!urlError} data-test-subj={`${ConfigKey.URLS}-input`} tabIndex={0} /> - + { + await trigger?.(); + }} + /> diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/advanced/index.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/advanced/index.tsx index 4b69da4ad70c3c..fa44b4ddbdc5f7 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/advanced/index.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/advanced/index.tsx @@ -8,17 +8,14 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiAccordion, EuiDescribedFormGroup, EuiPanel, EuiSpacer } from '@elastic/eui'; -import { useFormContext, FieldError } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; import styled from 'styled-components'; import { FORM_CONFIG } from '../form/form_config'; import { Field } from '../form/field'; import { ConfigKey, FormMonitorType } from '../types'; export const AdvancedConfig = ({ readOnly }: { readOnly: boolean }) => { - const { - watch, - formState: { errors }, - } = useFormContext(); + const { watch } = useFormContext(); const [type]: [FormMonitorType] = watch([ConfigKey.FORM_MONITOR_TYPE]); const formConfig = useMemo(() => { @@ -36,7 +33,7 @@ export const AdvancedConfig = ({ readOnly }: { readOnly: boolean }) => { {formConfig.advanced?.map((configGroup) => { return ( - {configGroup.title}} fullWidth @@ -46,15 +43,9 @@ export const AdvancedConfig = ({ readOnly }: { readOnly: boolean }) => { style={{ flexWrap: 'wrap' }} > {configGroup.components.map((field) => { - return ( - - ); + return ; })} - + ); })} @@ -62,7 +53,7 @@ export const AdvancedConfig = ({ readOnly }: { readOnly: boolean }) => { ) : null; }; -const DescripedFormGroup = styled(EuiDescribedFormGroup)` +const DescribedFormGroup = styled(EuiDescribedFormGroup)` > div.euiFlexGroup { flex-wrap: wrap; } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/index_response_body_field.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/index_response_body_field.tsx index dc6da249aab2de..f708280f4de4b9 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/index_response_body_field.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/index_response_body_field.tsx @@ -25,15 +25,15 @@ export const ResponseBodyIndexField = ({ onBlur, readOnly, }: ResponseBodyIndexFieldProps) => { - const [policy, setPolicy] = useState( - defaultValue !== ResponseBodyIndexPolicy.NEVER ? defaultValue : ResponseBodyIndexPolicy.ON_ERROR - ); + const [policy, setPolicy] = useState(defaultValue); const [checked, setChecked] = useState(defaultValue !== ResponseBodyIndexPolicy.NEVER); useEffect(() => { if (checked) { - setPolicy(policy); - onChange(policy); + const defaultOrSelected = + policy === ResponseBodyIndexPolicy.NEVER ? ResponseBodyIndexPolicy.ON_ERROR : policy; + setPolicy(defaultOrSelected); + onChange(defaultOrSelected); } else { onChange(ResponseBodyIndexPolicy.NEVER); } @@ -89,20 +89,20 @@ export const ResponseBodyIndexField = ({ const responseBodyIndexPolicyOptions = [ { - value: ResponseBodyIndexPolicy.ALWAYS, + value: ResponseBodyIndexPolicy.ON_ERROR, text: i18n.translate( - 'xpack.synthetics.createPackagePolicy.stepConfigure.responseBodyIndex.always', + 'xpack.synthetics.createPackagePolicy.stepConfigure.responseBodyIndex.onError', { - defaultMessage: 'Always', + defaultMessage: 'On error', } ), }, { - value: ResponseBodyIndexPolicy.ON_ERROR, + value: ResponseBodyIndexPolicy.ALWAYS, text: i18n.translate( - 'xpack.synthetics.createPackagePolicy.stepConfigure.responseBodyIndex.onError', + 'xpack.synthetics.createPackagePolicy.stepConfigure.responseBodyIndex.always', { - defaultMessage: 'On error', + defaultMessage: 'Always', } ), }, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/controlled_field.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/controlled_field.tsx index 5eafb3646b3323..cc37a530087c46 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/controlled_field.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/controlled_field.tsx @@ -4,15 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useEffect } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiFormRow, EuiFormRowProps } from '@elastic/eui'; import { useSelector } from 'react-redux'; -import { - UseFormReturn, - ControllerRenderProps, - ControllerFieldState, - useFormContext, -} from 'react-hook-form'; +import { useDebounce } from 'react-use'; +import { ControllerRenderProps, ControllerFieldState, useFormContext } from 'react-hook-form'; import { useKibanaSpace, useIsEditFlow } from '../hooks'; import { selectServiceLocationsState } from '../../../state'; import { FieldMeta, FormConfig } from '../types'; @@ -25,43 +21,60 @@ type Props = FieldMeta & { error: React.ReactNode; dependenciesValues: unknown[]; dependenciesFieldMeta: Record; + isInvalid: boolean; }; -const setFieldValue = - (key: keyof FormConfig, setValue: UseFormReturn['setValue']) => (value: any) => { - setValue(key, value); - }; - export const ControlledField = ({ component: FieldComponent, props, fieldKey, - shouldUseSetValue, field, formRowProps, - fieldState, - customHook, error, dependenciesValues, dependenciesFieldMeta, + isInvalid, }: Props) => { - const { setValue, reset, formState, setError, clearErrors } = useFormContext(); - const noop = () => {}; - let hook: Function = noop; - let hookProps; + const { setValue, getFieldState, reset, formState, trigger } = useFormContext(); + const { locations } = useSelector(selectServiceLocationsState); const { space } = useKibanaSpace(); const isEdit = useIsEditFlow(); - if (customHook) { - hookProps = customHook(field.value); - hook = hookProps.func; - } - const { [hookProps?.fieldKey as string]: hookResult } = hook(hookProps?.params) || {}; - const onChange = shouldUseSetValue ? setFieldValue(fieldKey, setValue) : field.onChange; + + const [onChangeArgs, setOnChangeArgs] = useState | undefined>( + undefined + ); + + useDebounce( + async () => { + if (onChangeArgs !== undefined) { + await trigger?.(); // Manually invalidate whole form to make dependency validations reactive + } + }, + 500, + [onChangeArgs] + ); + + const handleChange = useCallback( + async (...event: any[]) => { + if (typeof event?.[0] === 'string' && !getFieldState(fieldKey).isTouched) { + // This is needed for composite fields like code editors + setValue(fieldKey, event[0], { shouldTouch: true }); + } + + field.onChange(...event); + setOnChangeArgs(event); + }, + // Do not depend on `field` + // eslint-disable-next-line react-hooks/exhaustive-deps + [setOnChangeArgs] + ); + const generatedProps = props ? props({ field, setValue, + trigger, reset, locations: locations.map((location) => ({ ...location, key: location.id })), dependencies: dependenciesValues, @@ -71,33 +84,14 @@ export const ControlledField = ({ formState, }) : {}; - const isInvalid = hookResult || Boolean(fieldState.error); - const hookErrorContent = hookProps?.error; - const hookError = hookResult ? hookProps?.error : undefined; - - useEffect(() => { - if (!customHook) { - return; - } - - if (hookResult && !fieldState.error) { - setError(fieldKey, { type: 'custom', message: hookErrorContent as string }); - } else if (!hookResult && fieldState.error?.message === hookErrorContent) { - clearErrors(fieldKey); - } - }, [setError, fieldKey, clearErrors, fieldState, customHook, hookResult, hookErrorContent]); return ( - + ( props, fieldKey, controlled, - shouldUseSetValue, required, validation, - error, + error: validationError, fieldError, dependencies, customHook, hidden, }: Props) => { - const { register, watch, control, setValue, reset, getFieldState, formState } = - useFormContext(); + const { register, control, setValue, reset, formState, trigger } = useFormContext(); const { locations } = useSelector(selectServiceLocationsState); const { space } = useKibanaSpace(); const isEdit = useIsEditFlow(); - const [dependenciesFieldMeta, setDependenciesFieldMeta] = useState< - Record - >({}); - let dependenciesValues: unknown[] = []; - if (dependencies) { - dependenciesValues = watch(dependencies); - } - useEffect(() => { - if (dependencies) { - dependencies.forEach((dependency) => { - setDependenciesFieldMeta((prevState) => ({ - ...prevState, - [dependency]: getFieldState(dependency), - })); - }); + + const { dependenciesValues, dependenciesFieldMeta, error, isInvalid, rules } = useValidateField( + { + fieldKey, + validation, + dependencies, + required: required ?? false, + customHook, + validationError, } - // run effect when dependencies values change, to get the most up to date meta state - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(dependenciesValues || []), dependencies, getFieldState]); + ); if (hidden && hidden(dependenciesValues)) { return null; @@ -73,22 +63,18 @@ export const Field = memo( control={control} name={fieldKey} - rules={{ - required, - ...(validation ? validation(dependenciesValues) : {}), - }} + rules={rules} render={({ field, fieldState: fieldStateT }) => { return ( @@ -102,15 +88,13 @@ export const Field = memo( error={fieldError?.message || error} > ({ ...location, key: location.id })), dependencies: dependenciesValues, @@ -119,7 +103,7 @@ export const Field = memo( isEdit, }) : {})} - isInvalid={Boolean(fieldError)} + isInvalid={isInvalid} fullWidth /> diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx index 133f509c4b0ec5..67c49f6030208e 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx @@ -242,18 +242,17 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }), controlled: true, dependencies: [ConfigKey.NAME], - props: ({ setValue, dependenciesFieldMeta, isEdit, formState }): EuiFieldTextProps => { + props: ({ setValue, dependenciesFieldMeta, isEdit, trigger }): EuiFieldTextProps => { return { 'data-test-subj': 'syntheticsMonitorConfigURL', - onChange: (event: React.ChangeEvent) => { - setValue(ConfigKey.URLS, event.target.value, { - shouldValidate: Boolean(formState.submitCount > 0), - }); + onChange: async (event: React.ChangeEvent) => { + setValue(ConfigKey.URLS, event.target.value, { shouldTouch: true }); if (!dependenciesFieldMeta[ConfigKey.NAME].isDirty && !isEdit) { setValue(ConfigKey.NAME, event.target.value, { - shouldValidate: Boolean(formState.submitCount > 0), + shouldTouch: true, }); } + await trigger(); }, readOnly, }; @@ -271,16 +270,15 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }), controlled: true, dependencies: [ConfigKey.NAME], - props: ({ setValue, dependenciesFieldMeta, isEdit, formState }): EuiFieldTextProps => { + props: ({ setValue, trigger, dependenciesFieldMeta, isEdit }): EuiFieldTextProps => { return { - onChange: (event: React.ChangeEvent) => { - setValue(ConfigKey.URLS, event.target.value, { - shouldValidate: Boolean(formState.submitCount > 0), - }); + onChange: async (event: React.ChangeEvent) => { + setValue(ConfigKey.URLS, event.target.value, { shouldTouch: true }); if (!dependenciesFieldMeta[ConfigKey.NAME].isDirty && !isEdit) { setValue(ConfigKey.NAME, event.target.value, { - shouldValidate: Boolean(formState.submitCount > 0), + shouldTouch: true, }); + await trigger(); } }, 'data-test-subj': 'syntheticsMonitorConfigURL', @@ -297,17 +295,14 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }), controlled: true, dependencies: [ConfigKey.NAME], - props: ({ setValue, dependenciesFieldMeta, isEdit, formState }): EuiFieldTextProps => { + props: ({ setValue, trigger, dependenciesFieldMeta, isEdit }): EuiFieldTextProps => { return { - onChange: (event: React.ChangeEvent) => { - setValue(ConfigKey.HOSTS, event.target.value, { - shouldValidate: Boolean(formState.submitCount > 0), - }); + onChange: async (event: React.ChangeEvent) => { + setValue(ConfigKey.HOSTS, event.target.value, { shouldTouch: true }); if (!dependenciesFieldMeta[ConfigKey.NAME].isDirty && !isEdit) { - setValue(ConfigKey.NAME, event.target.value, { - shouldValidate: Boolean(formState.submitCount > 0), - }); + setValue(ConfigKey.NAME, event.target.value, { shouldTouch: true }); } + await trigger(); }, 'data-test-subj': 'syntheticsMonitorConfigHost', readOnly, @@ -323,17 +318,14 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }), controlled: true, dependencies: [ConfigKey.NAME], - props: ({ setValue, dependenciesFieldMeta, isEdit, formState }): EuiFieldTextProps => { + props: ({ setValue, trigger, dependenciesFieldMeta, isEdit }): EuiFieldTextProps => { return { - onChange: (event: React.ChangeEvent) => { - setValue(ConfigKey.HOSTS, event.target.value, { - shouldValidate: Boolean(formState.submitCount > 0), - }); + onChange: async (event: React.ChangeEvent) => { + setValue(ConfigKey.HOSTS, event.target.value, { shouldTouch: true }); if (!dependenciesFieldMeta[ConfigKey.NAME].isDirty && !isEdit) { - setValue(ConfigKey.NAME, event.target.value, { - shouldValidate: Boolean(formState.submitCount > 0), - }); + setValue(ConfigKey.NAME, event.target.value, { shouldTouch: true }); } + await trigger(); }, 'data-test-subj': 'syntheticsMonitorConfigHost', readOnly, @@ -362,7 +354,12 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }), validation: () => ({ validate: { - notEmpty: (value) => Boolean(value.trim()), + notEmpty: (value) => + !Boolean(value.trim()) + ? i18n.translate('xpack.synthetics.monitorConfig.name.error', { + defaultMessage: 'Monitor name is required', + }) + : true, }, }), error: i18n.translate('xpack.synthetics.monitorConfig.name.error', { @@ -404,7 +401,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ defaultMessage: 'Where do you want to run this test from? Additional locations will increase your total cost.', }), - props: ({ field, setValue, locations, formState }) => { + props: ({ field, setValue, locations, trigger }) => { return { options: Object.values(locations).map((location) => ({ label: locations?.find((loc) => location.id === loc.id)?.label || '', @@ -425,15 +422,14 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ isServiceManaged: location.isServiceManaged || false, })), 'data-test-subj': 'syntheticsMonitorConfigLocations', - onChange: (updatedValues: FormLocation[]) => { + onChange: async (updatedValues: FormLocation[]) => { const valuesToSave = updatedValues.map(({ id, label, isServiceManaged }) => ({ id, label, isServiceManaged, })); - setValue(ConfigKey.LOCATIONS, valuesToSave, { - shouldValidate: Boolean(formState.submitCount > 0), - }); + setValue(ConfigKey.LOCATIONS, valuesToSave); + await trigger(ConfigKey.LOCATIONS); }, isDisabled: readOnly, renderOption: (option: FormLocation, searchValue: string) => { @@ -487,14 +483,15 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ helpText: i18n.translate('xpack.synthetics.monitorConfig.edit.enabled.label', { defaultMessage: `When disabled, the monitor doesn't run any tests. You can enable it at any time.`, }), - props: ({ setValue, field }): EuiSwitchProps => ({ + props: ({ setValue, field, trigger }): EuiSwitchProps => ({ id: 'syntheticsMontiorConfigIsEnabled', label: i18n.translate('xpack.synthetics.monitorConfig.enabled.label', { defaultMessage: 'Enable Monitor', }), checked: field?.value || false, - onChange: (event) => { + onChange: async (event) => { setValue(ConfigKey.ENABLED, !!event.target.checked); + await trigger(ConfigKey.ENABLED); }, 'data-test-subj': 'syntheticsEnableSwitch', // enabled is an allowed field for read only @@ -505,7 +502,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: AlertConfigKey.STATUS_ENABLED, component: Switch, controlled: true, - props: ({ setValue, field }): EuiSwitchProps => ({ + props: ({ setValue, field, trigger }): EuiSwitchProps => ({ id: 'syntheticsMonitorConfigIsAlertEnabled', label: field?.value ? i18n.translate('xpack.synthetics.monitorConfig.enabledAlerting.label', { @@ -515,8 +512,9 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ defaultMessage: 'Enable status alerts on this monitor', }), checked: field?.value || false, - onChange: (event) => { + onChange: async (event) => { setValue(AlertConfigKey.STATUS_ENABLED, !!event.target.checked); + await trigger(AlertConfigKey.STATUS_ENABLED); }, 'data-test-subj': 'syntheticsAlertStatusSwitch', // alert config is an allowed field for read only @@ -527,7 +525,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: AlertConfigKey.TLS_ENABLED, component: Switch, controlled: true, - props: ({ setValue, field }): EuiSwitchProps => ({ + props: ({ setValue, field, trigger }): EuiSwitchProps => ({ id: 'syntheticsMonitorConfigIsTlsAlertEnabled', label: field?.value ? i18n.translate('xpack.synthetics.monitorConfig.edit.alertTlsEnabled.label', { @@ -537,8 +535,9 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ defaultMessage: 'Enable TLS alerts on this monitor.', }), checked: field?.value || false, - onChange: (event) => { + onChange: async (event) => { setValue(AlertConfigKey.TLS_ENABLED, !!event.target.checked); + await trigger(AlertConfigKey.TLS_ENABLED); }, 'data-test-subj': 'syntheticsAlertStatusSwitch', // alert config is an allowed field for read only @@ -573,14 +572,15 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ defaultMessage: 'The total time allowed for testing the connection and exchanging data.', }), props: (): EuiFieldNumberProps => ({ + 'data-test-subj': 'syntheticsMonitorConfigTimeout', min: 1, step: 'any', readOnly, }), dependencies: [ConfigKey.SCHEDULE], - validation: ([schedule]) => { - return { - validate: (value) => { + validation: ([schedule]) => ({ + validate: { + validTimeout: (value) => { switch (true) { case value < 0: return i18n.translate('xpack.synthetics.monitorConfig.timeout.greaterThan0Error', { @@ -588,7 +588,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }); case value > parseFloat((schedule as MonitorFields[ConfigKey.SCHEDULE]).number) * 60: return i18n.translate('xpack.synthetics.monitorConfig.timeout.scheduleError', { - defaultMessage: 'Timemout must be less than the monitor frequency.', + defaultMessage: 'Timeout must be less than the monitor frequency.', }); case !Boolean(`${value}`.match(FLOATS_ONLY)): return i18n.translate('xpack.synthetics.monitorConfig.timeout.formatError', { @@ -598,8 +598,8 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ return true; } }, - }; - }, + }, + }), }, [ConfigKey.APM_SERVICE_NAME]: { fieldKey: ConfigKey.APM_SERVICE_NAME, @@ -645,7 +645,9 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ readOnly, }), validation: () => ({ - validate: (namespace) => isValidNamespace(namespace).error, + validate: { + validNamespace: (namespace) => isValidNamespace(namespace).error, + }, }), }, [ConfigKey.MAX_REDIRECTS]: { @@ -658,6 +660,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ defaultMessage: 'The total number of redirects to follow.', }), props: (): EuiFieldNumberProps => ({ + 'data-test-subj': 'syntheticsMonitorConfigMaxRedirects', min: 0, max: 10, step: 1, @@ -665,6 +668,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }), validation: () => ({ min: 0, + max: 10, pattern: WHOLE_NUMBERS_ONLY, }), error: i18n.translate('xpack.synthetics.monitorConfig.maxRedirects.error', { @@ -682,6 +686,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ 'The duration to wait before emitting another ICMP Echo Request if no response is received.', }), props: (): EuiFieldNumberProps => ({ + 'data-test-subj': 'syntheticsMonitorConfigWait', min: 1, step: 1, readOnly, @@ -762,18 +767,26 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }), controlled: true, validation: () => ({ - validate: (headers) => !validateHeaders(headers), + validate: { + validHeaders: (headers) => + validateHeaders(headers) + ? i18n.translate('xpack.synthetics.monitorConfig.requestHeaders.error', { + defaultMessage: 'Header key must be a valid HTTP token.', + }) + : true, + }, }), dependencies: [ConfigKey.REQUEST_BODY_CHECK], error: i18n.translate('xpack.synthetics.monitorConfig.requestHeaders.error', { defaultMessage: 'Header key must be a valid HTTP token.', }), - // contentMode is optional for other implementations, but required for this implemention of this field + // contentMode is optional for other implementations, but required for this implementation of this field props: ({ dependencies, }): HeaderFieldProps & { contentMode: HeaderFieldProps['contentMode'] } => { const [requestBody] = dependencies; return { + 'data-test-subj': 'syntheticsHeaderFieldRequestHeaders', readOnly, contentMode: (requestBody as RequestBodyCheck).type, }; @@ -847,13 +860,15 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ isDisabled: readOnly, }), validation: () => ({ - validate: (value) => { - const validateFn = validate[DataStream.HTTP][ConfigKey.RESPONSE_STATUS_CHECK]; - if (validateFn) { - return !validateFn({ - [ConfigKey.RESPONSE_STATUS_CHECK]: value, - }); - } + validate: { + validResponseStatusCheck: (value) => { + const validateFn = validate[DataStream.HTTP][ConfigKey.RESPONSE_STATUS_CHECK]; + if (validateFn) { + return !validateFn({ + [ConfigKey.RESPONSE_STATUS_CHECK]: value, + }); + } + }, }, }), error: i18n.translate('xpack.synthetics.monitorConfig.responseStatusCheck.error', { @@ -871,12 +886,17 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }), controlled: true, validation: () => ({ - validate: (headers) => !validateHeaders(headers), - }), - error: i18n.translate('xpack.synthetics.monitorConfig.responseHeadersCheck.error', { - defaultMessage: 'Header key must be a valid HTTP token.', + validate: { + validHeaders: (headers) => + validateHeaders(headers) + ? i18n.translate('xpack.synthetics.monitorConfig.responseHeadersCheck.error', { + defaultMessage: 'Header key must be a valid HTTP token.', + }) + : true, + }, }), props: (): HeaderFieldProps => ({ + 'data-test-subj': 'syntheticsHeaderFieldResponseHeaders', readOnly, }), }, @@ -964,31 +984,36 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ isEditFlow: isEdit, }), validation: () => ({ - validate: (value) => { - // return false if script contains import or require statement - if ( - value.script?.includes('import ') || - value.script?.includes('require(') || - value.script?.includes('journey(') - ) { - return i18n.translate('xpack.synthetics.monitorConfig.monitorScript.invalid', { - defaultMessage: - 'Monitor script is invalid. Inline scripts cannot be full journey scripts, they may only contain step definitions.', - }); - } - // should contain at least one step - if (value.script && !value.script?.includes('step(')) { - return i18n.translate('xpack.synthetics.monitorConfig.monitorScript.invalid.oneStep', { - defaultMessage: - 'Monitor script is invalid. Inline scripts must contain at least one step definition.', - }); - } - return Boolean(value.script); + validate: { + validScript: (value) => { + if (!value.script) { + return i18n.translate('xpack.synthetics.monitorConfig.monitorScript.error', { + defaultMessage: 'Monitor script is required', + }); + } + + // return false if script contains import or require statement + if ( + value.script?.includes('import ') || + value.script?.includes('require(') || + value.script?.includes('journey(') + ) { + return i18n.translate('xpack.synthetics.monitorConfig.monitorScript.invalid', { + defaultMessage: + 'Monitor script is invalid. Inline scripts cannot be full journey scripts, they may only contain step definitions.', + }); + } + // should contain at least one step + if (value.script && !value.script?.includes('step(')) { + return i18n.translate('xpack.synthetics.monitorConfig.monitorScript.invalid.oneStep', { + defaultMessage: + 'Monitor script is invalid. Inline scripts must contain at least one step definition.', + }); + } + return true; + }, }, }), - error: i18n.translate('xpack.synthetics.monitorConfig.monitorScript.error', { - defaultMessage: 'Monitor script is required', - }), }, [ConfigKey.PARAMS]: { fieldKey: ConfigKey.PARAMS, @@ -1005,9 +1030,6 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }), readOnly, }), - error: i18n.translate('xpack.synthetics.monitorConfig.params.error', { - defaultMessage: 'Invalid JSON format', - }), helpText: ( ({ /> ), validation: () => ({ - validate: (value) => { - const validateFn = validate[DataStream.BROWSER][ConfigKey.PARAMS]; - if (validateFn) { - return !validateFn({ - [ConfigKey.PARAMS]: value, - }); - } + validate: { + validParams: (value) => { + const validateFn = validate[DataStream.BROWSER][ConfigKey.PARAMS]; + if (validateFn) { + return validateFn({ + [ConfigKey.PARAMS]: value, + }) + ? i18n.translate('xpack.synthetics.monitorConfig.params.error', { + defaultMessage: 'Invalid JSON format', + }) + : true; + } + + return true; + }, }, }), }, @@ -1081,7 +1111,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ return !Boolean(isTLSEnabled); }, dependencies: ['isTLSEnabled'], - props: ({ field, setValue }): EuiComboBoxProps => { + props: ({ field, setValue, trigger }): EuiComboBoxProps => { return { options: Object.values(TLSVersion).map((version) => ({ label: version, @@ -1089,11 +1119,12 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ selectedOptions: Object.values(field?.value || []).map((version) => ({ label: version as TLSVersion, })), - onChange: (updatedValues: Array>) => { + onChange: async (updatedValues: Array>) => { setValue( ConfigKey.TLS_VERSION, updatedValues.map((option) => option.label as TLSVersion) ); + await trigger(ConfigKey.TLS_VERSION); }, isDisabled: readOnly, }; @@ -1276,9 +1307,6 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ ), - error: i18n.translate('xpack.synthetics.monitorConfig.playwrightOptions.error', { - defaultMessage: 'Invalid JSON format', - }), ariaLabel: i18n.translate( 'xpack.synthetics.monitorConfig.playwrightOptions.codeEditor.json.ariaLabel', { @@ -1298,13 +1326,21 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ id: 'syntheticsPlaywrightOptionsJSONCodeEditor', }), validation: () => ({ - validate: (value) => { - const validateFn = validate[DataStream.BROWSER][ConfigKey.PLAYWRIGHT_OPTIONS]; - if (validateFn) { - return !validateFn({ - [ConfigKey.PLAYWRIGHT_OPTIONS]: value, - }); - } + validate: { + validPlaywrightOptions: (value) => { + const validateFn = validate[DataStream.BROWSER][ConfigKey.PLAYWRIGHT_OPTIONS]; + if (validateFn) { + return validateFn({ + [ConfigKey.PLAYWRIGHT_OPTIONS]: value, + }) + ? i18n.translate('xpack.synthetics.monitorConfig.playwrightOptions.error', { + defaultMessage: 'Invalid JSON format', + }) + : true; + } + + return true; + }, }, }), }, @@ -1346,16 +1382,17 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ })} ), - props: ({ setValue, field }): EuiComboBoxProps => ({ + props: ({ setValue, field, trigger }): EuiComboBoxProps => ({ id: 'syntheticsMontiorConfigSyntheticsArgs', selectedOptions: Object.values(field?.value || []).map((arg) => ({ label: arg, })), - onChange: (updatedValues: Array>) => { + onChange: async (updatedValues: Array>) => { setValue( ConfigKey.SYNTHETICS_ARGS, updatedValues.map((option) => option.label) ); + await trigger(ConfigKey.SYNTHETICS_ARGS); }, onCreateOption: (newValue: string) => { setValue(ConfigKey.SYNTHETICS_ARGS, [...(field?.value || []), newValue]); @@ -1400,7 +1437,12 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ const [responseBodyIndex] = dependencies || []; return responseBodyIndex === ResponseBodyIndexPolicy.NEVER; }, - props: (): EuiFieldNumberProps => ({ min: 1, step: 'any', readOnly }), + props: (): EuiFieldNumberProps => ({ + 'data-test-subj': 'syntheticsMonitorConfigMaxBytes', + min: 1, + step: 'any', + readOnly, + }), dependencies: [ConfigKey.RESPONSE_BODY_INDEX], }, [ConfigKey.IPV4]: { @@ -1414,7 +1456,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }), controlled: true, dependencies: [ConfigKey.IPV6], - props: ({ field, setValue, dependencies }): EuiComboBoxProps => { + props: ({ field, setValue, trigger, dependencies }): EuiComboBoxProps => { const [ipv6] = dependencies; const ipv4 = field?.value; const values: string[] = []; @@ -1436,7 +1478,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ selectedOptions: values.map((version) => ({ label: version, })), - onChange: (updatedValues: Array>) => { + onChange: async (updatedValues: Array>) => { setValue( ConfigKey.IPV4, updatedValues.some((value) => value.label === 'IPv4') @@ -1445,6 +1487,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ ConfigKey.IPV6, updatedValues.some((value) => value.label === 'IPv6') ); + await trigger([ConfigKey.IPV4, ConfigKey.IPV4]); }, isDisabled: readOnly, }; @@ -1461,12 +1504,17 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }), controlled: true, validation: () => ({ - validate: (headers) => !validateHeaders(headers), - }), - error: i18n.translate('xpack.synthetics.monitorConfig.proxyHeaders.error', { - defaultMessage: 'The header key must be a valid HTTP token.', + validate: { + validHeaders: (headers) => + validateHeaders(headers) + ? i18n.translate('xpack.synthetics.monitorConfig.proxyHeaders.error', { + defaultMessage: 'The header key must be a valid HTTP token.', + }) + : true, + }, }), props: (): HeaderFieldProps => ({ + 'data-test-subj': 'syntheticsHeaderFieldProxyHeaders', readOnly, }), }, @@ -1481,7 +1529,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ 'A list of expressions executed against the body when parsed as JSON. The body size must be less than or equal to 100 MiB.', }), controlled: true, - props: ({ field, setValue }): KeyValuePairsFieldProps => ({ + props: ({ field, setValue, trigger }): KeyValuePairsFieldProps => ({ readOnly, keyLabel: i18n.translate('xpack.synthetics.monitorConfig.responseJSON.key.label', { defaultMessage: 'Description', @@ -1495,7 +1543,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ defaultMessage: 'Add expression', } ), - onChange: (pairs) => { + onChange: async (pairs) => { const value: ResponseCheckJSON[] = pairs .map((pair) => { const [description, expression] = pair; @@ -1507,21 +1555,23 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ .filter((pair) => pair.description || pair.expression); if (!isEqual(value, field?.value)) { setValue(ConfigKey.RESPONSE_JSON_CHECK, value); + await trigger(ConfigKey.RESPONSE_JSON_CHECK); } }, defaultPairs: field?.value.map((check) => [check.description, check.expression]) || [], }), - validation: () => { - return { - validate: (value: ResponseCheckJSON[]) => { + validation: () => ({ + validate: { + validBodyJSON: (value: ResponseCheckJSON[]) => { if (value.some((check) => !check.expression || !check.description)) { return i18n.translate('xpack.synthetics.monitorConfig.responseJSON.error', { defaultMessage: "This JSON expression isn't valid. Make sure that both the label and expression are defined.", }); } + return true; }, - }; - }, + }, + }), }, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/formatter.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/formatter.ts index f38149d6d6e757..e27fb0b908ee79 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/formatter.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/formatter.ts @@ -8,7 +8,7 @@ import { get, pick } from 'lodash'; import { ConfigKey, DataStream, FormMonitorType, MonitorFields } from '../types'; import { DEFAULT_FIELDS } from '../constants'; -export const formatter = (fields: Record) => { +export const serializeNestedFormField = (fields: Record) => { const monitorType = fields[ConfigKey.MONITOR_TYPE] as DataStream; const monitorFields: Record = {}; const defaults = DEFAULT_FIELDS[monitorType] as MonitorFields; @@ -23,7 +23,7 @@ export const formatter = (fields: Record) => { export const ALLOWED_FIELDS = [ConfigKey.ENABLED, ConfigKey.ALERT_CONFIG]; export const format = (fields: Record, readOnly: boolean = false) => { - const formattedFields = formatter(fields) as MonitorFields; + const formattedFields = serializeNestedFormField(fields) as MonitorFields; const textAssertion = formattedFields[ConfigKey.TEXT_ASSERTION] ? ` await page.getByText('${formattedFields[ConfigKey.TEXT_ASSERTION]}').first().waitFor();` diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/index.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/index.tsx index 64cfaae171a267..33c8ec03e5ff95 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/index.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/index.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EuiForm, EuiSpacer } from '@elastic/eui'; import { FormProvider } from 'react-hook-form'; -import { useFormWrapped } from '../hooks/use_form_wrapped'; +import { useFormWrapped } from '../../../../../hooks/use_form_wrapped'; import { FormMonitorType, SyntheticsMonitor } from '../types'; import { getDefaultFormFields, formatDefaultFormValues } from './defaults'; import { ActionBar } from './submit'; @@ -21,7 +21,7 @@ export const MonitorForm: React.FC<{ }> = ({ children, defaultValues, space, readOnly = false }) => { const methods = useFormWrapped({ mode: 'onSubmit', - reValidateMode: 'onChange', + reValidateMode: 'onSubmit', defaultValues: formatDefaultFormValues(defaultValues as SyntheticsMonitor) || getDefaultFormFields(space)[FormMonitorType.MULTISTEP], diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx index 00996009cf98d4..749b9b041519a9 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx @@ -26,9 +26,7 @@ export const ActionBar = ({ readOnly = false }: { readOnly: boolean }) => { const history = useHistory(); const { handleSubmit, - formState: { errors, defaultValues }, - getValues, - getFieldState, + formState: { defaultValues, isValid }, } = useFormContext(); const [monitorPendingDeletion, setMonitorPendingDeletion] = useState( @@ -42,12 +40,7 @@ export const ActionBar = ({ readOnly = false }: { readOnly: boolean }) => { const canEditSynthetics = useCanEditSynthetics(); const formSubmitter = (formData: Record) => { - // An additional invalid field check to account for customHook managed validation - const isAnyFieldInvalid = Object.keys(getValues()).some( - (fieldKey) => getFieldState(fieldKey).invalid - ); - - if (!Object.keys(errors).length && !isAnyFieldInvalid) { + if (isValid) { setMonitorData(format(formData, readOnly)); } }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/hooks/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/hooks/index.ts index 1bdb1a996cb047..6049abb37b22e8 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/hooks/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/hooks/index.ts @@ -6,4 +6,5 @@ */ export * from './use_is_edit_flow'; +export * from './use_validate_field'; export { useKibanaSpace } from '../../../../../hooks/use_kibana_space'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/hooks/use_form_wrapped.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/hooks/use_form_wrapped.tsx deleted file mode 100644 index 0f2c75ac559802..00000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/hooks/use_form_wrapped.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useCallback } from 'react'; -import { FieldValues, useForm, UseFormProps } from 'react-hook-form'; - -export function useFormWrapped( - props?: UseFormProps -) { - const { register, ...restOfForm } = useForm(props); - - const euiRegister = useCallback( - (name, ...registerArgs) => { - const { ref, ...restOfRegister } = register(name, ...registerArgs); - - return { - inputRef: ref, - ref, - ...restOfRegister, - }; - }, - [register] - ); - - return { - register: euiRegister, - ...restOfForm, - }; -} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/hooks/use_validate_field.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/hooks/use_validate_field.ts new file mode 100644 index 00000000000000..12515f56a560d2 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/hooks/use_validate_field.ts @@ -0,0 +1,85 @@ +/* + * 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 { useEffect, useState, ComponentProps } from 'react'; +import { Controller, ControllerFieldState, useFormContext } from 'react-hook-form'; +import { FieldMeta, FormConfig } from '../types'; + +export function useValidateField({ + fieldKey, + validation, + validationError, + required, + dependencies, + customHook, +}: { + fieldKey: FieldMeta['fieldKey']; + validation: FieldMeta['validation']; + validationError: FieldMeta['error']; + required: boolean; + dependencies: FieldMeta['dependencies']; + customHook: FieldMeta['customHook']; +}) { + const { getValues, formState, getFieldState, watch } = useFormContext(); + const fieldState = getFieldState(fieldKey, formState); + const fieldValue = getValues(fieldKey); + const fieldError = fieldState.error; + const isFieldTouched = fieldState.isTouched; + const isFieldInvalid = fieldState.invalid; + + const [dependenciesFieldMeta, setDependenciesFieldMeta] = useState< + Record + >({}); + let dependenciesValues: unknown[] = []; + + if (dependencies) { + dependenciesValues = watch(dependencies); + } + useEffect(() => { + if (dependencies) { + dependencies.forEach((dependency) => { + setDependenciesFieldMeta((prevState) => ({ + ...prevState, + [dependency]: getFieldState(dependency), + })); + }); + } + // run effect when dependencies values change, to get the most up-to-date meta state + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(dependenciesValues || []), dependencies, getFieldState]); + + let hookFn: Function = () => {}; + let hookProps; + + if (customHook) { + hookProps = customHook(fieldValue); + hookFn = hookProps.func; + } + const { [hookProps?.fieldKey as string]: hookResult } = hookFn(hookProps?.params) || {}; + const hookErrorContent = hookProps?.error; + const hookError = hookResult ? hookProps?.error : undefined; + + const { validate: fieldValidator, ...fieldRules } = validation?.(dependenciesValues) ?? {}; + const validatorsWithHook = { + validHook: () => (hookError ? hookErrorContent : true), + ...(fieldValidator ?? {}), + }; + + const showFieldAsInvalid = isFieldInvalid && (isFieldTouched || formState.isSubmitted); + + return { + dependenciesValues, + dependenciesFieldMeta, + isInvalid: showFieldAsInvalid, + error: showFieldAsInvalid ? fieldError?.message || validationError : undefined, + rules: { + required, + ...(fieldRules ?? {}), + validate: validatorsWithHook, + } as ComponentProps['rules'], + }; +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_edit_page.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_edit_page.test.tsx index 179480bfa137bc..4355b027f5e599 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_edit_page.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_edit_page.test.tsx @@ -6,6 +6,8 @@ */ import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { mockGlobals } from '../../utils/testing'; import { render } from '../../utils/testing/rtl_helpers'; import { MonitorEditPage } from './monitor_edit_page'; @@ -200,7 +202,7 @@ describe('MonitorEditPage', () => { it.each([true, false])( 'shows duplicate error when "nameAlreadyExists" is %s', - (nameAlreadyExists) => { + async (nameAlreadyExists) => { (useMonitorName as jest.Mock).mockReturnValue({ nameAlreadyExists }); jest.spyOn(observabilitySharedPublic, 'useFetcher').mockReturnValue({ @@ -216,7 +218,7 @@ describe('MonitorEditPage', () => { refetch: () => null, loading: false, }); - const { getByText, queryByText } = render(, { + const { getByText, queryByText, getByTestId } = render(, { state: { serviceLocations: { locations: [ @@ -235,8 +237,13 @@ describe('MonitorEditPage', () => { }, }); + const inputField = getByTestId('syntheticsMonitorConfigName'); + fireEvent.focus(inputField); + userEvent.type(inputField, 'any value'); // Hook is made to return duplicate error as true + fireEvent.blur(inputField); + if (nameAlreadyExists) { - expect(getByText('Monitor name already exists')).toBeInTheDocument(); + await waitFor(() => getByText('Monitor name already exists')); } else { expect(queryByText('Monitor name already exists')).not.toBeInTheDocument(); } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/index.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/index.tsx index f08452dc836074..ecfd00b23d76ea 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/index.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/index.tsx @@ -10,6 +10,7 @@ import { EuiSteps, EuiPanel, EuiText, EuiSpacer } from '@elastic/eui'; import { useFormContext } from 'react-hook-form'; import { InspectMonitorPortal } from './inspect_monitor_portal'; import { ConfigKey, FormMonitorType, StepMap } from '../types'; +import { serializeNestedFormField } from '../form/formatter'; import { AdvancedConfig } from '../advanced'; import { MonitorTypePortal } from './monitor_type_portal'; import { ReadOnlyCallout } from './read_only_callout'; @@ -25,7 +26,7 @@ export const MonitorSteps = ({ isEditFlow?: boolean; projectId?: string; }) => { - const { watch } = useFormContext(); + const { watch, formState } = useFormContext(); const [type]: [FormMonitorType] = watch([ConfigKey.FORM_MONITOR_TYPE]); const steps = stepMap[type]; @@ -55,7 +56,10 @@ export const MonitorSteps = ({ )} - + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/inspect_monitor_portal.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/inspect_monitor_portal.tsx index 80dd5e0f6a16cd..0f30bb43ab5109 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/inspect_monitor_portal.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/steps/inspect_monitor_portal.tsx @@ -7,13 +7,20 @@ import React from 'react'; import { InPortal } from 'react-reverse-portal'; +import { MonitorFields } from '../../../../../../common/runtime_types'; import { MonitorInspectWrapper } from '../../common/components/monitor_inspect'; import { InspectMonitorPortalNode } from '../portals'; -export const InspectMonitorPortal = () => { +export const InspectMonitorPortal = ({ + isValid, + monitorFields, +}: { + isValid: boolean; + monitorFields: MonitorFields; +}) => { return ( - + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts index 95d05c7b250ea8..d318fa878e9cf3 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts @@ -11,6 +11,8 @@ import { ControllerRenderProps, ControllerFieldState, FormState, + UseControllerProps, + FieldValues, } from 'react-hook-form'; import { ConfigKey, @@ -81,6 +83,7 @@ export interface FieldMeta { field?: ControllerRenderProps; formState: FormState; setValue: UseFormReturn['setValue']; + trigger: UseFormReturn['trigger']; reset: UseFormReturn['reset']; locations: Array; dependencies: unknown[]; @@ -90,7 +93,6 @@ export interface FieldMeta { }) => Record; controlled?: boolean; required?: boolean; - shouldUseSetValue?: boolean; customHook?: (value: unknown) => { // custom hooks are only supported for controlled components and only supported for determining error validation func: Function; @@ -102,9 +104,9 @@ export interface FieldMeta { event: React.ChangeEvent, formOnChange: (event: React.ChangeEvent) => void ) => void; - validation?: (dependencies: unknown[]) => Parameters[1]; + validation?: (dependencies: unknown[]) => UseControllerProps['rules']; error?: React.ReactNode; - dependencies?: Array; // fields that another field may depend for or validation. Values are passed to the validation function + dependencies?: Array; // fields that another field may depend on or for validation. Values are passed to the validation function } export interface FieldMap { diff --git a/x-pack/plugins/synthetics/public/hooks/use_form_wrapped.tsx b/x-pack/plugins/synthetics/public/hooks/use_form_wrapped.tsx index 97b1929a3200d3..97a5d743371448 100644 --- a/x-pack/plugins/synthetics/public/hooks/use_form_wrapped.tsx +++ b/x-pack/plugins/synthetics/public/hooks/use_form_wrapped.tsx @@ -5,30 +5,58 @@ * 2.0. */ -import { useCallback, useMemo } from 'react'; -import { FieldValues, useForm, UseFormProps } from 'react-hook-form'; +import { useCallback, useState } from 'react'; +import { FieldValues, useForm, UseFormProps, ChangeHandler } from 'react-hook-form'; +import useDebounce from 'react-use/lib/useDebounce'; export function useFormWrapped( props?: UseFormProps ) { - const form = useForm(props); + const { register, trigger, ...restOfForm } = useForm(props); + const [changed, setChanged] = useState(false); + useDebounce( + async () => { + if (changed) { + await trigger?.(); // Manually invalidate whole form to make dependency validations reactive + } + }, + 500, + [changed] + ); + + // Wrap `onChange` to validate form to trigger validations + const euiOnChange = useCallback( + (onChange: ChangeHandler) => { + return async (event: Parameters[0]) => { + setChanged(false); + const onChangeResult = await onChange(event); + setChanged(true); + + return onChangeResult; + }; + }, + [setChanged] + ); + + // Wrap react-hook-form register method to wire `onChange` and `inputRef` const euiRegister = useCallback( (name, ...registerArgs) => { - const { ref, ...restOfRegister } = form.register(name, ...registerArgs); + const { ref, onChange, ...restOfRegister } = register(name, ...registerArgs); return { inputRef: ref, ref, + onChange: euiOnChange(onChange), ...restOfRegister, }; }, - [form] + [register, euiOnChange] ); - const formState = form.formState; - return useMemo( - () => ({ ...form, register: euiRegister, formState }), - [euiRegister, form, formState] - ); + return { + register: euiRegister, + trigger, + ...restOfForm, + }; }