export function useForm()

in src/plugins/opensearch_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts [49:516]


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 ?? {};

  const initDefaultValue = useCallback(
    (_defaultValue?: Partial<T>): { [key: string]: any } => {
      if (_defaultValue === undefined || Object.keys(_defaultValue).length === 0) {
        return {};
      }

      const filtered = Object.entries(_defaultValue as object)
        .filter(({ 1: value }) => value !== undefined)
        .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {} as T);

      return deserializer ? deserializer(filtered) : filtered;
    },
    [deserializer]
  );

  const defaultValueMemoized = useMemo<{ [key: string]: any }>(() => {
    return initDefaultValue(defaultValue);
  }, [defaultValue, initDefaultValue]);

  const defaultValueDeserialized = useRef(defaultValueMemoized);

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

  const [isSubmitted, setIsSubmitted] = useState(false);
  const [isSubmitting, setSubmitting] = useState(false);
  const [isValid, setIsValid] = useState<boolean | undefined>(undefined);
  const fieldsRefs = useRef<FieldsMap>({});
  const formUpdateSubscribers = useRef<Subscription[]>([]);
  const isMounted = useRef<boolean>(false);

  // formData$ is an observable we can subscribe to in order to receive live
  // update of the raw form data. As an observable it does not trigger any React
  // render().
  // The <FormDataProvider> component is the one in charge of reading this observable
  // and updating its state to trigger the necessary view render.
  const formData$ = useRef<Subject<T> | null>(null);

  // -- HELPERS
  // ----------------------------------
  const getFormData$ = useCallback((): Subject<T> => {
    if (formData$.current === null) {
      formData$.current = new Subject<T>({} as T);
    }
    return formData$.current;
  }, []);

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

  const getFieldsForOutput = useCallback(
    (fields: FieldsMap, opts: { stripEmptyFields: 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;
          }
        }

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

  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$]
  );

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

  // -- API
  // ----------------------------------
  const getFormData: FormHook<T>['getFormData'] = useCallback(
    (getDataOptions: Parameters<FormHook<T>['getFormData']>[0] = { unflatten: true }) => {
      if (getDataOptions.unflatten) {
        const fieldsToOutput = getFieldsForOutput(fieldsRefs.current, {
          stripEmptyFields: formOptions.stripEmptyFields,
        });
        const fieldsValue = mapFormFields(fieldsToOutput, (field) => field.__serializeValue());
        return serializer
          ? (serializer(unflattenObject(fieldsValue) as I) as T)
          : (unflattenObject(fieldsValue) as T);
      }

      return Object.entries(fieldsRefs.current).reduce(
        (acc, [key, field]) => ({
          ...acc,
          [key]: field.value,
        }),
        {} as T
      );
    },
    [getFieldsForOutput, formOptions.stripEmptyFields, serializer]
  );

  const getErrors: FormHook['getErrors'] = useCallback(() => {
    if (isValid === true) {
      return [];
    }

    return fieldsToArray().reduce((acc, field) => {
      const fieldError = field.getErrorsMessages();
      if (fieldError === null) {
        return acc;
      }
      return [...acc, fieldError];
    }, [] as string[]);
  }, [isValid, fieldsToArray]);

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

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

    return new Promise((resolve) => {
      setTimeout(() => {
        areSomeFieldValidating = fieldsToArray().some((field) => field.isValidating);
        if (areSomeFieldValidating) {
          // Recursively wait for all the fields to finish validating.
          return waitForFieldsToFinishValidating().then(resolve);
        }
        resolve();
      }, 100);
    });
  }, [fieldsToArray]);

  const validateFields: FormHook<T>['__validateFields'] = useCallback(
    async (fieldNames) => {
      const fieldsToValidate = fieldNames
        .map((name) => fieldsRefs.current[name])
        .filter((field) => field !== undefined);

      const formData = getFormData({ unflatten: false });
      const validationResult = await Promise.all(
        fieldsToValidate.map((field) => field.validate({ formData }))
      );

      if (isMounted.current === false) {
        return { areFieldsValid: true, isFormValid: true };
      }

      const areFieldsValid = validationResult.every(Boolean);

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

      // At this stage we have an updated field validation state inside the "validationResultByPath" object.
      // The fields we have in our "fieldsRefs.current" have not been updated yet with the new validation state
      // (isValid, isValidated...) as this will happen _after_, when the "useEffect" triggers and calls "addField()".
      // This means that we have **stale state value** in our fieldsRefs.
      // To know the current form validity, we will then merge the "validationResult" _with_ the fieldsRefs object state,
      // the "validationResult" taking presedence over the fieldsRefs values.
      const formFieldsValidity = fieldsToArray().map((field) => {
        const hasUpdatedValidity = validationResultByPath[field.path] !== undefined;
        const _isValid = validationResultByPath[field.path] ?? field.isValid;
        const _isValidated = hasUpdatedValidity ? true : field.isValidated;
        const _isValidating = hasUpdatedValidity ? false : field.isValidating;
        return {
          isValid: _isValid,
          isValidated: _isValidated,
          isValidating: _isValidating,
        };
      });

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

      // If *not* all the fiels 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 validateAllFields = 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();
    const fieldsToValidate = fieldsArray.filter((field) => !field.isValidated);

    let isFormValid: boolean | undefined;

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

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

  const addField: FormHook<T>['__addField'] = useCallback(
    (field) => {
      fieldsRefs.current[field.path] = field;

      updateFormDataAt(field.path, field.value);

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

        // When we submit the form (and set "isSubmitted" to "true"), we validate **all fields**.
        // If a field is added and it is not validated it means that we have swapped fields and added new ones:
        // --> we have basically have a new form in front of us.
        // For that reason we make sure that the "isSubmitted" state is false.
        setIsSubmitted(false);
      }
    },
    [updateFormDataAt]
  );

  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];
      });

      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)
       */
      setIsValid((prev) => {
        if (prev === false) {
          const isFormValid = fieldsToArray().every(isFieldValid);
          return isFormValid;
        }
        // If the form validity is "true" or "undefined", it does not change after removing a field
        return prev;
      });
    },
    [getFormData$, fieldsToArray]
  );

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

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

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

  const getFieldDefaultValue: FormHook['__getFieldDefaultValue'] = useCallback(
    (fieldName) => get(defaultValueDeserialized.current, fieldName),
    []
  );

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

      return config;
    },
    [schema]
  );

  const submitForm: FormHook<T>['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 validateAllFields();
      const formData = isFormValid ? getFormData() : ({} as T);

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

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

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

  const subscribe: FormHook<T>['subscribe'] = useCallback(
    (handler) => {
      const subscription = getFormData$().subscribe((raw) => {
        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'] = useCallback(
    (resetOptions = { resetValues: true }) => {
      const { resetValues = true, defaultValue: updatedDefaultValue } = resetOptions;
      const currentFormData = { ...getFormData$().value } as FormData;

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

      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 fieldDefaultValue = getFieldDefaultValue(path);
          field.reset({ resetValue: resetValues, defaultValue: fieldDefaultValue });
          currentFormData[path] = fieldDefaultValue;
        }
      });
      if (resetValues) {
        getFormData$().next(currentFormData as T);
      }

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

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

  useEffect(() => {
    if (!isMounted.current) {
      return;
    }

    // Whenever the "defaultValue" prop changes, reinitialize our ref
    defaultValueDeserialized.current = defaultValueMemoized;
  }, [defaultValueMemoized]);

  useEffect(() => {
    isMounted.current = true;

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

  return {
    form,
  };
}