[Synthetics] Monitor Add/Edit - Fix form error states (#156626)

- Connects form validation states with react-hoom-form's state (which
was the reason of the reported bug that the error state was only
reflected in euiForm and not the react-hook-form)
- Changes `reValidateMode` from `onChange` to `onSubmit` and manually
invalidates the form when fields change so that dependent validations
can be executed reactively e.g. fixes the following state:
<img width="703" alt="Screenshot 2023-05-04 at 00 10 03"
src="https://user-images.githubusercontent.com/2748376/236061192-a9417915-1eca-4cff-b871-b680955ec15a.png">
- Extracts validation and dependencies logic into a hook to simplify
state management
- Fixes the following error where "Response body max bytes" field appear
despite "Index response body" flag being unchecked.
<img width="1205" alt="Screenshot 2023-05-05 at 19 35 27"
src="https://user-images.githubusercontent.com/2748376/236527581-ff62550a-2679-431e-8571-da02a89c0a32.png">
- Fixes a case where updated values from for nested fields (such as
"Response body contains JSON") weren't being passed to monitor inspect
component ("Inspect configuration" flyout on monitor Add/Edit).
This commit is contained in:
Abdul Wahab Zahid 2023-07-24 21:47:18 +02:00 committed by GitHub
parent d2ac7033a2
commit e3cc4a9a22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 993 additions and 331 deletions

View file

