export function useForm()

in src/platform/plugins/shared/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts [35:703]


export function useForm<T extends FormData = FormData, I extends FormData = T>(
  formConfig?: FormConfig<T, I>
): UseFormReturn<T, I> {
  const {
    onSubmit,
    schema,
    serializer,
    deserializer,
    options,
    id = 'default',
    defaultValue,
  } = formConfig ?? {};

  // Strip out any "undefined" value and run the deserializer
  const initDefaultValue = useCallback(
    (_defaultValue?: Partial<T>, runDeserializer: boolean = true): I | undefined => {
      if (_defaultValue === undefined || Object.keys(_defaultValue).length === 0) {
        return undefined;
      }

      const filtered = stripOutUndefinedValues<T>(_defaultValue);

      return runDeserializer && deserializer
        ? stripOutUndefinedValues(deserializer(filtered))
        : (filtered as unknown as I);
    },
    [deserializer]
  );

  // We create this stable reference to be able to initialize our "defaultValueDeserialized" ref below
  // as we can't initialize useRef by calling a function (e.g. useRef(initDefaultValue()))
  const defaultValueInitialized = useMemo<I | undefined>(() => {
    return initDefaultValue(defaultValue);
  }, [defaultValue, initDefaultValue]);

  const {
    valueChangeDebounceTime,
    stripEmptyFields: doStripEmptyFields,
    stripUnsetFields,
  } = options ?? {};
  const formOptions = useMemo(
    () => ({
      stripEmptyFields: doStripEmptyFields ?? DEFAULT_OPTIONS.stripEmptyFields,
      valueChangeDebounceTime: valueChangeDebounceTime ?? DEFAULT_OPTIONS.valueChangeDebounceTime,
      stripUnsetFields: stripUnsetFields ?? DEFAULT_OPTIONS.stripUnsetFields,
    }),
    [valueChangeDebounceTime, doStripEmptyFields, stripUnsetFields]
  );

  const [isSubmitted, setIsSubmitted] = useState(false);
  const [isSubmitting, setSubmitting] = useState(false);
  const [isValid, setIsValid] = useState<boolean | undefined>(undefined);
  const [errorMessages, setErrorMessages] = useState<{ [fieldName: string]: string }>({});

  /**
   * Map of all the fields currently in the form
   */
  const fieldsRefs = useRef<FieldsMap>({});
  /**
   * Keep a track of the fields that have been removed from the form.
   * This will allow us to know if the form has been modified
   * (this ref is then accessed in the "useFormIsModified()" hook)
   */
  const fieldsRemovedRefs = useRef<FieldsMap>({});
  /**
   * A list of all subscribers to form data and validity changes that
   * called "form.subscribe()"
   */
  const formUpdateSubscribers = useRef<Subscription[]>([]);
  const isMounted = useRef<boolean>(false);
  /**
   * Keep a reference to the form defaultValue once it has been deserialized.
   * This allows us to reset the form and put back the initial value of each fields
   */
  const defaultValueDeserialized = useRef(defaultValueInitialized);

  /**
   * We have both a state and a ref for the error messages so the consumer can, in the same callback,
   * validate the form **and** have the errors returned immediately.
   * Note: As an alternative we could return the errors when calling the "validate()" method but that creates
   * a breaking change in the API which would require to update many forms.
   *
   * ```
   * const myHandler = useCallback(async () => {
   *   const isFormValid = await validate();
   *   const errors = getErrors(); // errors from the validate() call are there
   * }, [validate, getErrors]);
   * ```
   */
  const errorMessagesRef = useRef<{ [fieldName: string]: string }>({});

  /**
   * formData$ is an observable that gets updated every time a field value changes.
   * It is the "useFormData()" hook that subscribes to this observable and updates
   * its internal "formData" state that in turn triggers the necessary re-renders in the consumer component.
   */
  const formData$ = useRef<Subject<FormData> | null>(null);

  // ----------------------------------
  // -- HELPERS
  // ----------------------------------
  /**
   * We can't initialize a React ref by calling a function (in this case
   * useRef(new Subject())) the function is called on every render and would
   * create a new "Subject" instance.
   * We use this handler to access the ref and initialize it on first access.
   */
  const getFormData$ = useCallback((): Subject<FormData> => {
    if (formData$.current === null) {
      formData$.current = new Subject<FormData>({});
    }
    return formData$.current;
  }, []);

  const updateFormData$ = useCallback(
    (nextValue: FormData) => {
      getFormData$().next(nextValue);
    },
    [getFormData$]
  );

  const updateFieldErrorMessage = useCallback((path: string, errorMessage: string | null) => {
    setErrorMessages((prev) => {
      const previousMessageValue = prev[path];

      if (
        errorMessage === previousMessageValue ||
        (previousMessageValue === undefined && errorMessage === null)
      ) {
        // Don't update the state, the error message has not changed.
        return prev;
      }

      if (errorMessage === null) {
        // The field at this path is now valid, we strip out any previous error message
        const { [path]: discard, ...next } = prev;
        errorMessagesRef.current = next;
        return next;
      }

      const next = {
        ...prev,
        [path]: errorMessage,
      };
      errorMessagesRef.current = next;
      return next;
    });
  }, []);

  const fieldsToArray = useCallback<() => FieldHook[]>(() => Object.values(fieldsRefs.current), []);

  const getFieldDefaultValue: FormHook<T, I>['getFieldDefaultValue'] = useCallback(
    (fieldName) => get(defaultValueDeserialized.current ?? {}, fieldName),
    []
  );

  const getFieldsForOutput = useCallback(
    (
      fields: FieldsMap,
      opts: { stripEmptyFields: boolean; stripUnsetFields: boolean }
    ): FieldsMap => {
      return Object.entries(fields).reduce((acc, [key, field]) => {
        if (!field.__isIncludedInOutput) {
          return acc;
        }

        if (opts.stripEmptyFields) {
          const isFieldEmpty = typeof field.value === 'string' && field.value.trim() === '';
          if (isFieldEmpty) {
            return acc;
          }
        }

        if (opts.stripUnsetFields) {
          if (!field.isDirty && getFieldDefaultValue(field.path) === undefined) {
            return acc;
          }
        }

        acc[key] = field;
        return acc;
      }, {} as FieldsMap);
    },
    [getFieldDefaultValue]
  );

  const updateFormDataAt: FormHook<T, I>['__updateFormDataAt'] = useCallback(
    (path, value) => {
      const currentFormData = getFormData$().value;

      if (currentFormData[path] !== value) {
        updateFormData$({ ...currentFormData, [path]: value });
      }
    },
    [getFormData$, updateFormData$]
  );

  const updateDefaultValueAt: FormHook<T, I>['__updateDefaultValueAt'] = useCallback(
    (path, value) => {
      if (defaultValueDeserialized.current === undefined) {
        defaultValueDeserialized.current = {} as I;
      }

      // We allow "undefined" to be passed to be able to remove a value from the form `defaultValue` object.
      // When <UseField path="foo" defaultValue="bar" /> mounts it calls `updateDefaultValueAt("foo", "bar")` to
      // update the form "defaultValue" object. When that component unmounts we want to be able to clean up and
      // remove its defaultValue on the form.
      if (value === undefined) {
        const updated = flattenObject(defaultValueDeserialized.current!);
        delete updated[path];
        defaultValueDeserialized.current = unflattenObject<I>(updated);
      } else {
        set(defaultValueDeserialized.current!, path, value);
      }
    },
    []
  );

  const isFieldValid = (field: FieldHook) => field.isValid && !field.isValidating;

  const waitForFieldsToFinishValidating = useCallback(async () => {
    let areSomeFieldValidating = fieldsToArray().some((field) => field.isValidating);

    return new Promise<void>((resolve) => {
      if (areSomeFieldValidating) {
        setTimeout(() => {
          areSomeFieldValidating = fieldsToArray().some((field) => field.isValidating);
          if (areSomeFieldValidating) {
            // Recursively wait for all the fields to finish validating.
            return waitForFieldsToFinishValidating().then(resolve);
          }
          resolve();
        }, 100);
      } else {
        /*
         * We need to use "setTimeout()" to ensure that the "validate()" method
         * returns a Promise that is resolved on the next tick. This is important
         * because the "validate()" method is often called in a "useEffect()" hook
         * and we want the "useEffect()" to be triggered on the next tick. If we
         * don't use "setTimeout()" the "useEffect()" would be triggered on the same
         * tick and would not have access to the latest form state.
         * This is also why we don't use "Promise.resolve()" here. It would resolve
         * the Promise on the same tick.
         */
        setTimeout(resolve, 0);
      }
    });
  }, [fieldsToArray]);

  // ----------------------------------
  // -- Internal API
  // ----------------------------------
  const addField: FormHook<T, I>['__addField'] = useCallback(
    (field) => {
      const fieldPreviouslyAdded = fieldsRefs.current[field.path] !== undefined;
      fieldsRefs.current[field.path] = field;
      delete fieldsRemovedRefs.current[field.path];

      updateFormDataAt(field.path, field.value);
      updateFieldErrorMessage(field.path, field.getErrorsMessages());

      if (!fieldPreviouslyAdded && !field.isValidated) {
        setIsValid(undefined);

        // When we submit() the form we set the "isSubmitted" state to "true" and all fields are marked as "isValidated: true".
        // If a **new** field is added and and its "isValidated" is "false" it means that we have swapped fields and added new ones:
        // --> we have a new form in front of us with different set of fields. We need to reset the "isSubmitted" state.
        // (e.g. In the mappings editor when the user switches the field "type" it brings a whole new set of settings)
        setIsSubmitted(false);
      }
    },
    [updateFormDataAt, updateFieldErrorMessage]
  );

  const removeField: FormHook<T, I>['__removeField'] = useCallback(
    (_fieldNames) => {
      const fieldNames = Array.isArray(_fieldNames) ? _fieldNames : [_fieldNames];
      const updatedFormData = { ...getFormData$().value };

      fieldNames.forEach((name) => {
        fieldsRemovedRefs.current[name] = fieldsRefs.current[name];
        updateFieldErrorMessage(name, null);
        delete fieldsRefs.current[name];
        delete updatedFormData[name];
      });

      updateFormData$(updatedFormData);

      /**
       * After removing a field, the form validity might have changed
       * (an invalid field might have been removed and now the form is valid)
       */
      setIsValid((prev) => {
        if (prev === false) {
          const isFormValid = fieldsToArray().every(isFieldValid);
          return isFormValid;
        }
        // If the form validity is "true" or "undefined", it remains the same after removing a field
        return prev;
      });
    },
    [getFormData$, updateFormData$, fieldsToArray, updateFieldErrorMessage]
  );

  const getFormDefaultValue: FormHook<T, I>['__getFormDefaultValue'] = useCallback(
    () => defaultValueDeserialized.current,
    []
  );

  const readFieldConfigFromSchema: FormHook<T, I>['__readFieldConfigFromSchema'] = useCallback(
    (fieldName) => {
      const config = get(schema ?? {}, fieldName);

      return config;
    },
    [schema]
  );

  const getFieldsRemoved: FormHook<T, I>['getFields'] = useCallback(
    () => fieldsRemovedRefs.current,
    []
  );

  // ----------------------------------
  // -- Public API
  // ----------------------------------
  const validateFields: FormHook<T, I>['validateFields'] = useCallback(
    async (fieldNames, onlyBlocking = false) => {
      const fieldsToValidate = fieldNames
        .map((name) => fieldsRefs.current[name])
        .filter((field) => field !== undefined);

      const formData = getFormData$().value;
      const validationResult = await Promise.all(
        fieldsToValidate.map((field) => field.validate({ formData, onlyBlocking }))
      );

      if (isMounted.current === false) {
        // If the form has unmounted while validating, the result is not pertinent
        // anymore. Let's satisfy TS and exit.
        return { areFieldsValid: true, isFormValid: true };
      }

      const areFieldsValid = validationResult.every((res) => res.isValid);

      const validationResultByPath = fieldsToValidate.reduce((acc, field, i) => {
        acc[field.path] = validationResult[i].isValid;
        return acc;
      }, {} as { [fieldPath: string]: boolean });

      // At this stage we have an updated field validation state inside the "validationResultByPath" object.
      // The fields object in "fieldsRefs.current" have not been updated yet with their new validation state
      // (isValid, isValidated...) as this occurs later, when the "useEffect" kicks in and calls "addField()" on the form.
      // This means that we have **stale state value** in our fieldsRefs map.
      // To know the current form validity, we will then merge the "validationResult" with the fieldsRefs object state.
      const formFieldsValidity = fieldsToArray().map((field) => {
        const hasUpdatedValidity = validationResultByPath[field.path] !== undefined;

        return {
          isValid: validationResultByPath[field.path] ?? field.isValid,
          isValidated: hasUpdatedValidity ? true : field.isValidated,
          isValidating: hasUpdatedValidity ? false : field.isValidating,
        };
      });

      const areAllFieldsValidated = formFieldsValidity.every((field) => field.isValidated);
      const areSomeFieldValidating = formFieldsValidity.some((field) => field.isValidating);

      // If *not* all the fields have been validated, the validity of the form is unknown, thus still "undefined"
      const isFormValid =
        areAllFieldsValidated && areSomeFieldValidating === false
          ? formFieldsValidity.every((field) => field.isValid)
          : undefined;

      setIsValid(isFormValid);

      return { areFieldsValid, isFormValid };
    },
    [getFormData$, fieldsToArray]
  );

  const getFormData: FormHook<T, I>['getFormData'] = useCallback(() => {
    const fieldsToOutput = getFieldsForOutput(fieldsRefs.current, {
      stripEmptyFields: formOptions.stripEmptyFields,
      stripUnsetFields: formOptions.stripUnsetFields,
    });
    const fieldsValue = mapFormFields(fieldsToOutput, (field) => field.__serializeValue());
    return serializer
      ? serializer(unflattenObject<I>(fieldsValue))
      : unflattenObject<T>(fieldsValue);
  }, [getFieldsForOutput, formOptions.stripEmptyFields, formOptions.stripUnsetFields, serializer]);

  const getErrors: FormHook<T, I>['getErrors'] = useCallback(() => {
    if (isValid === true) {
      return [];
    }
    return Object.values({ ...errorMessages, ...errorMessagesRef.current });
  }, [isValid, errorMessages]);

  const validate: FormHook<T, I>['validate'] = useCallback(async (): Promise<boolean> => {
    // Maybe some field are being validated because of their async validation(s).
    // We make sure those validations have finished executing before proceeding.
    await waitForFieldsToFinishValidating();

    if (!isMounted.current) {
      return false;
    }

    const fieldsArray = fieldsToArray();
    // We only need to validate the fields that haven't been validated yet. Those
    // are pristine fields (dirty fields are always validated when their value changed)
    const fieldsToValidate = fieldsArray.filter((field) => !field.isValidated);

    let isFormValid: boolean | undefined;

    if (fieldsToValidate.length === 0) {
      isFormValid = fieldsArray.every(isFieldValid);
    } else {
      const fieldPathsToValidate = fieldsToValidate.map((field) => field.path);
      const validateOnlyBlockingValidation = true;
      ({ isFormValid } = await validateFields(
        fieldPathsToValidate,
        validateOnlyBlockingValidation
      ));
    }

    setIsValid(isFormValid);
    return isFormValid!;
  }, [fieldsToArray, validateFields, waitForFieldsToFinishValidating]);

  const setFieldValue: FormHook<T, I>['setFieldValue'] = useCallback((fieldName, value) => {
    if (fieldsRefs.current[fieldName]) {
      fieldsRefs.current[fieldName].setValue(value);
    }
  }, []);

  const setFieldErrors: FormHook<T, I>['setFieldErrors'] = useCallback((fieldName, errors) => {
    if (fieldsRefs.current[fieldName]) {
      fieldsRefs.current[fieldName].setErrors(errors);
    }
  }, []);

  const getFields: FormHook<T, I>['getFields'] = useCallback(() => fieldsRefs.current, []);

  const updateFieldValues: FormHook<T, I>['updateFieldValues'] = useCallback(
    (updatedFormData, { runDeserializer = true } = {}) => {
      if (
        !updatedFormData ||
        typeof updatedFormData !== 'object' ||
        Object.keys(updatedFormData).length === 0
      ) {
        return;
      }

      const updatedFormDataInitialized = initDefaultValue(updatedFormData, runDeserializer);

      const mergedDefaultValue = mergeWith(
        {},
        defaultValueDeserialized.current,
        updatedFormDataInitialized,
        (_, srcValue) => {
          if (Array.isArray(srcValue)) {
            // Arrays are returned as provided, we don't want to merge
            // previous array values with the new ones.
            return srcValue;
          }
        }
      );

      defaultValueDeserialized.current = stripOutUndefinedValues<I>(mergedDefaultValue);

      const doUpdateValues = (obj: object, currentObjPath: string[] = []) => {
        Object.entries(obj).forEach(([key, value]) => {
          const fullPath = [...currentObjPath, key].join('.');
          const internalArrayfieldPath = getInternalArrayFieldPath(fullPath);

          // Check if there is an **internal array** (created by <UseArray />) defined at this key.
          // If there is one, we update that field value and don't go any further as from there it will
          // be the individual fields (children) declared inside the UseArray that will read the "defaultValue"
          // object of the form (which we've updated above).
          if (Array.isArray(value) && fieldsRefs.current[internalArrayfieldPath]) {
            const field = fieldsRefs.current[internalArrayfieldPath];
            const fieldValue = value.map((_, index) => createArrayItem(fullPath, index, false));
            field.setValue(fieldValue);
            return;
          }

          if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
            // We make sure that at least _some_ leaf fields are present in the fieldsRefs object
            // If not, we should not consider this as a multi fields but single field (e.g. a select field whose value is { label: 'Foo', value: 'foo' })
            const hasSomeLeafField = Object.keys(value).some(
              (leaf) => fieldsRefs.current[`${fullPath}.${leaf}`] !== undefined
            );

            if (hasSomeLeafField) {
              // Recursively update internal objects
              doUpdateValues(value, [...currentObjPath, key]);
              return;
            }
          }

          const field = fieldsRefs.current[fullPath];
          if (!field) {
            return;
          }

          field.setValue(value);
        });
      };

      doUpdateValues(updatedFormDataInitialized!);
    },
    [initDefaultValue]
  );

  const submit: FormHook<T, I>['submit'] = useCallback(
    async (e) => {
      if (e) {
        e.preventDefault();
      }

      setIsSubmitted(true); // User has attempted to submit the form at least once
      setSubmitting(true);

      const isFormValid = await validate();
      const formData = isFormValid ? getFormData() : ({} as T);

      if (onSubmit) {
        await onSubmit(formData, isFormValid!);
      }

      if (isMounted.current) {
        setSubmitting(false);
      }

      return { data: formData, isValid: isFormValid! };
    },
    [validate, getFormData, onSubmit]
  );

  const subscribe: FormHook<T, I>['subscribe'] = useCallback(
    (handler) => {
      const subscription = getFormData$().subscribe((raw) => {
        handler({
          isValid,
          data: { internal: unflattenObject<I>(raw), format: getFormData },
          validate,
        });
      });

      formUpdateSubscribers.current.push(subscription);

      return {
        unsubscribe() {
          formUpdateSubscribers.current = formUpdateSubscribers.current.filter(
            (sub) => sub !== subscription
          );
          return subscription.unsubscribe();
        },
      };
    },
    [getFormData$, isValid, getFormData, validate]
  );

  const reset: FormHook<T, I>['reset'] = useCallback(
    (resetOptions = { resetValues: true }) => {
      const { resetValues = true, defaultValue: updatedDefaultValue } = resetOptions;
      const currentFormData = { ...getFormData$().value };

      if (updatedDefaultValue) {
        defaultValueDeserialized.current = initDefaultValue(updatedDefaultValue);
      }

      Object.entries(fieldsRefs.current).forEach(([path, field]) => {
        // By resetting the form and changing field values, some fields might be unmounted
        // (e.g. a toggle might be set back to "false" and some fields removed from the UI as a consequence).
        // We make sure that the field still exists before resetting it.
        const isFieldMounted = fieldsRefs.current[path] !== undefined;
        if (isFieldMounted) {
          const fieldDefaultValue = getFieldDefaultValue(path);
          field.reset({ resetValue: resetValues, defaultValue: fieldDefaultValue });
          currentFormData[path] = fieldDefaultValue;
        }
      });

      if (resetValues) {
        updateFormData$(currentFormData);
      }

      setIsSubmitted(false);
      setSubmitting(false);
      setIsValid(undefined);
    },
    [getFormData$, updateFormData$, initDefaultValue, getFieldDefaultValue]
  );

  const form = useMemo<FormHook<T, I>>(() => {
    return {
      isSubmitted,
      isSubmitting,
      isValid,
      id,
      submit,
      validate,
      subscribe,
      setFieldValue,
      setFieldErrors,
      getFields,
      getFieldDefaultValue,
      getFormData,
      getErrors,
      updateFieldValues,
      reset,
      validateFields,
      __options: formOptions,
      __getFormData$: getFormData$,
      __updateFormDataAt: updateFormDataAt,
      __updateDefaultValueAt: updateDefaultValueAt,
      __readFieldConfigFromSchema: readFieldConfigFromSchema,
      __getFormDefaultValue: getFormDefaultValue,
      __addField: addField,
      __removeField: removeField,
      __getFieldsRemoved: getFieldsRemoved,
    };
  }, [
    isSubmitted,
    isSubmitting,
    isValid,
    id,
    submit,
    subscribe,
    setFieldValue,
    setFieldErrors,
    getFields,
    getFieldsRemoved,
    getFormData,
    getErrors,
    getFormDefaultValue,
    getFieldDefaultValue,
    updateFieldValues,
    reset,
    formOptions,
    getFormData$,
    updateFormDataAt,
    updateDefaultValueAt,
    readFieldConfigFromSchema,
    addField,
    removeField,
    validateFields,
    validate,
  ]);

  // ----------------------------------
  // -- EFFECTS
  // ----------------------------------
  useEffect(() => {
    isMounted.current = true;

    return () => {
      isMounted.current = false;
      formUpdateSubscribers.current.forEach((subscription) => subscription.unsubscribe());
      formUpdateSubscribers.current = [];
    };
  }, []);

  return {
    form,
  };
}