[Form lib] Memoize form hook object and fix hook array deps (#71237)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Patryk Kopycinski <contact@patrykkopycinski.com>
This commit is contained in:
Sébastien Loix 2020-07-15 16:58:51 +02:00 committed by GitHub
parent 1ac56d7bfc
commit 99255d824d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 778 additions and 682 deletions

View file

@ -17,7 +17,7 @@
* under the License.
*/
import React, { useEffect, useCallback, createContext, useContext } from 'react';
import React, { useEffect, useCallback, createContext, useContext, useRef } from 'react';
import { useMultiContent, HookProps, Content, MultiContent } from './use_multi_content';
@ -55,7 +55,14 @@ export function useMultiContentContext<T extends object = { [key: string]: any }
* @param contentId The content id to be added to the "contents" map
*/
export function useContent<T extends object, K extends keyof T>(contentId: K) {
const { updateContentAt, saveSnapshotAndRemoveContent, getData } = useMultiContentContext<T>();
const isMounted = useRef(false);
const defaultValue = useRef<T[K] | undefined>(undefined);
const {
updateContentAt,
saveSnapshotAndRemoveContent,
getData,
getSingleContentData,
} = useMultiContentContext<T>();
const updateContent = useCallback(
(content: Content) => {
@ -71,12 +78,22 @@ export function useContent<T extends object, K extends keyof T>(contentId: K) {
};
}, [contentId, saveSnapshotAndRemoveContent]);
const data = getData();
const defaultValue = data[contentId];
useEffect(() => {
if (isMounted.current === false) {
isMounted.current = true;
}
}, []);
if (isMounted.current === false) {
// Only read the default value once, on component mount to avoid re-rendering the
// consumer each time the multi-content validity ("isValid") changes.
defaultValue.current = getSingleContentData(contentId);
}
return {
defaultValue,
defaultValue: defaultValue.current!,
updateContent,
getData,
getSingleContentData,
};
}

View file

@ -45,6 +45,7 @@ export interface MultiContent<T extends object> {
updateContentAt: (id: keyof T, content: Content) => void;
saveSnapshotAndRemoveContent: (id: keyof T) => void;
getData: () => T;
getSingleContentData: <K extends keyof T>(contentId: K) => T[K];
validate: () => Promise<boolean>;
validation: Validation<T>;
}
@ -109,9 +110,22 @@ export function useMultiContent<T extends object>({
};
}, [stateData, validation]);
/**
* Read a single content data.
*/
const getSingleContentData = useCallback(
<K extends keyof T>(contentId: K): T[K] => {
if (contents.current[contentId]) {
return contents.current[contentId].getData();
}
return stateData[contentId];
},
[stateData]
);
const updateContentValidity = useCallback(
(updatedData: { [key in keyof T]?: boolean | undefined }): boolean | undefined => {
let allContentValidity: boolean | undefined;
let isAllContentValid: boolean | undefined = validation.isValid;
setValidation((prev) => {
if (
@ -120,7 +134,7 @@ export function useMultiContent<T extends object>({
)
) {
// No change in validation, nothing to update
allContentValidity = prev.isValid;
isAllContentValid = prev.isValid;
return prev;
}
@ -129,21 +143,21 @@ export function useMultiContent<T extends object>({
...updatedData,
};
allContentValidity = Object.values(nextContentsValidityState).some(
isAllContentValid = Object.values(nextContentsValidityState).some(
(_isValid) => _isValid === undefined
)
? undefined
: Object.values(nextContentsValidityState).every(Boolean);
return {
isValid: allContentValidity,
isValid: isAllContentValid,
contents: nextContentsValidityState,
};
});
return allContentValidity;
return isAllContentValid;
},
[]
[validation.isValid]
);
/**
@ -163,7 +177,7 @@ export function useMultiContent<T extends object>({
}
return Boolean(updateContentValidity(updatedValidation));
}, [updateContentValidity]);
}, [validation.isValid, updateContentValidity]);
/**
* Update a content. It replaces the content in our "contents" map and update
@ -186,7 +200,7 @@ export function useMultiContent<T extends object>({
});
}
},
[updateContentValidity, onChange]
[updateContentValidity, onChange, getData, validate]
);
/**
@ -211,6 +225,7 @@ export function useMultiContent<T extends object>({
return {
getData,
getSingleContentData,
validate,
validation,
updateContentAt,

View file

@ -29,6 +29,7 @@ interface Props {
export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) => {
const form = useFormContext();
const { subscribe } = form;
const previousRawData = useRef<FormData>(form.__getFormData$().value);
const [formData, setFormData] = useState<FormData>(previousRawData.current);
@ -54,9 +55,9 @@ export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) =
);
useEffect(() => {
const subscription = form.subscribe(onFormData);
const subscription = subscribe(onFormData);
return subscription.unsubscribe;
}, [form.subscribe, onFormData]);
}, [subscribe, onFormData]);
return children(formData);
});

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useFormContext } from '../form_context';
@ -83,14 +83,18 @@ export const UseArray = ({
const [items, setItems] = useState<ArrayItem[]>(initialState);
const updatePaths = (_rows: ArrayItem[]) =>
_rows.map(
(row, index) =>
({
...row,
path: `${path}[${index}]`,
} as ArrayItem)
);
const updatePaths = useCallback(
(_rows: ArrayItem[]) => {
return _rows.map(
(row, index) =>
({
...row,
path: `${path}[${index}]`,
} as ArrayItem)
);
},
[path]
);
const addItem = () => {
setItems((previousItems) => {
@ -108,11 +112,13 @@ export const UseArray = ({
useEffect(() => {
if (didMountRef.current) {
setItems(updatePaths(items));
setItems((prev) => {
return updatePaths(prev);
});
} else {
didMountRef.current = true;
}
}, [path]);
}, [path, updatePaths]);
return children({ items, addItem, removeItem });
};

View file

@ -30,8 +30,9 @@ describe('<UseField />', () => {
const TestComp = ({ onData }: { onData: OnUpdateHandler }) => {
const { form } = useForm();
const { subscribe } = form;
useEffect(() => form.subscribe(onData).unsubscribe, [form]);
useEffect(() => subscribe(onData).unsubscribe, [subscribe, onData]);
return (
<Form form={form}>

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { useState, useEffect, useRef, useMemo } from 'react';
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { FormHook, FieldHook, FieldConfig, FieldValidateResponse, ValidationError } from '../types';
import { FIELD_TYPES, VALIDATION_TYPES } from '../constants';
@ -34,21 +34,21 @@ export const useField = <T>(
label = '',
labelAppend = '',
helpText = '',
validations = [],
formatters = [],
fieldsToValidateOnChange = [path],
validations,
formatters,
fieldsToValidateOnChange,
errorDisplayDelay = form.__options.errorDisplayDelay,
serializer = (value: unknown) => value,
deserializer = (value: unknown) => value,
serializer,
deserializer,
} = config;
const { getFormData, __removeField, __updateFormDataAt, __validateFields } = form;
const initialValue = useMemo(
() =>
typeof defaultValue === 'function'
? deserializer(defaultValue())
: deserializer(defaultValue),
[defaultValue]
) as T;
const initialValue = useMemo(() => {
if (typeof defaultValue === 'function') {
return deserializer ? deserializer(defaultValue()) : defaultValue();
}
return deserializer ? deserializer(defaultValue) : defaultValue;
}, [defaultValue, deserializer]) as T;
const [value, setStateValue] = useState<T>(initialValue);
const [errors, setErrors] = useState<ValidationError[]>([]);
@ -64,6 +64,12 @@ export const useField = <T>(
// -- HELPERS
// ----------------------------------
const serializeOutput: FieldHook<T>['__serializeOutput'] = useCallback(
(rawValue = value) => {
return serializer ? serializer(rawValue) : rawValue;
},
[serializer, value]
);
/**
* Filter an array of errors with specific validation type on them
@ -84,19 +90,22 @@ export const useField = <T>(
);
};
const formatInputValue = <T>(inputValue: unknown): T => {
const isEmptyString = typeof inputValue === 'string' && inputValue.trim() === '';
const formatInputValue = useCallback(
<T>(inputValue: unknown): T => {
const isEmptyString = typeof inputValue === 'string' && inputValue.trim() === '';
if (isEmptyString) {
return inputValue as T;
}
if (isEmptyString || !formatters) {
return inputValue as T;
}
const formData = form.getFormData({ unflatten: false });
const formData = getFormData({ unflatten: false });
return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as T;
};
return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as T;
},
[formatters, getFormData]
);
const onValueChange = async () => {
const onValueChange = useCallback(async () => {
const changeIteration = ++changeCounter.current;
const startTime = Date.now();
@ -116,10 +125,10 @@ export const useField = <T>(
}
// Update the form data observable
form.__updateFormDataAt(path, newValue);
__updateFormDataAt(path, newValue);
// Validate field(s) and set form.isValid flag
await form.__validateFields(fieldsToValidateOnChange);
// Validate field(s) and update form.isValid state
await __validateFields(fieldsToValidateOnChange ?? [path]);
if (isUnmounted.current) {
return;
@ -142,9 +151,18 @@ export const useField = <T>(
setIsChangingValue(false);
}
}
};
}, [
serializeOutput,
valueChangeListener,
errorDisplayDelay,
path,
value,
fieldsToValidateOnChange,
__updateFormDataAt,
__validateFields,
]);
const cancelInflightValidation = () => {
const cancelInflightValidation = useCallback(() => {
// Cancel any inflight validation (like an HTTP Request)
if (
inflightValidation.current &&
@ -153,209 +171,232 @@ export const useField = <T>(
(inflightValidation.current as any).cancel();
inflightValidation.current = null;
}
};
}, []);
const runValidations = ({
formData,
value: valueToValidate,
validationTypeToValidate,
}: {
formData: any;
value: unknown;
validationTypeToValidate?: string;
}): ValidationError[] | Promise<ValidationError[]> => {
// By default, for fields that have an asynchronous validation
// we will clear the errors as soon as the field value changes.
clearErrors([VALIDATION_TYPES.FIELD, VALIDATION_TYPES.ASYNC]);
const clearErrors: FieldHook['clearErrors'] = useCallback(
(validationType = VALIDATION_TYPES.FIELD) => {
setErrors((previousErrors) => filterErrors(previousErrors, validationType));
},
[]
);
cancelInflightValidation();
const runAsync = async () => {
const validationErrors: ValidationError[] = [];
for (const validation of validations) {
inflightValidation.current = null;
const {
validator,
exitOnFail = true,
type: validationType = VALIDATION_TYPES.FIELD,
} = validation;
if (
typeof validationTypeToValidate !== 'undefined' &&
validationType !== validationTypeToValidate
) {
continue;
}
inflightValidation.current = validator({
value: (valueToValidate as unknown) as string,
errors: validationErrors,
form,
formData,
path,
}) as Promise<ValidationError>;
const validationResult = await inflightValidation.current;
if (!validationResult) {
continue;
}
validationErrors.push({
...validationResult,
validationType: validationType || VALIDATION_TYPES.FIELD,
});
if (exitOnFail) {
break;
}
const runValidations = useCallback(
({
formData,
value: valueToValidate,
validationTypeToValidate,
}: {
formData: any;
value: unknown;
validationTypeToValidate?: string;
}): ValidationError[] | Promise<ValidationError[]> => {
if (!validations) {
return [];
}
return validationErrors;
};
// By default, for fields that have an asynchronous validation
// we will clear the errors as soon as the field value changes.
clearErrors([VALIDATION_TYPES.FIELD, VALIDATION_TYPES.ASYNC]);
const runSync = () => {
const validationErrors: ValidationError[] = [];
// Sequentially execute all the validations for the field
for (const validation of validations) {
const {
validator,
exitOnFail = true,
type: validationType = VALIDATION_TYPES.FIELD,
} = validation;
cancelInflightValidation();
if (
typeof validationTypeToValidate !== 'undefined' &&
validationType !== validationTypeToValidate
) {
continue;
const runAsync = async () => {
const validationErrors: ValidationError[] = [];
for (const validation of validations) {
inflightValidation.current = null;
const {
validator,
exitOnFail = true,
type: validationType = VALIDATION_TYPES.FIELD,
} = validation;
if (
typeof validationTypeToValidate !== 'undefined' &&
validationType !== validationTypeToValidate
) {
continue;
}
inflightValidation.current = validator({
value: (valueToValidate as unknown) as string,
errors: validationErrors,
form,
formData,
path,
}) as Promise<ValidationError>;
const validationResult = await inflightValidation.current;
if (!validationResult) {
continue;
}
validationErrors.push({
...validationResult,
validationType: validationType || VALIDATION_TYPES.FIELD,
});
if (exitOnFail) {
break;
}
}
const validationResult = validator({
value: (valueToValidate as unknown) as string,
errors: validationErrors,
form,
formData,
path,
});
return validationErrors;
};
if (!validationResult) {
continue;
const runSync = () => {
const validationErrors: ValidationError[] = [];
// Sequentially execute all the validations for the field
for (const validation of validations) {
const {
validator,
exitOnFail = true,
type: validationType = VALIDATION_TYPES.FIELD,
} = validation;
if (
typeof validationTypeToValidate !== 'undefined' &&
validationType !== validationTypeToValidate
) {
continue;
}
const validationResult = validator({
value: (valueToValidate as unknown) as string,
errors: validationErrors,
form,
formData,
path,
});
if (!validationResult) {
continue;
}
if (!!validationResult.then) {
// The validator returned a Promise: abort and run the validations asynchronously
// We keep a reference to the onflith promise so we can cancel it.
inflightValidation.current = validationResult as Promise<ValidationError>;
cancelInflightValidation();
return runAsync();
}
validationErrors.push({
...(validationResult as ValidationError),
validationType: validationType || VALIDATION_TYPES.FIELD,
});
if (exitOnFail) {
break;
}
}
if (!!validationResult.then) {
// The validator returned a Promise: abort and run the validations asynchronously
// We keep a reference to the onflith promise so we can cancel it.
return validationErrors;
};
inflightValidation.current = validationResult as Promise<ValidationError>;
cancelInflightValidation();
return runAsync();
}
validationErrors.push({
...(validationResult as ValidationError),
validationType: validationType || VALIDATION_TYPES.FIELD,
});
if (exitOnFail) {
break;
}
}
return validationErrors;
};
// We first try to run the validations synchronously
return runSync();
};
// We first try to run the validations synchronously
return runSync();
},
[clearErrors, cancelInflightValidation, validations, form, path]
);
// -- API
// ----------------------------------
const clearErrors: FieldHook['clearErrors'] = (validationType = VALIDATION_TYPES.FIELD) => {
setErrors((previousErrors) => filterErrors(previousErrors, validationType));
};
/**
* Validate a form field, running all its validations.
* If a validationType is provided then only that validation will be executed,
* skipping the other type of validation that might exist.
*/
const validate: FieldHook<T>['validate'] = (validationData = {}) => {
const {
formData = form.getFormData({ unflatten: false }),
value: valueToValidate = value,
validationType,
} = validationData;
const validate: FieldHook<T>['validate'] = useCallback(
(validationData = {}) => {
const {
formData = getFormData({ unflatten: false }),
value: valueToValidate = value,
validationType,
} = validationData;
setIsValidated(true);
setValidating(true);
setIsValidated(true);
setValidating(true);
// By the time our validate function has reached completion, its possible
// that validate() will have been called again. If this is the case, we need
// to ignore the results of this invocation and only use the results of
// the most recent invocation to update the error state for a field
const validateIteration = ++validateCounter.current;
// By the time our validate function has reached completion, its possible
// that validate() will have been called again. If this is the case, we need
// to ignore the results of this invocation and only use the results of
// the most recent invocation to update the error state for a field
const validateIteration = ++validateCounter.current;
const onValidationErrors = (_validationErrors: ValidationError[]): FieldValidateResponse => {
if (validateIteration === validateCounter.current) {
// This is the most recent invocation
setValidating(false);
// Update the errors array
const filteredErrors = filterErrors(errors, validationType);
setErrors([...filteredErrors, ..._validationErrors]);
}
const onValidationErrors = (_validationErrors: ValidationError[]): FieldValidateResponse => {
if (validateIteration === validateCounter.current) {
// This is the most recent invocation
setValidating(false);
// Update the errors array
setErrors((prev) => {
const filteredErrors = filterErrors(prev, validationType);
return [...filteredErrors, ..._validationErrors];
});
}
return {
isValid: _validationErrors.length === 0,
errors: _validationErrors,
return {
isValid: _validationErrors.length === 0,
errors: _validationErrors,
};
};
};
const validationErrors = runValidations({
formData,
value: valueToValidate,
validationTypeToValidate: validationType,
});
const validationErrors = runValidations({
formData,
value: valueToValidate,
validationTypeToValidate: validationType,
});
if (Reflect.has(validationErrors, 'then')) {
return (validationErrors as Promise<ValidationError[]>).then(onValidationErrors);
}
return onValidationErrors(validationErrors as ValidationError[]);
};
if (Reflect.has(validationErrors, 'then')) {
return (validationErrors as Promise<ValidationError[]>).then(onValidationErrors);
}
return onValidationErrors(validationErrors as ValidationError[]);
},
[getFormData, value, runValidations]
);
/**
* Handler to change the field value
*
* @param newValue The new value to assign to the field
*/
const setValue: FieldHook<T>['setValue'] = (newValue) => {
if (isPristine) {
setPristine(false);
}
const setValue: FieldHook<T>['setValue'] = useCallback(
(newValue) => {
if (isPristine) {
setPristine(false);
}
const formattedValue = formatInputValue<T>(newValue);
setStateValue(formattedValue);
};
const formattedValue = formatInputValue<T>(newValue);
setStateValue(formattedValue);
return formattedValue;
},
[formatInputValue, isPristine]
);
const _setErrors: FieldHook<T>['setErrors'] = (_errors) => {
const _setErrors: FieldHook<T>['setErrors'] = useCallback((_errors) => {
setErrors(_errors.map((error) => ({ validationType: VALIDATION_TYPES.FIELD, ...error })));
};
}, []);
/**
* Form <input /> "onChange" event handler
*
* @param event Form input change event
*/
const onChange: FieldHook<T>['onChange'] = (event) => {
const newValue = {}.hasOwnProperty.call(event!.target, 'checked')
? event.target.checked
: event.target.value;
const onChange: FieldHook<T>['onChange'] = useCallback(
(event) => {
const newValue = {}.hasOwnProperty.call(event!.target, 'checked')
? event.target.checked
: event.target.value;
setValue((newValue as unknown) as T);
};
setValue((newValue as unknown) as T);
},
[setValue]
);
/**
* As we can have multiple validation types (FIELD, ASYNC, ARRAY_ITEM), this
@ -367,48 +408,50 @@ export const useField = <T>(
*
* @param validationType The validation type to return error messages from
*/
const getErrorsMessages: FieldHook<T>['getErrorsMessages'] = (args = {}) => {
const { errorCode, validationType = VALIDATION_TYPES.FIELD } = args;
const errorMessages = errors.reduce((messages, error) => {
const isSameErrorCode = errorCode && error.code === errorCode;
const isSamevalidationType =
error.validationType === validationType ||
(validationType === VALIDATION_TYPES.FIELD &&
!{}.hasOwnProperty.call(error, 'validationType'));
const getErrorsMessages: FieldHook<T>['getErrorsMessages'] = useCallback(
(args = {}) => {
const { errorCode, validationType = VALIDATION_TYPES.FIELD } = args;
const errorMessages = errors.reduce((messages, error) => {
const isSameErrorCode = errorCode && error.code === errorCode;
const isSamevalidationType =
error.validationType === validationType ||
(validationType === VALIDATION_TYPES.FIELD &&
!{}.hasOwnProperty.call(error, 'validationType'));
if (isSameErrorCode || (typeof errorCode === 'undefined' && isSamevalidationType)) {
return messages ? `${messages}, ${error.message}` : (error.message as string);
if (isSameErrorCode || (typeof errorCode === 'undefined' && isSamevalidationType)) {
return messages ? `${messages}, ${error.message}` : (error.message as string);
}
return messages;
}, '');
return errorMessages ? errorMessages : null;
},
[errors]
);
const reset: FieldHook<T>['reset'] = useCallback(
(resetOptions = { resetValue: true }) => {
const { resetValue = true } = resetOptions;
setPristine(true);
setValidating(false);
setIsChangingValue(false);
setIsValidated(false);
setErrors([]);
if (resetValue) {
setValue(initialValue);
/**
* Having to call serializeOutput() is a current bug of the lib and will be fixed
* in a future PR. The serializer function should only be called when outputting
* the form data. If we need to continuously format the data while it changes,
* we need to use the field `formatter` config.
*/
return serializeOutput(initialValue);
}
return messages;
}, '');
return errorMessages ? errorMessages : null;
};
const reset: FieldHook<T>['reset'] = (resetOptions = { resetValue: true }) => {
const { resetValue = true } = resetOptions;
setPristine(true);
setValidating(false);
setIsChangingValue(false);
setIsValidated(false);
setErrors([]);
if (resetValue) {
setValue(initialValue);
/**
* Having to call serializeOutput() is a current bug of the lib and will be fixed
* in a future PR. The serializer function should only be called when outputting
* the form data. If we need to continuously format the data while it changes,
* we need to use the field `formatter` config.
*/
return serializeOutput(initialValue);
}
return value;
};
const serializeOutput: FieldHook<T>['__serializeOutput'] = (rawValue = value) =>
serializer(rawValue);
},
[setValue, serializeOutput, initialValue]
);
// -- EFFECTS
// ----------------------------------
@ -425,54 +468,64 @@ export const useField = <T>(
clearTimeout(debounceTimeout.current);
}
};
}, [value]);
}, [isPristine, onValueChange]);
const field: FieldHook<T> = {
const field: FieldHook<T> = useMemo(() => {
return {
path,
type,
label,
labelAppend,
helpText,
value,
errors,
form,
isPristine,
isValid: errors.length === 0,
isValidating,
isValidated,
isChangingValue,
onChange,
getErrorsMessages,
setValue,
setErrors: _setErrors,
clearErrors,
validate,
reset,
__serializeOutput: serializeOutput,
};
}, [
path,
type,
label,
labelAppend,
helpText,
value,
errors,
form,
isPristine,
isValid: errors.length === 0,
errors,
isValidating,
isValidated,
isChangingValue,
onChange,
getErrorsMessages,
setValue,
setErrors: _setErrors,
_setErrors,
clearErrors,
validate,
reset,
__serializeOutput: serializeOutput,
};
serializeOutput,
]);
form.__addField(field as FieldHook<any>); // Executed first (1)
form.__addField(field as FieldHook<any>);
useEffect(() => {
/**
* NOTE: effect cleanup actually happens *after* the new component has been mounted,
* but before the next effect callback is run.
* Ref: https://kentcdodds.com/blog/understanding-reacts-key-prop
*
* This means that, the "form.__addField(field)" outside the effect will be called *before*
* the cleanup `form.__removeField(path);` creating a race condition.
*
* TODO: See how we could refactor "use_field" & "use_form" to avoid having the
* `form.__addField(field)` call outside the effect.
*/
form.__addField(field as FieldHook<any>); // Executed third (3)
return () => {
// Remove field from the form when it is unmounted or if its path changes.
isUnmounted.current = true;
form.__removeField(path); // Executed second (2)
__removeField(path);
};
}, [path]);
}, [path, __removeField]);
return field;
};