@ -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`
);
}
}
});
});
}
);

View file

@ -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'));

View file

@ -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<Page['locator']>) {
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<Page['locator']>, value: string) {
await locator.fill('');
await locator.type(value);
}
export async function typeViaKeyboard(locator: ReturnType<Page['locator']>, value: string) {
await locator.click();
await locator.page().keyboard.type(value);
}
export async function blur(locator: ReturnType<Page['locator']>) {
await locator.evaluate((e) => {
e.blur();
});
}
export async function clickAndBlur(locator: ReturnType<Page['locator']>) {
await locator.click();
await blur(locator);
}
export async function assertShouldNotExist(locator: ReturnType<Page['locator']>) {
await locator.waitFor({ state: 'detached' });
}

View file

@ -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<ClientPluginsStart>();
@ -40,10 +43,10 @@ export const MonitorInspectWrapper = () => {
const isInspectorEnabled = uiSettings?.get<boolean>(enableInspectEsQueries);
return isDev || isInspectorEnabled ? <MonitorInspect /> : null;
return isDev || isInspectorEnabled ? <MonitorInspect {...props} /> : 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 (
<>
<EuiToolTip content={formState.isValid ? FORMATTED_CONFIG_DESCRIPTION : VALID_CONFIG_LABEL}>
<EuiToolTip content={isValid ? FORMATTED_CONFIG_DESCRIPTION : VALID_CONFIG_LABEL}>
<EuiButton
disabled={!formState.isValid}
disabled={!isValid}
data-test-subj="syntheticsMonitorInspectShowFlyoutExampleButton"
onClick={onButtonClick}
iconType="inspect"

View file

@ -6,10 +6,10 @@
*/
import React from 'react';
import { Controller, FieldErrors, Control } from 'react-hook-form';
import { Controller, Control } from 'react-hook-form';
import { useSelector } from 'react-redux';
import { EuiComboBox, EuiFormRow } from '@elastic/eui';
import { EuiComboBox, EuiComboBoxProps, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ServiceLocation } from '../../../../../../common/runtime_types';
import { formatLocation } from '../../../../../../common/utils/location_formatter';
@ -19,28 +19,37 @@ import { SimpleFormData } from '../simple_monitor_form';
import { ConfigKey } from '../../../../../../common/constants/monitor_management';
export const ServiceLocationsField = ({
errors,
control,
onChange,
}: {
errors: FieldErrors;
control: Control<SimpleFormData, any>;
onChange: (locations: ServiceLocation[]) => void;
}) => {
const { locations } = useSelector(selectServiceLocationsState);
const fieldState = control.getFieldState(ConfigKey.LOCATIONS);
const showError = fieldState.isTouched || control._formState.isSubmitted;
return (
<EuiFormRow
fullWidth
label={LOCATIONS_LABEL}
helpText={!errors?.[ConfigKey.LOCATIONS] ? SELECT_ONE_OR_MORE_LOCATIONS_DETAILS : undefined}
isInvalid={!!errors?.[ConfigKey.LOCATIONS]}
error={SELECT_ONE_OR_MORE_LOCATIONS}
helpText={showError && fieldState.invalid ? undefined : SELECT_ONE_OR_MORE_LOCATIONS_DETAILS}
isInvalid={showError && fieldState.invalid}
error={showError ? SELECT_ONE_OR_MORE_LOCATIONS : undefined}
>
<Controller
name={ConfigKey.LOCATIONS}
control={control}
rules={{ required: true }}
rules={{
validate: {
notEmpty: (value: ServiceLocation[]) => {
return value?.length > 0 ? true : SELECT_ONE_OR_MORE_LOCATIONS;
},
},
}}
render={({ field }) => (
<EuiComboBox
<ComboBoxWithRef
fullWidth
aria-label={SELECT_ONE_OR_MORE_LOCATIONS}
options={locations.map((location) => ({
@ -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<HTMLInputElement, EuiComboBoxProps<ServiceLocation>>(
(props, ref) => (
<EuiComboBox
{...props}
inputRef={(element) => {
if (ref) {
if (typeof ref === 'function') {
ref(element);
} else {
ref.current = element;
}
}
}}
/>
)
);
const SELECT_ONE_OR_MORE_LOCATIONS = i18n.translate(
'xpack.synthetics.monitorManagement.selectOneOrMoreLocations',
{

View file

@ -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 (
<EuiForm
@ -63,20 +66,29 @@ export const SimpleMonitorForm = () => {
<EuiFormRow
fullWidth
label={WEBSITE_URL_LABEL}
helpText={!hasURLError ? WEBSITE_URL_HELP_TEXT : ''}
isInvalid={!!errors?.[ConfigKey.URLS]}
error={hasURLError ? URL_REQUIRED_LABEL : undefined}
helpText={urlError ? undefined : WEBSITE_URL_HELP_TEXT}
isInvalid={!!urlError}
error={urlError?.message}
>
<EuiFieldText
fullWidth
{...register(ConfigKey.URLS, { required: true })}
isInvalid={!!errors?.[ConfigKey.URLS]}
{...register(ConfigKey.URLS, {
validate: {
notEmpty: (value: string) => (!Boolean(value.trim()) ? URL_REQUIRED_LABEL : true),
},
})}
isInvalid={!!urlError}
data-test-subj={`${ConfigKey.URLS}-input`}
tabIndex={0}
/>
</EuiFormRow>
<EuiSpacer />
<ServiceLocationsField errors={errors} control={control} />
<ServiceLocationsField
control={control}
onChange={async (_locations: ServiceLocation[]) => {
await trigger?.();
}}
/>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>

View file

@ -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 }) => {
<EuiSpacer />
{formConfig.advanced?.map((configGroup) => {
return (
<DescripedFormGroup
<DescribedFormGroup
description={configGroup.description}
title={<h4>{configGroup.title}</h4>}
fullWidth
@ -46,15 +43,9 @@ export const AdvancedConfig = ({ readOnly }: { readOnly: boolean }) => {
style={{ flexWrap: 'wrap' }}
>
{configGroup.components.map((field) => {
return (
<Field
{...field}
key={field.fieldKey}
fieldError={errors[field.fieldKey] as FieldError}
/>
);
return <Field {...field} key={field.fieldKey} />;
})}
</DescripedFormGroup>
</DescribedFormGroup>
);
})}
</EuiAccordion>
@ -62,7 +53,7 @@ export const AdvancedConfig = ({ readOnly }: { readOnly: boolean }) => {
) : null;
};
const DescripedFormGroup = styled(EuiDescribedFormGroup)`
const DescribedFormGroup = styled(EuiDescribedFormGroup)`
> div.euiFlexGroup {
flex-wrap: wrap;
}

View file

@ -25,15 +25,15 @@ export const ResponseBodyIndexField = ({
onBlur,
readOnly,
}: ResponseBodyIndexFieldProps) => {
const [policy, setPolicy] = useState<ResponseBodyIndexPolicy>(
defaultValue !== ResponseBodyIndexPolicy.NEVER ? defaultValue : ResponseBodyIndexPolicy.ON_ERROR
);
const [policy, setPolicy] = useState<ResponseBodyIndexPolicy>(defaultValue);
const [checked, setChecked] = useState<boolean>(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);
}
@ -88,15 +88,6 @@ export const ResponseBodyIndexField = ({
};
const responseBodyIndexPolicyOptions = [
{
value: ResponseBodyIndexPolicy.ALWAYS,
text: i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.responseBodyIndex.always',
{
defaultMessage: 'Always',
}
),
},
{
value: ResponseBodyIndexPolicy.ON_ERROR,
text: i18n.translate(
@ -106,4 +97,13 @@ const responseBodyIndexPolicyOptions = [
}
),
},
{
value: ResponseBodyIndexPolicy.ALWAYS,
text: i18n.translate(
'xpack.synthetics.createPackagePolicy.stepConfigure.responseBodyIndex.always',
{
defaultMessage: 'Always',
}
),
},
];

View file

@ -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<TFieldKey extends keyof FormConfig = any> = FieldMeta<TFieldKey> & {
error: React.ReactNode;
dependenciesValues: unknown[];
dependenciesFieldMeta: Record<string, ControllerFieldState>;
isInvalid: boolean;
};
const setFieldValue =
(key: keyof FormConfig, setValue: UseFormReturn<FormConfig>['setValue']) => (value: any) => {
setValue(key, value);
};
export const ControlledField = <TFieldKey extends keyof FormConfig>({
component: FieldComponent,
props,
fieldKey,
shouldUseSetValue,
field,
formRowProps,
fieldState,
customHook,
error,
dependenciesValues,
dependenciesFieldMeta,
isInvalid,
}: Props<TFieldKey>) => {
const { setValue, reset, formState, setError, clearErrors } = useFormContext<FormConfig>();
const noop = () => {};
let hook: Function = noop;
let hookProps;
const { setValue, getFieldState, reset, formState, trigger } = useFormContext<FormConfig>();
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<Parameters<typeof field.onChange> | 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 = <TFieldKey extends keyof FormConfig>({
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 (
<EuiFormRow
{...formRowProps}
isInvalid={isInvalid}
error={isInvalid ? hookError || fieldState.error?.message || error : undefined}
>
<EuiFormRow {...formRowProps} isInvalid={isInvalid} error={error}>
<FieldComponent
{...field}
checked={field.value || false}
defaultValue={field.value}
onChange={onChange}
onChange={handleChange}
{...generatedProps}
isInvalid={isInvalid}
fullWidth

View file

@ -4,12 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useEffect, useState } from 'react';
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { Controller, useFormContext, FieldError, ControllerFieldState } from 'react-hook-form';
import { Controller, useFormContext, FieldError } from 'react-hook-form';
import { EuiFormRow } from '@elastic/eui';
import { selectServiceLocationsState } from '../../../state';
import { useKibanaSpace, useIsEditFlow } from '../hooks';
import { useKibanaSpace, useIsEditFlow, useValidateField } from '../hooks';
import { ControlledField } from './controlled_field';
import { FormConfig, FieldMeta } from '../types';
@ -24,39 +24,29 @@ export const Field = memo<Props>(
props,
fieldKey,
controlled,
shouldUseSetValue,
required,
validation,
error,
error: validationError,
fieldError,
dependencies,
customHook,
hidden,
}: Props) => {
const { register, watch, control, setValue, reset, getFieldState, formState } =
useFormContext<FormConfig>();
const { register, control, setValue, reset, formState, trigger } = useFormContext<FormConfig>();
const { locations } = useSelector(selectServiceLocationsState);
const { space } = useKibanaSpace();
const isEdit = useIsEditFlow();
const [dependenciesFieldMeta, setDependenciesFieldMeta] = useState<
Record<string, ControllerFieldState>
>({});
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<Props>(
<Controller<FormConfig, keyof FormConfig>
control={control}
name={fieldKey}
rules={{
required,
...(validation ? validation(dependenciesValues) : {}),
}}
rules={rules}
render={({ field, fieldState: fieldStateT }) => {
return (
<ControlledField
field={field}
component={Component}
props={props}
shouldUseSetValue={shouldUseSetValue}
fieldKey={fieldKey}
customHook={customHook}
formRowProps={formRowProps}
fieldState={fieldStateT}
error={error}
isInvalid={isInvalid}
dependenciesValues={dependenciesValues}
dependenciesFieldMeta={dependenciesFieldMeta}
/>
@ -102,15 +88,13 @@ export const Field = memo<Props>(
error={fieldError?.message || error}
>
<Component
{...register(fieldKey, {
required,
...(validation ? validation(dependenciesValues) : {}),
})}
{...register(fieldKey, rules)}
{...(props
? props({
field: undefined,
formState,
setValue,
trigger,
reset,
locations: locations.map((location) => ({ ...location, key: location.id })),
dependencies: dependenciesValues,
@ -119,7 +103,7 @@ export const Field = memo<Props>(
isEdit,
})
: {})}
isInvalid={Boolean(fieldError)}
isInvalid={isInvalid}
fullWidth
/>
</EuiFormRow>

View file

@ -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<HTMLInputElement>) => {
setValue(ConfigKey.URLS, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
onChange: async (event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
setValue(ConfigKey.URLS, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
onChange: async (event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
setValue(ConfigKey.HOSTS, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
onChange: async (event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
setValue(ConfigKey.HOSTS, event.target.value, {
shouldValidate: Boolean(formState.submitCount > 0),
});
onChange: async (event: React.ChangeEvent<HTMLInputElement>) => {
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: (
<FormattedMessage
id="xpack.synthetics.monitorConfig.params.helpText"
@ -1018,13 +1040,21 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({
/>
),
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<TLSVersion> => {
props: ({ field, setValue, trigger }): EuiComboBoxProps<TLSVersion> => {
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<EuiComboBoxOptionOption<TLSVersion>>) => {
onChange: async (updatedValues: Array<EuiComboBoxOptionOption<TLSVersion>>) => {
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 => ({
</EuiLink>
</span>
),
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 => ({
})}
</span>
),
props: ({ setValue, field }): EuiComboBoxProps<string> => ({
props: ({ setValue, field, trigger }): EuiComboBoxProps<string> => ({
id: 'syntheticsMontiorConfigSyntheticsArgs',
selectedOptions: Object.values(field?.value || []).map((arg) => ({
label: arg,
})),
onChange: (updatedValues: Array<EuiComboBoxOptionOption<string>>) => {
onChange: async (updatedValues: Array<EuiComboBoxOptionOption<string>>) => {
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<string> => {
props: ({ field, setValue, trigger, dependencies }): EuiComboBoxProps<string> => {
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<EuiComboBoxOptionOption<string>>) => {
onChange: async (updatedValues: Array<EuiComboBoxOptionOption<string>>) => {
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;
},
};
},
},
}),
},
});

View file

@ -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<string, any>) => {
export const serializeNestedFormField = (fields: Record<string, any>) => {
const monitorType = fields[ConfigKey.MONITOR_TYPE] as DataStream;
const monitorFields: Record<string, any> = {};
const defaults = DEFAULT_FIELDS[monitorType] as MonitorFields;
@ -23,7 +23,7 @@ export const formatter = (fields: Record<string, any>) => {
export const ALLOWED_FIELDS = [ConfigKey.ENABLED, ConfigKey.ALERT_CONFIG];
export const format = (fields: Record<string, unknown>, 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();`

