This commit is contained in:
Stefan Pejcic
2024-11-07 19:03:37 +01:00
parent c6df945ed5
commit 09f9f9502d
2472 changed files with 620417 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,357 @@
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 type {
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,
});
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<
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;
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,
mutation: mutationResult,
queryResult,
query: 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-`;

View File

@@ -0,0 +1,262 @@
import type { Dispatch, SetStateAction } from "react";
import type {
QueryObserverResult,
UseQueryOptions,
} from "@tanstack/react-query";
import type {
OptimisticUpdateMapType,
UseUpdateProps,
UseUpdateReturnType,
} from "../data/useUpdate";
import type { UseCreateProps, UseCreateReturnType } from "../data/useCreate";
import type {
UseLoadingOvertimeOptionsProps,
UseLoadingOvertimeReturnType,
} from "../useLoadingOvertime";
import type {
BaseKey,
BaseRecord,
CreateResponse,
GetOneResponse,
HttpError,
IQueryKeys,
MetaQuery,
MutationMode,
UpdateResponse,
} from "../../contexts/data/types";
import type { LiveModeProps } from "../../contexts/live/types";
import type { SuccessErrorNotification } from "../../contexts/notification/types";
import type { Action } from "../../contexts/router/types";
export type FormAction = Extract<Action, "create" | "edit" | "clone">;
export type RedirectAction =
| Extract<Action, "create" | "edit" | "list" | "show">
| false;
/**
* @deprecated use RedirectAction type instead
*/
export type RedirectionTypes = RedirectAction;
export type AutoSaveProps<TVariables> = {
autoSave?: {
enabled: boolean;
debounce?: number;
onFinish?: (values: TVariables) => TVariables;
invalidateOnUnmount?: boolean;
invalidateOnClose?: boolean;
};
};
export type AutoSaveReturnType<
TData extends BaseRecord = BaseRecord,
TError extends HttpError = HttpError,
TVariables = {},
> = {
autoSaveProps: Pick<
UseUpdateReturnType<TData, TError, TVariables>,
"data" | "error" | "status"
>;
onFinishAutoSave: (
values: TVariables,
) => Promise<UpdateResponse<TData> | void>;
};
export type AutoSaveIndicatorElements = Partial<
Record<"success" | "error" | "loading" | "idle", React.ReactNode>
>;
export type ActionParams = {
/**
* Type of the form mode
* @default Action that it reads from route otherwise "create" is used
*/
action?: FormAction;
};
type ActionFormProps<
TQueryFnData extends BaseRecord = BaseRecord,
TError extends HttpError = HttpError,
TVariables = {},
TData extends BaseRecord = TQueryFnData,
TResponse extends BaseRecord = TData,
TResponseError extends HttpError = TError,
> = {
/**
* Resource name for API data interactions
* @default Resource name that it reads from route
*/
resource?: string;
/**
* Record id for fetching
* @default Id that it reads from the URL
*/
id?: BaseKey;
/**
* Page to redirect after a succesfull mutation
* @type `"show" | "edit" | "list" | "create" | false`
* @default `"list"`
*/
redirect?: RedirectAction;
/**
* Metadata query for dataProvider
*/
meta?: MetaQuery;
/**
* Metadata query for dataProvider
* @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
*/
metaData?: MetaQuery;
/**
* Metadata to pass for the `useOne` query
*/
queryMeta?: MetaQuery;
/**
* Metadata to pass for the mutation (`useCreate` for `create` and `clone` actions, `useUpdate` for `edit` action)
*/
mutationMeta?: MetaQuery;
/**
* [Determines when mutations are executed](/advanced-tutorials/mutation-mode.md)
* @default `"pessimistic"*`
*/
mutationMode?: MutationMode;
/**
* Called when a mutation is successful
*/
onMutationSuccess?: (
data: CreateResponse<TResponse> | UpdateResponse<TResponse>,
variables: TVariables,
context: any,
isAutoSave?: boolean,
) => void;
/**
* Called when a mutation encounters an error
*/
onMutationError?: (
error: TResponseError,
variables: TVariables,
context: any,
isAutoSave?: boolean,
) => void;
/**
* Duration to wait before executing mutations when `mutationMode = "undoable"`
* @default `5000*`
*/
undoableTimeout?: number;
/**
* If there is more than one `dataProvider`, you should use the `dataProviderName` that you will use.
*/
dataProviderName?: string;
/**
* You can use it to manage the invalidations that will occur at the end of the mutation.
* @type `all`, `resourceAll`, `list`, `many`, `detail`, `false`
* @default `["list", "many", "detail"]`
*/
invalidates?: Array<keyof IQueryKeys>;
/**
* react-query's [useQuery](https://tanstack.com/query/v4/docs/reference/useQuery) options of useOne hook used while in edit mode.
*/
queryOptions?: UseQueryOptions<
GetOneResponse<TQueryFnData>,
TError,
GetOneResponse<TData>
>;
/**
* react-query's [useMutation](https://tanstack.com/query/v4/docs/reference/useMutation) options of useCreate hook used while submitting in create and clone modes.
*/
createMutationOptions?: UseCreateProps<
TResponse,
TResponseError,
TVariables
>["mutationOptions"];
/**
* react-query's [useMutation](https://tanstack.com/query/v4/docs/reference/useMutation) options of useUpdate hook used while submitting in edit mode.
*/
updateMutationOptions?: UseUpdateProps<
TResponse,
TResponseError,
TVariables
>["mutationOptions"];
/**
* If you customize the [`optimisticUpdateMap`](https://refine.dev/docs/api-reference/core/hooks/data/useUpdateMany/#optimisticupdatemap) option, you can use it to manage the invalidations that will occur at the end of the mutation.
* @default {
* list: true,
* many: true,
* detail: true,
* }
*/
optimisticUpdateMap?: OptimisticUpdateMapType<TResponse, TVariables>;
} & SuccessErrorNotification<
UpdateResponse<TResponse> | CreateResponse<TResponse>,
TResponseError,
{ id: BaseKey; values: TVariables } | TVariables
> &
ActionParams &
LiveModeProps;
export type UseFormProps<
TQueryFnData extends BaseRecord = BaseRecord,
TError extends HttpError = HttpError,
TVariables = {},
TData extends BaseRecord = TQueryFnData,
TResponse extends BaseRecord = TData,
TResponseError extends HttpError = TError,
> = ActionFormProps<
TQueryFnData,
TError,
TVariables,
TData,
TResponse,
TResponseError
> &
ActionParams &
LiveModeProps &
UseLoadingOvertimeOptionsProps &
AutoSaveProps<TVariables>;
export type UseFormReturnType<
TQueryFnData extends BaseRecord = BaseRecord,
TError extends HttpError = HttpError,
TVariables = {},
TData extends BaseRecord = TQueryFnData,
TResponse extends BaseRecord = TData,
TResponseError extends HttpError = TError,
> = {
id?: BaseKey;
setId: Dispatch<SetStateAction<BaseKey | undefined>>;
query?: QueryObserverResult<GetOneResponse<TData>, TError>;
/**
* @deprecated use `query` instead
*/
queryResult?: QueryObserverResult<GetOneResponse<TData>, TError>;
mutation:
| UseUpdateReturnType<TResponse, TResponseError, TVariables>
| UseCreateReturnType<TResponse, TResponseError, TVariables>;
/**
* @deprecated use `mutation` instead
*/
mutationResult:
| UseUpdateReturnType<TResponse, TResponseError, TVariables>
| UseCreateReturnType<TResponse, TResponseError, TVariables>;
formLoading: boolean;
onFinish: (
values: TVariables,
) => Promise<CreateResponse<TResponse> | UpdateResponse<TResponse> | void>;
redirect: (
redirect: RedirectAction,
idFromFunction?: BaseKey | undefined,
routeParams?: Record<string, string | number>,
) => void;
} & UseLoadingOvertimeReturnType &
AutoSaveReturnType<TResponse, TResponseError, TVariables>;
export type FormWithSyncWithLocationParams = {
/**
* If true, the form will be synced with the location.
* If an object is passed, the key property will be used as the key for the query params.
* By default, query params are placed under the key, `${resource.name}-${action}`.
*/
syncWithLocation?: boolean | { key?: string; syncId?: boolean };
};