pankod/refine

View on GitHub
packages/core/src/hooks/form/index.ts

Summary

Maintainability
D
2 days
Test Coverage
A
100%
import React from "react";
import warnOnce from "warn-once";

import {
  useMeta,
  useOne,
  useCreate,
  useUpdate,
  useResourceParams,
  useInvalidate,
  useMutationMode,
  useRefineOptions,
  useLoadingOvertime,
  useWarnAboutChange,
  useRedirectionAfterSubmission,
} from "@hooks";

import {
  redirectPage,
  asyncDebounce,
  deferExecution,
  pickNotDeprecated,
} from "@definitions/helpers";

import type { UpdateParams } from "../data/useUpdate";
import type { UseCreateParams } from "../data/useCreate";
import type { UseFormProps, UseFormReturnType } from "./types";
import {
  BaseKey,
  BaseRecord,
  CreateResponse,
  HttpError,
  UpdateResponse,
} from "../../contexts/data/types";

export type {
  ActionParams,
  UseFormProps,
  UseFormReturnType,
  AutoSaveIndicatorElements,
  AutoSaveProps,
  AutoSaveReturnType,
  FormAction,
  RedirectAction,
  RedirectionTypes,
  FormWithSyncWithLocationParams,
} from "./types";

/**
 * This hook orchestrates Refine's data hooks to create, edit, and clone data. It also provides a set of features to make it easier for users to implement their real world needs and handle edge cases such as redirects, invalidation, auto-save and more.
 *
 * @see {@link https://refine.dev/docs/data/hooks/use-form} for more details.
 *
 * @typeParam TQueryFnData - Result data returned by the query function. Extends {@link https://refine.dev/docs/core/interface-references/#baserecord `BaseRecord`}
 * @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/core/interface-references/#httperror `HttpError`}
 * @typeParam TVariables - Values for params. default `{}`
 * @typeParam TData - Result data returned by the `select` function. Extends {@link https://refine.dev/docs/core/interface-references/#baserecord `BaseRecord`}. Defaults to `TQueryFnData`
 * @typeParam TResponse - Result data returned by the mutation function. Extends {@link https://refine.dev/docs/core/interface-references/#baserecord `BaseRecord`}. Defaults to `TData`
 * @typeParam TResponseError - Custom error object that extends {@link https://refine.dev/docs/core/interface-references/#httperror `HttpError`}. Defaults to `TError`
 *
 */
export const useForm = <
  TQueryFnData extends BaseRecord = BaseRecord,
  TError extends HttpError = HttpError,
  TVariables = {},
  TData extends BaseRecord = TQueryFnData,
  TResponse extends BaseRecord = TData,
  TResponseError extends HttpError = TError,
>(
  props: UseFormProps<
    TQueryFnData,
    TError,
    TVariables,
    TData,
    TResponse,
    TResponseError
  > = {},
): UseFormReturnType<
  TQueryFnData,
  TError,
  TVariables,
  TData,
  TResponse,
  TResponseError