View file

@ -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],

View file

@ -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<SyntheticsMonitor | null>(
@ -42,12 +40,7 @@ export const ActionBar = ({ readOnly = false }: { readOnly: boolean }) => {
const canEditSynthetics = useCanEditSynthetics();
const formSubmitter = (formData: Record<string, any>) => {
// 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));
}
};

View file

@ -6,4 +6,5 @@
*/
export * from './use_is_edit_flow';
export * from './use_validate_field';
export { useKibanaSpace } from '../../../../../hooks/use_kibana_space';

View file

@ -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<TFieldValues extends FieldValues = FieldValues, TContext = any>(
props?: UseFormProps<TFieldValues, TContext>
) {
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,
};
}

View file

@ -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<TFieldKey extends keyof FormConfig>({
fieldKey,
validation,
validationError,
required,
dependencies,
customHook,
}: {
fieldKey: FieldMeta<TFieldKey>['fieldKey'];
validation: FieldMeta<TFieldKey>['validation'];
validationError: FieldMeta<TFieldKey>['error'];
required: boolean;
dependencies: FieldMeta<TFieldKey>['dependencies'];
customHook: FieldMeta<TFieldKey>['customHook'];
}) {
const { getValues, formState, getFieldState, watch } = useFormContext<FormConfig>();
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<string, ControllerFieldState>
>({});
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<typeof Controller>['rules'],
};
}

