Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Form lib] Export internal state instead of raw state #80842

Merged
merged 20 commits into from
Oct 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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