Skip to content

Commit

Permalink
[7.x] [Form lib] Export internal state instead of raw state (#80842) (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
sebelga authored Oct 20, 2020
1 parent 075efe4 commit 80d3349
Show file tree
Hide file tree
Showing 35 changed files with 429 additions and 357 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe('<FormDataProvider />', () => {
find('btn').simulate('click').update();
});

expect(onFormData.mock.calls.length).toBe(1);
expect(onFormData.mock.calls.length).toBe(2);

const [formDataUpdated] = onFormData.mock.calls[onFormData.mock.calls.length - 1] as Parameters<
OnUpdateHandler
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,23 @@ import React from 'react';
import { FormData } from '../types';
import { useFormData } from '../hooks';

interface Props {
children: (formData: FormData) => JSX.Element | null;
interface Props<I> {
children: (formData: I) => JSX.Element | null;
pathsToWatch?: string | string[];
}

export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) => {
const { 0: formData, 2: isReady } = useFormData({ watch: pathsToWatch });
const FormDataProviderComp = function <I extends FormData = FormData>({
children,
pathsToWatch,
}: Props<I>) {
const { 0: formData, 2: isReady } = useFormData<I>({ watch: pathsToWatch });

if (!isReady) {
// No field has mounted yet, don't render anything
return null;
}

return children(formData);
});
};

export const FormDataProvider = React.memo(FormDataProviderComp) as typeof FormDataProviderComp;
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const UseArray = ({
getNewItemAtIndex,
]);