> => {
  const getMeta = useMeta();
  const invalidate = useInvalidate();
  const { redirect: defaultRedirect } = useRefineOptions();
  const { mutationMode: defaultMutationMode } = useMutationMode();

  const { setWarnWhen } = useWarnAboutChange();
  const handleSubmitWithRedirect = useRedirectionAfterSubmission();

  const pickedMeta = pickNotDeprecated(props.meta, props.metaData);
  const mutationMode = props.mutationMode ?? defaultMutationMode;

  const {
    id,
    setId,
    resource,
    identifier,
    formAction: action,
  } = useResourceParams({
    resource: props.resource,
    id: props.id,
    action: props.action,
  });

  const [autosaved, setAutosaved] = React.useState(false);

  const isEdit = action === "edit";
  const isClone = action === "clone";
  const isCreate = action === "create";

  const combinedMeta = getMeta({
    resource,
    meta: pickedMeta,
  });

  const isIdRequired = (isEdit || isClone) && Boolean(props.resource);
  const isIdDefined = typeof props.id !== "undefined";
  const isQueryDisabled = props.queryOptions?.enabled === false;

  /**
   * When a custom resource is provided through props, `id` will not be inferred from the URL to avoid any potential faulty requests.
   * In this case, `id` is required to be passed through props.
   * If `id` is not handled, a warning will be thrown in development mode.
   */
  warnOnce(
    isIdRequired && !isIdDefined && !isQueryDisabled,
    idWarningMessage(action, identifier, id),
  );

  /**
   * Target action to redirect after form submission.
   */
  const redirectAction = redirectPage({
    redirectFromProps: props.redirect,
    action,
    redirectOptions: defaultRedirect,
  });

  /**
   * Redirection function to be used in internal redirects and to be provided to the user.
   */
  const redirect: UseFormReturnType["redirect"] = (
    redirect = isEdit ? "list" : "edit",
    redirectId = id,
    routeParams = {},
  ) => {
    handleSubmitWithRedirect({
      redirect: redirect,
      resource,
      id: redirectId,
      meta: { ...pickedMeta, ...routeParams },
    });
  };

  const queryResult = useOne<TQueryFnData, TError, TData>({
    resource: identifier,
    id,
    queryOptions: {
      // Only enable the query if it's not a create action and the `id` is defined
      enabled: !isCreate && id !== undefined,
      ...props.queryOptions,
    },
    liveMode: props.liveMode,
    onLiveEvent: props.onLiveEvent,
    liveParams: props.liveParams,
    meta: { ...combinedMeta, ...props.queryMeta },
    dataProviderName: props.dataProviderName,
  });

  const createMutation = useCreate<TResponse, TResponseError, TVariables>({
    mutationOptions: props.createMutationOptions,
  });

  const updateMutation = useUpdate<TResponse, TResponseError, TVariables>({
    mutationOptions: props.updateMutationOptions,
  });

  const mutationResult = isEdit ? updateMutation : createMutation;
  const isMutationLoading = mutationResult.isLoading;
  const formLoading = isMutationLoading || queryResult.isFetching;

  const { elapsedTime } = useLoadingOvertime({
    isLoading: formLoading,
    interval: props.overtimeOptions?.interval,
    onInterval: props.overtimeOptions?.onInterval,
  });

  // biome-ignore lint/correctness/useExhaustiveDependencies: This is a controlled unmounting effect.
  React.useEffect(() => {
    // After `autosaved` is set to `true`, it won't be set to `false` again.
    // Therefore, the `invalidate` function will be called only once at the end of the hooks lifecycle.
    return () => {
      if (
        props.autoSave?.invalidateOnUnmount &&
        autosaved &&
        identifier &&
        typeof id !== "undefined"
      ) {
        invalidate({
          id,
          invalidates: props.invalidates || ["list", "many", "detail"],
          dataProviderName: props.dataProviderName,
          resource: identifier,
        });
      }
    };
  }, [props.autoSave?.invalidateOnUnmount, autosaved]);

  const onFinish = async (
    values: TVariables,
    { isAutosave = false }: { isAutosave?: boolean } = {},
  ) => {
    const isPessimistic = mutationMode === "pessimistic";

    // Disable warning trigger when the form is being submitted
    setWarnWhen(false);

    // Redirect after a successful form submission
    const onSuccessRedirect = (id?: BaseKey) => redirect(redirectAction, id);

    const submissionPromise = new Promise<
      // biome-ignore lint/suspicious/noConfusingVoidType: Void is an expected case for this promise.
      CreateResponse<TResponse> | UpdateResponse<TResponse> | void
    >((resolve, reject) => {
      // Reject the mutation if the resource is not defined
      if (!resource) return reject(missingResourceError);
      // Reject the mutation if the `id` is not defined in edit action
      // This line is commented out because the `id` might not be set for some cases and edit is done on a resource.
      // if (isEdit && !id) return reject(missingIdError);
      // Reject the mutation if the `id` is not defined in clone action
      if (isClone && !id) return reject(missingIdError);
      // Reject the mutation if there's no `values` passed
      if (!values) return reject(missingValuesError);
      // Auto Save is only allowed in edit action
      if (isAutosave && !isEdit) return reject(autosaveOnNonEditError);

      if (!isPessimistic && !isAutosave) {
        // If the mutation mode is not pessimistic, handle the redirect immediately in an async manner
        // `setWarnWhen` blocks the redirects until set to `false`
        // If redirect is done before the value is properly set, it will be blocked.
        // We're deferring the execution of the redirect to ensure that the value is set properly.
        deferExecution(() => onSuccessRedirect());
        // Resolve the promise immediately
        resolve();
      }

      const variables:
        | UpdateParams<TResponse, TResponseError, TVariables>
        | UseCreateParams<TResponse, TResponseError, TVariables> = {
        values,
        resource: identifier ?? resource.name,
        meta: { ...combinedMeta, ...props.mutationMeta },
        metaData: { ...combinedMeta, ...props.mutationMeta },
        dataProviderName: props.dataProviderName,
        invalidates: isAutosave ? [] : props.invalidates,
        successNotification: isAutosave ? false : props.successNotification,
        errorNotification: isAutosave ? false : props.errorNotification,
        // Update specific variables
        ...(isEdit
          ? {
              id: id ?? "",
              mutationMode,
              undoableTimeout: props.undoableTimeout,
              optimisticUpdateMap: props.optimisticUpdateMap,
            }
          : {}),
      };

      const { mutateAsync } = isEdit ? updateMutation : createMutation;

      /**
       * biome-ignore lint/suspicious/noExplicitAny: Validity of the `variables` is checked above.
       * For sake of having a single function call, we are using `any` here.
       * Appropriate variables will be constructed based on the `action` and auto-save status.
       * Then, the `mutateAsync` function will be called with the constructed variables.
       */
      mutateAsync(variables as any, {
        // Call user-defined `onMutationSuccess` and `onMutationError` callbacks if provided
        // These callbacks will not have an effect on the submission promise
        onSuccess: props.onMutationSuccess
          ? (data, _, context) => {
              props.onMutationSuccess?.(data, values, context, isAutosave);
            }
          : undefined,
        onError: props.onMutationError
          ? (error: TResponseError, _, context) => {
              props.onMutationError?.(error, values, context, isAutosave);
            }
          : undefined,
      })
        // If the mutation mode is pessimistic, resolve the promise after the mutation is succeeded
        .then((data) => {
          if (isPessimistic && !isAutosave) {
            deferExecution(() => onSuccessRedirect(data?.data?.id));
          }
          if (isAutosave) {
            setAutosaved(true);
          }
          resolve(data);
        })
        // If the mutation mode is pessimistic, reject the promise after the mutation is failed
        .catch(reject);
    });

    return submissionPromise;
  };

  const onFinishAutoSave = asyncDebounce(
    (values: TVariables) => onFinish(values, { isAutosave: true }),
    props.autoSave?.debounce || 1000,
    "Cancelled by debounce",
  );

  const overtime = {
    elapsedTime,
  };

  const autoSaveProps = {
    status: updateMutation.status,
    data: updateMutation.data,
    error: updateMutation.error,
  };

  return {
    onFinish,
    onFinishAutoSave,
    formLoading,
    mutationResult,
    queryResult,
    autoSaveProps,
    id,
    setId,
    redirect,
    overtime,
  };
};

const missingResourceError = new Error(
  "[useForm]: `resource` is not defined or not matched but is required",
);

const missingIdError = new Error(
  "[useForm]: `id` is not defined but is required in edit and clone actions",
);

const missingValuesError = new Error(
  "[useForm]: `values` is not provided but is required",
);

const autosaveOnNonEditError = new Error(
  "[useForm]: `autoSave` is only allowed in edit action",
);

const idWarningMessage = (action?: string, identifier?: string, id?: BaseKey) =>
  `[useForm]: action: "${action}", resource: "${identifier}", id: ${id}

If you don't use the \`setId\` method to set the \`id\`, you should pass the \`id\` prop to \`useForm\`. Otherwise, \`useForm\` will not be able to infer the \`id\` from the current URL with custom resource provided.

See https://refine.dev/docs/data/hooks/use-form/#id-`;