View file

@ -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(<MonitorEditPage />, {
const { getByText, queryByText, getByTestId } = render(<MonitorEditPage />, {
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();
}

View file

@ -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 = ({
)}
<AdvancedConfig readOnly={readOnly} />
<MonitorTypePortal monitorType={type} />
<InspectMonitorPortal />
<InspectMonitorPortal
isValid={formState.isValid}
monitorFields={serializeNestedFormField(watch())}
/>
</>
);
};

View file

@ -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 (
<InPortal node={InspectMonitorPortalNode}>
<MonitorInspectWrapper />
<MonitorInspectWrapper isValid={isValid} monitorFields={monitorFields} />
</InPortal>
);
};

View file

@ -11,6 +11,8 @@ import {
ControllerRenderProps,
ControllerFieldState,
FormState,
UseControllerProps,
FieldValues,
} from 'react-hook-form';
import {
ConfigKey,
@ -81,6 +83,7 @@ export interface FieldMeta<TFieldKey extends keyof FormConfig> {
field?: ControllerRenderProps<FormConfig, TFieldKey>;
formState: FormState<FormConfig>;
setValue: UseFormReturn<FormConfig>['setValue'];
trigger: UseFormReturn<FormConfig>['trigger'];
reset: UseFormReturn<FormConfig>['reset'];
locations: Array<ServiceLocation & { key: string }>;
dependencies: unknown[];
@ -90,7 +93,6 @@ export interface FieldMeta<TFieldKey extends keyof FormConfig> {
}) => Record<string, any>;
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<TFieldKey extends keyof FormConfig> {
event: React.ChangeEvent<HTMLInputElement>,
formOnChange: (event: React.ChangeEvent<HTMLInputElement>) => void
) => void;
validation?: (dependencies: unknown[]) => Parameters<UseFormReturn['register']>[1];
validation?: (dependencies: unknown[]) => UseControllerProps<FieldValues, TFieldKey>['rules'];
error?: React.ReactNode;
dependencies?: Array<keyof FormConfig>; // fields that another field may depend for or validation. Values are passed to the validation function
dependencies?: Array<keyof FormConfig>; // fields that another field may depend on or for validation. Values are passed to the validation function
}
export interface FieldMap {

View file

@ -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<TFieldValues extends FieldValues = FieldValues, TContext = any>(
props?: UseFormProps<TFieldValues, TContext>
) {
const form = useForm<TFieldValues>(props);
const { register, trigger, ...restOfForm } = useForm(props);
const [changed, setChanged] = useState<boolean>(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<ChangeHandler>[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,
};
}