// Create a new hook field with the "hasValue" set to false so we don't use its value to build the final form data.
// Create a new hook field with the "isIncludedInOutput" set to false so we don't use its value to build the final form data.
// Apart from that the field behaves like a normal field and is hooked into the form validation lifecycle.
const fieldConfigBase: FieldConfig<ArrayItem[]> & InternalFieldConfig<ArrayItem[]> = {
defaultValue: fieldDefaultValue,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('<UseField />', () => {
OnUpdateHandler
>;

expect(data.raw).toEqual({
expect(data.internal).toEqual({
name: 'John',
lastName: 'Snow',
});
Expand Down Expand Up @@ -214,8 +214,8 @@ describe('<UseField />', () => {
expect(serializer).not.toBeCalled();
expect(formatter).not.toBeCalled();

let formData = formHook.getFormData({ unflatten: false });
expect(formData.name).toEqual('John-deserialized');
const internalFormData = formHook.__getFormData$().value;
expect(internalFormData.name).toEqual('John-deserialized');

await act(async () => {
form.setInputValue('myField', 'Mike');
Expand All @@ -224,9 +224,9 @@ describe('<UseField />', () => {
expect(formatter).toBeCalled(); // Formatters are executed on each value change
expect(serializer).not.toBeCalled(); // Serializer are executed *only** when outputting the form data

formData = formHook.getFormData();
const outputtedFormData = formHook.getFormData();
expect(serializer).toBeCalled();
expect(formData.name).toEqual('MIKE-serialized');
expect(outputtedFormData.name).toEqual('MIKE-serialized');

// Make sure that when we reset the form values, we don't serialize the fields
serializer.mockReset();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import React, { createContext, useContext, useMemo } from 'react';
import { FormData, FormHook } from './types';
import { Subject } from './lib';

export interface Context<T extends FormData = FormData, I = T> {
getFormData$: () => Subject<I>;
getFormData: FormHook<T>['getFormData'];
export interface Context<T extends FormData = FormData, I extends FormData = T> {
getFormData$: () => Subject<FormData>;
getFormData: FormHook<T, I>['getFormData'];
}

const FormDataContext = createContext<Context<any> | undefined>(undefined);
Expand All @@ -45,6 +45,6 @@ export const FormDataContextProvider = ({ children, getFormData$, getFormData }:
return <FormDataContext.Provider value={value}>{children}</FormDataContext.Provider>;
};

export function useFormDataContext<T extends FormData = FormData>() {
return useContext<Context<T> | undefined>(FormDataContext);
export function useFormDataContext<T extends FormData = FormData, I extends FormData = T>() {
return useContext<Context<T, I> | undefined>(FormDataContext);
}
140 changes: 71 additions & 69 deletions src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const useField = <T, FormType = FormData, I = T>(
__removeField,
__updateFormDataAt,
__validateFields,
__getFormData$,
} = form;

const deserializeValue = useCallback(
Expand All @@ -76,7 +77,7 @@ export const useField = <T, FormType = FormData, I = T>(
);

const [value, setStateValue] = useState<I>(deserializeValue);
const [errors, setErrors] = useState<ValidationError[]>([]);
const [errors, setStateErrors] = useState<ValidationError[]>([]);
const [isPristine, setPristine] = useState(true);
const [isValidating, setValidating] = useState(false);
const [isChangingValue, setIsChangingValue] = useState(false);
Expand All @@ -86,18 +87,12 @@ export const useField = <T, FormType = FormData, I = T>(
const validateCounter = useRef(0);
const changeCounter = useRef(0);
const hasBeenReset = useRef<boolean>(false);
const inflightValidation = useRef<Promise<any> | null>(null);
const inflightValidation = useRef<(Promise<any> & { cancel?(): void }) | null>(null);
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);

// ----------------------------------
// -- HELPERS
// ----------------------------------
const serializeValue: FieldHook<T, I>['__serializeValue'] = useCallback(
(internalValue: I = value) => {
return serializer ? serializer(internalValue) : ((internalValue as unknown) as T);
},
[serializer, value]
);

/**
* Filter an array of errors with specific validation type on them
*
Expand All @@ -117,6 +112,11 @@ export const useField = <T, FormType = FormData, I = T>(
);
};

/**
* If the field has some "formatters" defined in its config, run them in series and return
* the transformed value. This handler is called whenever the field value changes, right before
* updating the "value" state.
*/
const formatInputValue = useCallback(
<T>(inputValue: unknown): T => {
const isEmptyString = typeof inputValue === 'string' && inputValue.trim() === '';
Expand All @@ -125,11 +125,11 @@ export const useField = <T, FormType = FormData, I = T>(
return inputValue as T;
}

const formData = getFormData({ unflatten: false });
const formData = __getFormData$().value;

return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as T;
},
[formatters, getFormData]
[formatters, __getFormData$]
);

const onValueChange = useCallback(async () => {
Expand All @@ -147,7 +147,7 @@ export const useField = <T, FormType = FormData, I = T>(
// Update the form data observable
__updateFormDataAt(path, value);

// Validate field(s) (that will update form.isValid state)
// Validate field(s) (this will update the form.isValid state)
await __validateFields(fieldsToValidateOnChange ?? [path]);

if (isMounted.current === false) {
Expand All @@ -162,15 +162,18 @@ export const useField = <T, FormType = FormData, I = T>(
*/
if (changeIteration === changeCounter.current) {
if (valueChangeDebounceTime > 0) {
const delta = Date.now() - startTime;
if (delta < valueChangeDebounceTime) {
const timeElapsed = Date.now() - startTime;

if (timeElapsed < valueChangeDebounceTime) {
const timeLeftToWait = valueChangeDebounceTime - timeElapsed;
debounceTimeout.current = setTimeout(() => {
debounceTimeout.current = null;
setIsChangingValue(false);
}, valueChangeDebounceTime - delta);
}, timeLeftToWait);
return;
}
}

setIsChangingValue(false);
}
}, [
Expand All @@ -183,41 +186,34 @@ export const useField = <T, FormType = FormData, I = T>(
__validateFields,
]);

// Cancel any inflight validation (e.g an HTTP Request)
const cancelInflightValidation = useCallback(() => {
// Cancel any inflight validation (like an HTTP Request)
if (
inflightValidation.current &&
typeof (inflightValidation.current as any).cancel === 'function'
) {
(inflightValidation.current as any).cancel();
if (inflightValidation.current && typeof inflightValidation.current.cancel === 'function') {
inflightValidation.current.cancel();
inflightValidation.current = null;
}
}, []);

const clearErrors: FieldHook['clearErrors'] = useCallback(
(validationType = VALIDATION_TYPES.FIELD) => {
setErrors((previousErrors) => filterErrors(previousErrors, validationType));
},
[]
);

const runValidations = useCallback(
({
formData,
value: valueToValidate,
validationTypeToValidate,
}: {
formData: any;
value: I;
validationTypeToValidate?: string;
}): ValidationError[] | Promise<ValidationError[]> => {
(
{
formData,
value: valueToValidate,
validationTypeToValidate,
}: {
formData: any;
value: I;
validationTypeToValidate?: string;
},
clearFieldErrors: FieldHook['clearErrors']
): ValidationError[] | Promise<ValidationError[]> => {
if (!validations) {
return [];
}

// 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]);
clearFieldErrors([VALIDATION_TYPES.FIELD, VALIDATION_TYPES.ASYNC]);

cancelInflightValidation();

Expand Down Expand Up @@ -329,21 +325,33 @@ export const useField = <T, FormType = FormData, I = T>(
// We first try to run the validations synchronously
return runSync();
},
[clearErrors, cancelInflightValidation, validations, getFormData, getFields, path]
[cancelInflightValidation, validations, getFormData, getFields, path]
);

// -- API
// ----------------------------------
// -- Internal API
// ----------------------------------
const serializeValue: FieldHook<T, I>['__serializeValue'] = useCallback(
(internalValue: I = value) => {
return serializer ? serializer(internalValue) : ((internalValue as unknown) as T);
},
[serializer, value]
);

// ----------------------------------
// -- Public API
// ----------------------------------
const clearErrors: FieldHook['clearErrors'] = useCallback(
(validationType = VALIDATION_TYPES.FIELD) => {
setStateErrors((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, I>['validate'] = useCallback(
(validationData = {}) => {
const {
formData = getFormData({ unflatten: false }),
formData = __getFormData$().value,
value: valueToValidate = value,
validationType,
} = validationData;
Expand All @@ -362,7 +370,7 @@ export const useField = <T, FormType = FormData, I = T>(
// This is the most recent invocation
setValidating(false);
// Update the errors array
setErrors((prev) => {
setStateErrors((prev) => {
const filteredErrors = filterErrors(prev, validationType);
return [...filteredErrors, ..._validationErrors];
});
Expand All @@ -374,25 +382,23 @@ export const useField = <T, FormType = FormData, I = T>(
};
};

const validationErrors = runValidations({
formData,
value: valueToValidate,
validationTypeToValidate: validationType,
});
const validationErrors = runValidations(
{
formData,
value: valueToValidate,
validationTypeToValidate: validationType,
},
clearErrors
);

if (Reflect.has(validationErrors, 'then')) {
return (validationErrors as Promise<ValidationError[]>).then(onValidationResult);
}
return onValidationResult(validationErrors as ValidationError[]);
},
[getFormData, value, runValidations]
[__getFormData$, value, runValidations, clearErrors]
);

/**
* Handler to change the field value
*
* @param newValue The new value to assign to the field
*/
const setValue: FieldHook<T, I>['setValue'] = useCallback(
(newValue) => {
setStateValue((prev) => {
Expand All @@ -408,8 +414,8 @@ export const useField = <T, FormType = FormData, I = T>(
[formatInputValue]
);

const _setErrors: FieldHook<T, I>['setErrors'] = useCallback((_errors) => {
setErrors(
const setErrors: FieldHook<T, I>['setErrors'] = useCallback((_errors) => {
setStateErrors(
_errors.map((error) => ({
validationType: VALIDATION_TYPES.FIELD,
__isBlocking__: true,
Expand All @@ -418,11 +424,6 @@ export const useField = <T, FormType = FormData, I = T>(
);
}, []);

/**
* Form <input /> "onChange" event handler
*
* @param event Form input change event
*/
const onChange: FieldHook<T, I>['onChange'] = useCallback(
(event) => {
const newValue = {}.hasOwnProperty.call(event!.target, 'checked')
Expand Down Expand Up @@ -485,7 +486,7 @@ export const useField = <T, FormType = FormData, I = T>(
case 'value':
return setValue(nextValue);
case 'errors':
return setErrors(nextValue);
return setStateErrors(nextValue);
case 'isChangingValue':
return setIsChangingValue(nextValue);
case 'isPristine':
Expand Down Expand Up @@ -539,7 +540,7 @@ export const useField = <T, FormType = FormData, I = T>(
onChange,
getErrorsMessages,
setValue,
setErrors: _setErrors,
setErrors,
clearErrors,
validate,
reset,
Expand All @@ -563,7 +564,7 @@ export const useField = <T, FormType = FormData, I = T>(
onChange,
getErrorsMessages,
setValue,
_setErrors,
setErrors,
clearErrors,
validate,
reset,
Expand All @@ -585,7 +586,8 @@ export const useField = <T, FormType = FormData, I = T>(

useEffect(() => {
// If the field value has been reset, we don't want to call the "onValueChange()"
// as it will set the "isPristine" state to true or validate the field, which initially we don't want.
// as it will set the "isPristine" state to true or validate the field, which we don't want
// to occur right after resetting the field state.
if (hasBeenReset.current) {
hasBeenReset.current = false;
return;
Expand Down
Loading

0 comments on commit 80d3349

Please sign in to comment.