View file

@ -135,12 +135,13 @@ describe('use_form() hook', () => {
test('should allow subscribing to the form data changes and provide a handler to build the form data', async () => {
const TestComp = ({ onData }: { onData: OnUpdateHandler }) => {
const { form } = useForm();
const { subscribe } = form;
useEffect(() => {
// Any time the form value changes, forward the data to the consumer
const subscription = form.subscribe(onData);
const subscription = subscribe(onData);
return subscription.unsubscribe;
}, [form]);
}, [subscribe, onData]);
return (
<Form form={form}>
@ -200,8 +201,9 @@ describe('use_form() hook', () => {
const TestComp = ({ onData }: { onData: OnUpdateHandler }) => {
const { form } = useForm({ defaultValue });
const { subscribe } = form;
useEffect(() => form.subscribe(onData).unsubscribe, [form]);
useEffect(() => subscribe(onData).unsubscribe, [subscribe, onData]);
return (
<Form form={form}>

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { useState, useRef, useEffect, useMemo } from 'react';
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { get } from 'lodash';
import { FormHook, FieldHook, FormData, FieldConfig, FieldsMap, FormConfig } from '../types';
@ -34,28 +34,34 @@ interface UseFormReturn<T extends FormData> {
}
export function useForm<T extends FormData = FormData>(
formConfig: FormConfig<T> | undefined = {}
formConfig?: FormConfig<T>
): UseFormReturn<T> {
const {
onSubmit,
schema,
serializer = <T>(data: T): T => data,
deserializer = <T>(data: T): T => data,
options = {},
id = 'default',
} = formConfig;
const { onSubmit, schema, serializer, deserializer, options, id = 'default', defaultValue } =
formConfig ?? {};
const formDefaultValue =
formConfig.defaultValue === undefined || Object.keys(formConfig.defaultValue).length === 0
? {}
: Object.entries(formConfig.defaultValue as object)
.filter(({ 1: value }) => value !== undefined)
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
const formDefaultValue = useMemo(() => {
if (defaultValue === undefined || Object.keys(defaultValue).length === 0) {
return {};
}
const formOptions = { ...DEFAULT_OPTIONS, ...options };
const defaultValueDeserialized = useMemo(() => deserializer(formDefaultValue), [
formConfig.defaultValue,
]);
return Object.entries(defaultValue as object)
.filter(({ 1: value }) => value !== undefined)
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
}, [defaultValue]);
const { errorDisplayDelay, stripEmptyFields: doStripEmptyFields } = options ?? {};
const formOptions = useMemo(
() => ({
stripEmptyFields: doStripEmptyFields ?? DEFAULT_OPTIONS.stripEmptyFields,
errorDisplayDelay: errorDisplayDelay ?? DEFAULT_OPTIONS.errorDisplayDelay,
}),
[errorDisplayDelay, doStripEmptyFields]
);
const defaultValueDeserialized = useMemo(
() => (deserializer ? deserializer(formDefaultValue) : formDefaultValue),
[formDefaultValue, deserializer]
);
const [isSubmitted, setIsSubmitted] = useState(false);
const [isSubmitting, setSubmitting] = useState(false);
@ -81,55 +87,68 @@ export function useForm<T extends FormData = FormData>(
// -- HELPERS
// ----------------------------------
const getFormData$ = (): Subject<T> => {
const getFormData$ = useCallback((): Subject<T> => {
if (formData$.current === null) {
formData$.current = new Subject<T>({} as T);
}
return formData$.current;
};
const fieldsToArray = () => Object.values(fieldsRefs.current);
}, []);
const stripEmptyFields = (fields: FieldsMap): FieldsMap => {
if (formOptions.stripEmptyFields) {
return Object.entries(fields).reduce((acc, [key, field]) => {
if (typeof field.value !== 'string' || field.value.trim() !== '') {
acc[key] = field;
}
return acc;
}, {} as FieldsMap);
}
return fields;
};
const fieldsToArray = useCallback(() => Object.values(fieldsRefs.current), []);
const updateFormDataAt: FormHook<T>['__updateFormDataAt'] = (path, value) => {
const _formData$ = getFormData$();
const currentFormData = _formData$.value;
const nextValue = { ...currentFormData, [path]: value };
_formData$.next(nextValue);
return _formData$.value;
};
const stripEmptyFields = useCallback(
(fields: FieldsMap): FieldsMap => {
if (formOptions.stripEmptyFields) {
return Object.entries(fields).reduce((acc, [key, field]) => {
if (typeof field.value !== 'string' || field.value.trim() !== '') {
acc[key] = field;
}
return acc;
}, {} as FieldsMap);
}
return fields;
},
[formOptions]
);
const updateFormDataAt: FormHook<T>['__updateFormDataAt'] = useCallback(
(path, value) => {
const _formData$ = getFormData$();
const currentFormData = _formData$.value;
if (currentFormData[path] !== value) {
_formData$.next({ ...currentFormData, [path]: value });
}
return _formData$.value;
},
[getFormData$]
);
// -- API
// ----------------------------------
const getFormData: FormHook<T>['getFormData'] = (
getDataOptions: Parameters<FormHook<T>['getFormData']>[0] = { unflatten: true }
) => {
if (getDataOptions.unflatten) {
const nonEmptyFields = stripEmptyFields(fieldsRefs.current);
const fieldsValue = mapFormFields(nonEmptyFields, (field) => field.__serializeOutput());
return serializer(unflattenObject(fieldsValue)) as T;
}
const getFormData: FormHook<T>['getFormData'] = useCallback(
(getDataOptions: Parameters<FormHook<T>['getFormData']>[0] = { unflatten: true }) => {
if (getDataOptions.unflatten) {
const nonEmptyFields = stripEmptyFields(fieldsRefs.current);
const fieldsValue = mapFormFields(nonEmptyFields, (field) => field.__serializeOutput());
return serializer
? (serializer(unflattenObject(fieldsValue)) as T)
: (unflattenObject(fieldsValue) as T);
}
return Object.entries(fieldsRefs.current).reduce(
(acc, [key, field]) => ({
...acc,
[key]: field.__serializeOutput(),
}),
{} as T
);
};
return Object.entries(fieldsRefs.current).reduce(
(acc, [key, field]) => ({
...acc,
[key]: field.__serializeOutput(),
}),
{} as T
);
},
[stripEmptyFields, serializer]
);
const getErrors: FormHook['getErrors'] = () => {
const getErrors: FormHook['getErrors'] = useCallback(() => {
if (isValid === true) {
return [];
}
@ -141,11 +160,15 @@ export function useForm<T extends FormData = FormData>(
}
return [...acc, fieldError];
}, [] as string[]);
};
}, [isValid, fieldsToArray]);
const isFieldValid = (field: FieldHook) => field.isValid && !field.isValidating;
const updateFormValidity = () => {
const updateFormValidity = useCallback(() => {
if (isUnmounted.current) {
return;
}
const fieldsArray = fieldsToArray();
const areAllFieldsValidated = fieldsArray.every((field) => field.isValidated);
@ -158,176 +181,220 @@ export function useForm<T extends FormData = FormData>(
setIsValid(isFormValid);
return isFormValid;
};
}, [fieldsToArray]);
const validateFields: FormHook<T>['__validateFields'] = async (fieldNames) => {
const fieldsToValidate = fieldNames
.map((name) => fieldsRefs.current[name])
.filter((field) => field !== undefined);
const validateFields: FormHook<T>['__validateFields'] = useCallback(
async (fieldNames) => {
const fieldsToValidate = fieldNames
.map((name) => fieldsRefs.current[name])
.filter((field) => field !== undefined);
if (fieldsToValidate.length === 0) {
// Nothing to validate
return { areFieldsValid: true, isFormValid: true };
}
if (fieldsToValidate.length === 0) {
// Nothing to validate
return { areFieldsValid: true, isFormValid: true };
}
const formData = getFormData({ unflatten: false });
await Promise.all(fieldsToValidate.map((field) => field.validate({ formData })));
const formData = getFormData({ unflatten: false });
await Promise.all(fieldsToValidate.map((field) => field.validate({ formData })));
const isFormValid = updateFormValidity();
const areFieldsValid = fieldsToValidate.every(isFieldValid);
const isFormValid = updateFormValidity();
const areFieldsValid = fieldsToValidate.every(isFieldValid);
return { areFieldsValid, isFormValid };
};
return { areFieldsValid, isFormValid };
},
[getFormData, updateFormValidity]
);
const validateAllFields = async (): Promise<boolean> => {
const validateAllFields = useCallback(async (): Promise<boolean> => {
const fieldsArray = fieldsToArray();
const fieldsToValidate = fieldsArray.filter((field) => !field.isValidated);
let isFormValid: boolean | undefined = isValid;
let isFormValid: boolean | undefined;
if (fieldsToValidate.length === 0) {
if (isFormValid === undefined) {
// We should never enter this condition as the form validity is updated each time
// a field is validated. But sometimes, during tests it does not happen and we need
// to wait the next tick (hooks lifecycle being tricky) to make sure the "isValid" state is updated.
// In order to avoid this unintentional behaviour, we add this if condition here.
isFormValid = fieldsArray.every(isFieldValid);
setIsValid(isFormValid);
}
// We should never enter this condition as the form validity is updated each time
// a field is validated. But sometimes, during tests or race conditions it does not happen and we need
// to wait the next tick (hooks lifecycle being tricky) to make sure the "isValid" state is updated.
// In order to avoid this unintentional behaviour, we add this if condition here.
// TODO: Fix this when adding tests to the form lib.
isFormValid = fieldsArray.every(isFieldValid);
setIsValid(isFormValid);
return isFormValid;
}
({ isFormValid } = await validateFields(fieldsToValidate.map((field) => field.path)));
return isFormValid!;
};
}, [fieldsToArray, validateFields]);
const addField: FormHook<T>['__addField'] = (field) => {
fieldsRefs.current[field.path] = field;
const addField: FormHook<T>['__addField'] = useCallback(
(field) => {
fieldsRefs.current[field.path] = field;
if (!{}.hasOwnProperty.call(getFormData$().value, field.path)) {
const fieldValue = field.__serializeOutput();
updateFormDataAt(field.path, fieldValue);
}
};
if (!{}.hasOwnProperty.call(getFormData$().value, field.path)) {
const fieldValue = field.__serializeOutput();
updateFormDataAt(field.path, fieldValue);
}
},
[getFormData$, updateFormDataAt]
);
const removeField: FormHook<T>['__removeField'] = (_fieldNames) => {
const fieldNames = Array.isArray(_fieldNames) ? _fieldNames : [_fieldNames];
const currentFormData = { ...getFormData$().value } as FormData;
const removeField: FormHook<T>['__removeField'] = useCallback(
(_fieldNames) => {
const fieldNames = Array.isArray(_fieldNames) ? _fieldNames : [_fieldNames];
const currentFormData = { ...getFormData$().value } as FormData;
fieldNames.forEach((name) => {
delete fieldsRefs.current[name];
delete currentFormData[name];
});
fieldNames.forEach((name) => {
delete fieldsRefs.current[name];
delete currentFormData[name];
});
getFormData$().next(currentFormData as T);
getFormData$().next(currentFormData as T);
/**
* After removing a field, the form validity might have changed
* (an invalid field might have been removed and now the form is valid)
*/
updateFormValidity();
};
/**
* After removing a field, the form validity might have changed
* (an invalid field might have been removed and now the form is valid)
*/
updateFormValidity();
},
[getFormData$, updateFormValidity]
);
const setFieldValue: FormHook<T>['setFieldValue'] = (fieldName, value) => {
const setFieldValue: FormHook<T>['setFieldValue'] = useCallback((fieldName, value) => {
if (fieldsRefs.current[fieldName] === undefined) {
return;
}
fieldsRefs.current[fieldName].setValue(value);
};
}, []);
const setFieldErrors: FormHook<T>['setFieldErrors'] = (fieldName, errors) => {
const setFieldErrors: FormHook<T>['setFieldErrors'] = useCallback((fieldName, errors) => {
if (fieldsRefs.current[fieldName] === undefined) {
return;
}
fieldsRefs.current[fieldName].setErrors(errors);
};
}, []);
const getFields: FormHook<T>['getFields'] = () => fieldsRefs.current;
const getFields: FormHook<T>['getFields'] = useCallback(() => fieldsRefs.current, []);
const getFieldDefaultValue: FormHook['getFieldDefaultValue'] = (fieldName) =>
get(defaultValueDeserialized, fieldName);
const getFieldDefaultValue: FormHook['getFieldDefaultValue'] = useCallback(
(fieldName) => get(defaultValueDeserialized, fieldName),
[defaultValueDeserialized]
);
const readFieldConfigFromSchema: FormHook<T>['__readFieldConfigFromSchema'] = (fieldName) => {
const config = (get(schema ? schema : {}, fieldName) as FieldConfig) || {};
const readFieldConfigFromSchema: FormHook<T>['__readFieldConfigFromSchema'] = useCallback(
(fieldName) => {
const config = (get(schema ?? {}, fieldName) as FieldConfig) || {};
return config;
};
return config;
},
[schema]
);
const submitForm: FormHook<T>['submit'] = async (e) => {
if (e) {
e.preventDefault();
}
if (!isSubmitted) {
setIsSubmitted(true); // User has attempted to submit the form at least once
}
setSubmitting(true);
const isFormValid = await validateAllFields();
const formData = getFormData();
if (onSubmit) {
await onSubmit(formData, isFormValid!);
}
if (isUnmounted.current === false) {
setSubmitting(false);
}
return { data: formData, isValid: isFormValid! };
};
const subscribe: FormHook<T>['subscribe'] = (handler) => {
const subscription = getFormData$().subscribe((raw) => {
if (!isUnmounted.current) {
handler({ isValid, data: { raw, format: getFormData }, validate: validateAllFields });
const submitForm: FormHook<T>['submit'] = useCallback(
async (e) => {
if (e) {
e.preventDefault();
}
});
formUpdateSubscribers.current.push(subscription);
setIsSubmitted(true); // User has attempted to submit the form at least once
setSubmitting(true);
return {
unsubscribe() {
formUpdateSubscribers.current = formUpdateSubscribers.current.filter(
(sub) => sub !== subscription
);
return subscription.unsubscribe();
},
};
};
const isFormValid = await validateAllFields();
const formData = getFormData();
if (onSubmit) {
await onSubmit(formData, isFormValid!);
}
if (isUnmounted.current === false) {
setSubmitting(false);
}
return { data: formData, isValid: isFormValid! };
},
[validateAllFields, getFormData, onSubmit]
);
const subscribe: FormHook<T>['subscribe'] = useCallback(
(handler) => {
const subscription = getFormData$().subscribe((raw) => {
if (!isUnmounted.current) {
handler({ isValid, data: { raw, format: getFormData }, validate: validateAllFields });
}
});
formUpdateSubscribers.current.push(subscription);
return {
unsubscribe() {
formUpdateSubscribers.current = formUpdateSubscribers.current.filter(
(sub) => sub !== subscription
);
return subscription.unsubscribe();
},
};
},
[getFormData$, isValid, getFormData, validateAllFields]
);
/**
* Reset all the fields of the form to their default values
* and reset all the states to their original value.
*/
const reset: FormHook<T>['reset'] = (resetOptions = { resetValues: true }) => {
const { resetValues = true } = resetOptions;
const currentFormData = { ...getFormData$().value } as FormData;
Object.entries(fieldsRefs.current).forEach(([path, field]) => {
// By resetting the form, some field might be unmounted. In order
// to avoid a race condition, we check that the field still exists.
const isFieldMounted = fieldsRefs.current[path] !== undefined;
if (isFieldMounted) {
const fieldValue = field.reset({ resetValue: resetValues });
currentFormData[path] = fieldValue;
const reset: FormHook<T>['reset'] = useCallback(
(resetOptions = { resetValues: true }) => {
const { resetValues = true } = resetOptions;
const currentFormData = { ...getFormData$().value } as FormData;
Object.entries(fieldsRefs.current).forEach(([path, field]) => {
// By resetting the form, some field might be unmounted. In order
// to avoid a race condition, we check that the field still exists.
const isFieldMounted = fieldsRefs.current[path] !== undefined;
if (isFieldMounted) {
const fieldValue = field.reset({ resetValue: resetValues }) ?? currentFormData[path];
currentFormData[path] = fieldValue;
}
});
if (resetValues) {
getFormData$().next(currentFormData as T);
}
});
if (resetValues) {
getFormData$().next(currentFormData as T);
}
setIsSubmitted(false);
setSubmitting(false);
setIsValid(undefined);
};
setIsSubmitted(false);
setSubmitting(false);
setIsValid(undefined);
},
[getFormData$]
);
const form: FormHook<T> = {
const form = useMemo<FormHook<T>>(() => {
return {
isSubmitted,
isSubmitting,
isValid,
id,
submit: submitForm,
subscribe,
setFieldValue,
setFieldErrors,
getFields,
getFormData,
getErrors,
getFieldDefaultValue,
reset,
__options: formOptions,
__getFormData$: getFormData$,
__updateFormDataAt: updateFormDataAt,
__readFieldConfigFromSchema: readFieldConfigFromSchema,
__addField: addField,
__removeField: removeField,
__validateFields: validateFields,
};
}, [
isSubmitted,
isSubmitting,
isValid,
id,
submit: submitForm,
submitForm,
subscribe,
setFieldValue,
setFieldErrors,
@ -336,14 +403,14 @@ export function useForm<T extends FormData = FormData>(
getErrors,
getFieldDefaultValue,
reset,
__options: formOptions,
__getFormData$: getFormData$,
__updateFormDataAt: updateFormDataAt,
__readFieldConfigFromSchema: readFieldConfigFromSchema,
__addField: addField,
__removeField: removeField,
__validateFields: validateFields,
};
formOptions,
getFormData$,
updateFormDataAt,
readFieldConfigFromSchema,
addField,
removeField,
validateFields,
]);
return {
form,

View file

@ -107,7 +107,7 @@ export interface FieldHook<T = unknown> {
errorCode?: string;
}) => string | null;
onChange: (event: ChangeEvent<{ name?: string; value: string; checked?: boolean }>) => void;
setValue: (value: T) => void;
setValue: (value: T) => T;
setErrors: (errors: ValidationError[]) => void;
clearErrors: (type?: string | string[]) => void;
validate: (validateData?: {
@ -115,7 +115,7 @@ export interface FieldHook<T = unknown> {
value?: unknown;
validationType?: string;
}) => FieldValidateResponse | Promise<FieldValidateResponse>;
reset: (options?: { resetValue: boolean }) => unknown;
reset: (options?: { resetValue: boolean }) => unknown | undefined;
__serializeOutput: (rawValue?: unknown) => unknown;
}

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
@ -44,26 +44,28 @@ export const StepLogistics: React.FunctionComponent<Props> = React.memo(
options: { stripEmptyFields: false },
});
const { isValid: isFormValid, submit, getFormData, subscribe } = form;
const { documentation } = useComponentTemplatesContext();
const [isMetaVisible, setIsMetaVisible] = useState<boolean>(
Boolean(defaultValue._meta && Object.keys(defaultValue._meta).length)
);
const validate = async () => {
return (await form.submit()).isValid;
};
const validate = useCallback(async () => {
return (await submit()).isValid;
}, [submit]);
useEffect(() => {
onChange({
isValid: form.isValid,
isValid: isFormValid,
validate,
getData: form.getFormData,
getData: getFormData,
});
}, [form.isValid, onChange]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isFormValid, getFormData, validate, onChange]);
useEffect(() => {
const subscription = form.subscribe(({ data, isValid }) => {
const subscription = subscribe(({ data, isValid }) => {
onChange({
isValid,
validate,
@ -71,7 +73,7 @@ export const StepLogistics: React.FunctionComponent<Props> = React.memo(
});
});
return subscription.unsubscribe;
}, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps
}, [subscribe, validate, onChange]);
return (
<Form form={form} data-test-subj="stepLogistics">

View file

@ -94,22 +94,23 @@ export const ConfigurationForm = React.memo(({ value }: Props) => {
id: 'configurationForm',
});
const dispatch = useDispatch();
const { subscribe, submit, reset, getFormData } = form;
useEffect(() => {
const subscription = form.subscribe(({ data, isValid, validate }) => {
const subscription = subscribe(({ data, isValid, validate }) => {
dispatch({
type: 'configuration.update',
value: {
data,
isValid,
validate,
submitForm: form.submit,
submitForm: submit,
},
});
});
return subscription.unsubscribe;
}, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps
}, [dispatch, subscribe, submit]);
useEffect(() => {
if (isMounted.current === undefined) {
@ -125,18 +126,18 @@ export const ConfigurationForm = React.memo(({ value }: Props) => {
// If the value has changed (it probably means that we have loaded a new JSON)
// we need to reset the form to update the fields values.
form.reset({ resetValues: true });
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
reset({ resetValues: true });
}, [value, reset]);
useEffect(() => {
return () => {
isMounted.current = false;
// Save a snapshot of the form state so we can get back to it when navigating back to the tab
const configurationData = form.getFormData();
const configurationData = getFormData();
dispatch({ type: 'configuration.save', value: configurationData });
};
}, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps
}, [getFormData, dispatch]);
return (
<Form

View file

@ -50,13 +50,15 @@ export const CreateField = React.memo(function CreateFieldComponent({
options: { stripEmptyFields: false },
});
const { subscribe } = form;
useEffect(() => {
const subscription = form.subscribe((updatedFieldForm) => {
const subscription = subscribe((updatedFieldForm) => {
dispatch({ type: 'fieldForm.update', value: updatedFieldForm });
});
return subscription.unsubscribe;
}, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps
}, [dispatch, subscribe]);
const cancel = () => {
dispatch({ type: 'documentField.changeStatus', value: 'idle' });

View file

@ -26,13 +26,15 @@ export const EditFieldContainer = React.memo(({ field, allFields }: Props) => {
options: { stripEmptyFields: false },
});
const { subscribe } = form;
useEffect(() => {
const subscription = form.subscribe((updatedFieldForm) => {
const subscription = subscribe((updatedFieldForm) => {
dispatch({ type: 'fieldForm.update', value: updatedFieldForm });
});
return subscription.unsubscribe;
}, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps
}, [subscribe, dispatch]);
const exitEdit = useCallback(() => {
dispatch({ type: 'documentField.changeStatus', value: 'idle' });

View file

@ -61,17 +61,18 @@ export const TemplatesForm = React.memo(({ value }: Props) => {
deserializer: formDeserializer,
defaultValue: value,
});
const { subscribe, getFormData, submit: submitForm, reset } = form;
const dispatch = useDispatch();
useEffect(() => {
const subscription = form.subscribe(({ data, isValid, validate }) => {
const subscription = subscribe(({ data, isValid, validate }) => {
dispatch({
type: 'templates.update',
value: { data, isValid, validate, submitForm: form.submit },
value: { data, isValid, validate, submitForm },
});
});
return subscription.unsubscribe;
}, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps
}, [subscribe, dispatch, submitForm]);
useEffect(() => {
if (isMounted.current === undefined) {
@ -87,18 +88,18 @@ export const TemplatesForm = React.memo(({ value }: Props) => {
// If the value has changed (it probably means that we have loaded a new JSON)
// we need to reset the form to update the fields values.
form.reset({ resetValues: true });
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
reset({ resetValues: true });
}, [value, reset]);
useEffect(() => {
return () => {
isMounted.current = false;
// On unmount => save in the state a snapshot of the current form data.
const dynamicTemplatesData = form.getFormData();
const dynamicTemplatesData = getFormData();
dispatch({ type: 'templates.save', value: dynamicTemplatesData });
};
}, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps
}, [getFormData, dispatch]);
return (
<div data-test-subj="dynamicTemplates">

View file

@ -14,15 +14,16 @@ interface Props {
}
export const StepMappingsContainer: React.FunctionComponent<Props> = ({ esDocsBase }) => {
const { defaultValue, updateContent, getData } = Forms.useContent<CommonWizardSteps, 'mappings'>(
const { defaultValue, updateContent, getSingleContentData } = Forms.useContent<
CommonWizardSteps,
'mappings'
);
>('mappings');
return (
<StepMappings
defaultValue={defaultValue}
onChange={updateContent}
indexSettings={getData().settings}
indexSettings={getSingleContentData('settings')}
esDocsBase={esDocsBase}
/>
);

View file

@ -47,7 +47,7 @@ export const AddComment = React.memo<AddCommentProps>(
options: { stripEmptyFields: false },
schema,
});
const { getFormData, setFieldValue, reset, submit } = form;
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline<CommentRequest>(
form,
'comment'
@ -55,26 +55,23 @@ export const AddComment = React.memo<AddCommentProps>(
useEffect(() => {
if (insertQuote !== null) {
const { comment } = form.getFormData();
form.setFieldValue(
'comment',
`${comment}${comment.length > 0 ? '\n\n' : ''}${insertQuote}`
);
const { comment } = getFormData();
setFieldValue('comment', `${comment}${comment.length > 0 ? '\n\n' : ''}${insertQuote}`);
}
}, [form, insertQuote]);
}, [getFormData, insertQuote, setFieldValue]);
const handleTimelineClick = useTimelineClick();
const onSubmit = useCallback(async () => {
const { isValid, data } = await form.submit();
const { isValid, data } = await submit();
if (isValid) {
if (onCommentSaving != null) {
onCommentSaving();
}
postComment(data, onCommentPosted);
form.reset();
reset();
}
}, [form, onCommentPosted, onCommentSaving, postComment]);
}, [onCommentPosted, onCommentSaving, postComment, reset, submit]);
return (
<span id="add-comment-permLink">

View file

@ -69,6 +69,7 @@ export const Create = React.memo(() => {
options: { stripEmptyFields: false },
schema,
});
const { submit } = form;
const { tags: tagOptions } = useGetTags();
const [options, setOptions] = useState(
tagOptions.map((label) => ({
@ -91,12 +92,12 @@ export const Create = React.memo(() => {
const handleTimelineClick = useTimelineClick();
const onSubmit = useCallback(async () => {
const { isValid, data } = await form.submit();
const { isValid, data } = await submit();
if (isValid) {
// `postCase`'s type is incorrect, it actually returns a promise
await postCase(data);
}
}, [form, postCase]);
}, [submit, postCase]);
const handleSetIsCancel = useCallback(() => {
history.push('/');

View file

@ -46,11 +46,13 @@ export const EditConnector = React.memo(
onSubmit,
selectedConnector,
}: EditConnectorProps) => {
const initialState = { connectors };
const { form } = useForm({
defaultValue: { connectors },
defaultValue: initialState,
options: { stripEmptyFields: false },
schema,
});
const { setFieldValue, submit } = form;
const [connectorHasChanged, setConnectorHasChanged] = useState(false);
const onChangeConnector = useCallback(
(connectorId) => {
@ -60,17 +62,18 @@ export const EditConnector = React.memo(
);
const onCancelConnector = useCallback(() => {
form.setFieldValue('connector', selectedConnector);
setFieldValue('connector', selectedConnector);
setConnectorHasChanged(false);
}, [form, selectedConnector]);
}, [selectedConnector, setFieldValue]);
const onSubmitConnector = useCallback(async () => {
const { isValid, data: newData } = await form.submit();
const { isValid, data: newData } = await submit();
if (isValid && newData.connector) {
onSubmit(newData.connector);
setConnectorHasChanged(false);
}
}, [form, onSubmit]);
}, [submit, onSubmit]);
return (
<EuiText>
<MyFlexGroup alignItems="center" gutterSize="xs" justifyContent="spaceBetween">

View file

@ -42,20 +42,23 @@ const MyFlexGroup = styled(EuiFlexGroup)`
export const TagList = React.memo(
({ disabled = false, isLoading, onSubmit, tags }: TagListProps) => {
const initialState = { tags };
const { form } = useForm({
defaultValue: { tags },
defaultValue: initialState,
options: { stripEmptyFields: false },
schema,
});
const { submit } = form;
const [isEditTags, setIsEditTags] = useState(false);
const onSubmitTags = useCallback(async () => {
const { isValid, data: newData } = await form.submit();
const { isValid, data: newData } = await submit();
if (isValid && newData.tags) {
onSubmit(newData.tags);
setIsEditTags(false);
}
}, [form, onSubmit]);
}, [onSubmit, submit]);
const { tags: tagOptions } = useGetTags();
const [options, setOptions] = useState(
tagOptions.map((label) => ({

View file

@ -6,7 +6,7 @@
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui';
import React, { useCallback } from 'react';
import styled, { css } from 'styled-components';
import styled from 'styled-components';
import * as i18n from '../case_view/translations';
import { Markdown } from '../../../common/components/markdown';
@ -18,9 +18,7 @@ import { MarkdownEditorForm } from '../../../common/components//markdown_editor/
import { useTimelineClick } from '../utils/use_timeline_click';
const ContentWrapper = styled.div`
${({ theme }) => css`
padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeL};
`}
padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`};
`;
interface UserActionMarkdownProps {
@ -37,11 +35,13 @@ export const UserActionMarkdown = ({
onChangeEditable,
onSaveContent,
}: UserActionMarkdownProps) => {
const initialState = { content };
const { form } = useForm<Content>({
defaultValue: { content },
defaultValue: initialState,
options: { stripEmptyFields: false },
schema,
});
const { submit } = form;
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline<Content>(
form,
'content'
@ -53,45 +53,43 @@ export const UserActionMarkdown = ({
const handleTimelineClick = useTimelineClick();
const handleSaveAction = useCallback(async () => {
const { isValid, data } = await form.submit();
const { isValid, data } = await submit();
if (isValid) {
onSaveContent(data.content);
}
onChangeEditable(id);
}, [form, id, onChangeEditable, onSaveContent]);
}, [id, onChangeEditable, onSaveContent, submit]);
const renderButtons = useCallback(
({ cancelAction, saveAction }) => {
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="user-action-cancel-markdown"
size="s"
onClick={cancelAction}
iconType="cross"
>
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="user-action-save-markdown"
color="secondary"
fill
iconType="save"
onClick={saveAction}
size="s"
>
{i18n.SAVE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleCancelAction, handleSaveAction]
({ cancelAction, saveAction }) => (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="user-action-cancel-markdown"
size="s"
onClick={cancelAction}
iconType="cross"
>
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="user-action-save-markdown"
color="secondary"
fill
iconType="save"
onClick={saveAction}
size="s"
>
{i18n.SAVE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
),
[]
);
return isEditable ? (
<Form form={form} data-test-subj="user-action-markdown-form">
<UseField

View file

@ -7,7 +7,6 @@
import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiButtonEmpty, EuiFormRow } from '@elastic/eui';
import React, { FC, memo, useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import {
RuleStepProps,
@ -35,7 +34,6 @@ import * as I18n from './translations';
import { StepContentWrapper } from '../step_content_wrapper';
import { NextStep } from '../next_step';
import { MarkdownEditorForm } from '../../../../common/components/markdown_editor/form';
import { setFieldValue } from '../../../pages/detection_engine/rules/helpers';
import { SeverityField } from '../severity_mapping';
import { RiskScoreField } from '../risk_score_mapping';
import { useFetchIndexPatterns } from '../../../containers/detection_engine/rules';
@ -44,8 +42,8 @@ import { AutocompleteField } from '../autocomplete_field';
const CommonUseField = getUseField({ component: Field });
interface StepAboutRuleProps extends RuleStepProps {
defaultValues?: AboutStepRule | null;
defineRuleData?: DefineStepRule | null;
defaultValues?: AboutStepRule;
defineRuleData?: DefineStepRule;
}
const ThreeQuartersContainer = styled.div`
@ -91,48 +89,35 @@ const StepAboutRuleComponent: FC<StepAboutRuleProps> = ({
setForm,
setStepData,
}) => {
const [myStepData, setMyStepData] = useState<AboutStepRule>(stepAboutDefaultValue);
const initialState = defaultValues ?? stepAboutDefaultValue;
const [myStepData, setMyStepData] = useState<AboutStepRule>(initialState);
const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns(
defineRuleData?.index ?? []
);
const { form } = useForm({
defaultValue: myStepData,
defaultValue: initialState,
options: { stripEmptyFields: false },
schema,
});
const { getFields, submit } = form;
const onSubmit = useCallback(async () => {
if (setStepData) {
setStepData(RuleStep.aboutRule, null, false);
const { isValid, data } = await form.submit();
const { isValid, data } = await submit();
if (isValid) {
setStepData(RuleStep.aboutRule, data, isValid);
setMyStepData({ ...data, isNew: false } as AboutStepRule);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
}, [setStepData, submit]);
useEffect(() => {
const { isNew, ...initDefaultValue } = myStepData;
if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) {
const myDefaultValues = {
...defaultValues,
isNew: false,
};
setMyStepData(myDefaultValues);
setFieldValue(form, schema, myDefaultValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValues]);
useEffect(() => {
if (setForm != null) {
if (setForm) {
setForm(RuleStep.aboutRule, form);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
}, [setForm, form]);
return isReadOnlyView && myStepData.name != null ? (
<StepContentWrapper data-test-subj="aboutStep" addPadding={addPadding}>
@ -338,8 +323,8 @@ const StepAboutRuleComponent: FC<StepAboutRuleProps> = ({
<FormDataProvider pathsToWatch="severity">
{({ severity }) => {
const newRiskScore = defaultRiskScoreBySeverity[severity as SeverityValue];
const severityField = form.getFields().severity;
const riskScoreField = form.getFields().riskScore;
const severityField = getFields().severity;
const riskScoreField = getFields().riskScore;
if (
severityField.value !== severity &&
newRiskScore != null &&

View file

@ -17,7 +17,6 @@ import { useFetchIndexPatterns } from '../../../containers/detection_engine/rule
import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations';
import { useMlCapabilities } from '../../../../common/components/ml_popover/hooks/use_ml_capabilities';
import { useUiSetting$ } from '../../../../common/lib/kibana';
import { setFieldValue } from '../../../pages/detection_engine/rules/helpers';
import {
filterRuleFieldsForType,
RuleFields,
@ -109,58 +108,46 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
const mlCapabilities = useMlCapabilities();
const [openTimelineSearch, setOpenTimelineSearch] = useState(false);
const [indexModified, setIndexModified] = useState(false);
const [localRuleType, setLocalRuleType] = useState(
defaultValues?.ruleType || stepDefineDefaultValue.ruleType
);
const [indicesConfig] = useUiSetting$<string[]>(DEFAULT_INDEX_KEY);
const [myStepData, setMyStepData] = useState<DefineStepRule>({
const initialState = defaultValues ?? {
...stepDefineDefaultValue,
index: indicesConfig ?? [],
});
};
const [localRuleType, setLocalRuleType] = useState(initialState.ruleType);
const [myStepData, setMyStepData] = useState<DefineStepRule>(initialState);
const [
{ browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar },
] = useFetchIndexPatterns(myStepData.index);
const { form } = useForm({
defaultValue: myStepData,
defaultValue: initialState,
options: { stripEmptyFields: false },
schema,
});
const clearErrors = useCallback(() => form.reset({ resetValues: false }), [form]);
const { getFields, reset, submit } = form;
const clearErrors = useCallback(() => reset({ resetValues: false }), [reset]);
const onSubmit = useCallback(async () => {
if (setStepData) {
setStepData(RuleStep.defineRule, null, false);
const { isValid, data } = await form.submit();
const { isValid, data } = await submit();
if (isValid && setStepData) {
setStepData(RuleStep.defineRule, data, isValid);
setMyStepData({ ...data, isNew: false } as DefineStepRule);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
}, [setStepData, submit]);
useEffect(() => {
const { isNew, ...values } = myStepData;
if (defaultValues != null && !deepEqual(values, defaultValues)) {
const newValues = { ...values, ...defaultValues, isNew: false };
setMyStepData(newValues);
setFieldValue(form, schema, newValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValues, setMyStepData, setFieldValue]);
useEffect(() => {
if (setForm != null) {
if (setForm) {
setForm(RuleStep.defineRule, form);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
}, [form, setForm]);
const handleResetIndices = useCallback(() => {
const indexField = form.getFields().index;
const indexField = getFields().index;
indexField.setValue(indicesConfig);
}, [form, indicesConfig]);
}, [getFields, indicesConfig]);
const handleOpenTimelineSearch = useCallback(() => {
setOpenTimelineSearch(true);
@ -281,11 +268,11 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
fields={{
thresholdField: {
path: 'threshold.field',
defaultValue: defaultValues?.threshold?.field,
defaultValue: initialState.threshold.field,
},
thresholdValue: {
path: 'threshold.value',
defaultValue: defaultValues?.threshold?.value,
defaultValue: initialState.threshold.value,
},
}}
>

View file

@ -14,9 +14,7 @@ import {
} from '@elastic/eui';
import { findIndex } from 'lodash/fp';
import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react';
import deepEqual from 'fast-deep-equal';
import { setFieldValue } from '../../../pages/detection_engine/rules/helpers';
import {
RuleStep,
RuleStepProps,
@ -71,7 +69,8 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
setForm,
actionMessageParams,
}) => {
const [myStepData, setMyStepData] = useState<ActionsStepRule>(stepActionsDefaultValue);
const initialState = defaultValues ?? stepActionsDefaultValue;
const [myStepData, setMyStepData] = useState<ActionsStepRule>(initialState);
const {
services: {
application,
@ -81,10 +80,11 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]);
const { form } = useForm({
defaultValue: myStepData,
defaultValue: initialState,
options: { stripEmptyFields: false },
schema,
});
const { submit } = form;
// TO DO need to make sure that logic is still valid
const kibanaAbsoluteUrl = useMemo(() => {
@ -101,36 +101,21 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
async (enabled: boolean) => {
if (setStepData) {
setStepData(RuleStep.ruleActions, null, false);
const { isValid: newIsValid, data } = await form.submit();
const { isValid: newIsValid, data } = await submit();
if (newIsValid) {
setStepData(RuleStep.ruleActions, { ...data, enabled }, newIsValid);
setMyStepData({ ...data, isNew: false } as ActionsStepRule);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[form]
[setStepData, submit]
);
useEffect(() => {
const { isNew, ...initDefaultValue } = myStepData;
if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) {
const myDefaultValues = {
...defaultValues,
isNew: false,
};
setMyStepData(myDefaultValues);
setFieldValue(form, schema, myDefaultValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValues]);
useEffect(() => {
if (setForm != null) {
if (setForm) {
setForm(RuleStep.ruleActions, form);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
}, [form, setForm]);
const updateThrottle = useCallback((throttle) => setMyStepData({ ...myStepData, throttle }), [
myStepData,

View file

@ -5,9 +5,7 @@
*/
import React, { FC, memo, useCallback, useEffect, useState } from 'react';
import deepEqual from 'fast-deep-equal';
import { setFieldValue } from '../../../pages/detection_engine/rules/helpers';
import {
RuleStep,
RuleStepProps,
@ -40,45 +38,32 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
setStepData,
setForm,
}) => {
const [myStepData, setMyStepData] = useState<ScheduleStepRule>(stepScheduleDefaultValue);
const initialState = defaultValues ?? stepScheduleDefaultValue;
const [myStepData, setMyStepData] = useState<ScheduleStepRule>(initialState);
const { form } = useForm({
defaultValue: myStepData,
defaultValue: initialState,
options: { stripEmptyFields: false },
schema,
});
const { submit } = form;
const onSubmit = useCallback(async () => {
if (setStepData) {
setStepData(RuleStep.scheduleRule, null, false);
const { isValid: newIsValid, data } = await form.submit();
const { isValid: newIsValid, data } = await submit();
if (newIsValid) {
setStepData(RuleStep.scheduleRule, { ...data }, newIsValid);
setMyStepData({ ...data, isNew: false } as ScheduleStepRule);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
}, [setStepData, submit]);
useEffect(() => {
const { isNew, ...initDefaultValue } = myStepData;
if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) {
const myDefaultValues = {
...defaultValues,
isNew: false,
};
setMyStepData(myDefaultValues);
setFieldValue(form, schema, myDefaultValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValues]);
useEffect(() => {
if (setForm != null) {
if (setForm) {
setForm(RuleStep.scheduleRule, form);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
}, [form, setForm]);
return isReadOnlyView && myStepData != null ? (
<StepContentWrapper addPadding={addPadding}>

View file

@ -109,10 +109,10 @@ const CreateRulePageComponent: React.FC = () => {
[RuleStep.ruleActions]: null,
});
const stepsData = useRef<Record<RuleStep, RuleStepData>>({
[RuleStep.defineRule]: { isValid: false, data: {} },
[RuleStep.aboutRule]: { isValid: false, data: {} },
[RuleStep.scheduleRule]: { isValid: false, data: {} },
[RuleStep.ruleActions]: { isValid: false, data: {} },
[RuleStep.defineRule]: { isValid: false, data: undefined },
[RuleStep.aboutRule]: { isValid: false, data: undefined },
[RuleStep.scheduleRule]: { isValid: false, data: undefined },
[RuleStep.ruleActions]: { isValid: false, data: undefined },
});
const [isStepRuleInReadOnlyView, setIsStepRuleInEditView] = useState<Record<RuleStep, boolean>>({
[RuleStep.defineRule]: false,
@ -123,7 +123,7 @@ const CreateRulePageComponent: React.FC = () => {
const [{ isLoading, isSaved }, setRule] = usePersistRule();
const actionMessageParams = useMemo(
() =>
getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule).ruleType),
getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule)?.ruleType),
// eslint-disable-next-line react-hooks/exhaustive-deps
[stepsData.current['define-rule'].data]
);
@ -335,9 +335,7 @@ const CreateRulePageComponent: React.FC = () => {
<EuiHorizontalRule margin="m" />
<StepDefineRule
addPadding={true}
defaultValues={
(stepsData.current[RuleStep.defineRule].data as DefineStepRule) ?? null
}
defaultValues={stepsData.current[RuleStep.defineRule].data as DefineStepRule}
isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.defineRule]}
isLoading={isLoading || loading}
setForm={setStepsForm}
@ -370,10 +368,8 @@ const CreateRulePageComponent: React.FC = () => {
<EuiHorizontalRule margin="m" />
<StepAboutRule
addPadding={true}
defaultValues={(stepsData.current[RuleStep.aboutRule].data as AboutStepRule) ?? null}
defineRuleData={
(stepsData.current[RuleStep.defineRule].data as DefineStepRule) ?? null
}
defaultValues={stepsData.current[RuleStep.aboutRule].data as AboutStepRule}
defineRuleData={stepsData.current[RuleStep.defineRule].data as DefineStepRule}
descriptionColumns="singleSplit"
isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.aboutRule]}
isLoading={isLoading || loading}
@ -406,9 +402,7 @@ const CreateRulePageComponent: React.FC = () => {
<EuiHorizontalRule margin="m" />
<StepScheduleRule
addPadding={true}
defaultValues={
(stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule) ?? null
}
defaultValues={stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule}
descriptionColumns="singleSplit"
isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.scheduleRule]}
isLoading={isLoading || loading}

View file

@ -5,7 +5,6 @@
*/
import dateMath from '@elastic/datemath';
import { get } from 'lodash/fp';
import moment from 'moment';
import memoizeOne from 'memoize-one';
import { useLocation } from 'react-router-dom';
@ -15,7 +14,6 @@ import { isMlRule } from '../../../../../common/machine_learning/helpers';
import { transformRuleToAlertAction } from '../../../../../common/detection_engine/transform_actions';
import { Filter } from '../../../../../../../../src/plugins/data/public';
import { Rule } from '../../../containers/detection_engine/rules';
import { FormData, FormHook, FormSchema } from '../../../../shared_imports';
import {
AboutStepRule,
AboutStepRuleDetails,
@ -273,17 +271,6 @@ export const getPrePackagedTimelineStatus = (
}
return 'unknown';
};
export const setFieldValue = (
form: FormHook<FormData>,
schema: FormSchema<FormData>,
defaultValues: unknown
) =>
Object.keys(schema).forEach((key) => {
const val = get(key, defaultValues);
if (val != null) {
form.setFieldValue(key, val);
}
});
export const redirectToDetections = (
isSignalIndexExists: boolean | null,