import { getOperationName } from '@graphcommerce/graphql/apollo' import type { ApolloLink, ErrorLike, MaybeMasked, TypedDocumentNode } from '@apollo/client' import { CombinedGraphQLErrors } from '@apollo/client/errors' import type { useLazyQuery, useMutation } from '@apollo/client/react' import useEventCallback from '@mui/utils/useEventCallback' import { useEffect, useRef } from 'react' import type { DefaultValues, FieldValues, UseFormProps, UseFormReturn } from 'react-hook-form' import diff from './diff' import type { UseGqlDocumentHandler } from './useGqlDocumentHandler' import { useGqlDocumentHandler } from './useGqlDocumentHandler' import { tryAsync } from './utils/tryTuple' type UseFormGraphQLCallbacks = { /** * Allows you to modify the variablels computed by the form to make it compatible with the GraphQL * Mutation. * * When returning false, it will SKIP the submission. When an Error is thrown, it will be set as * an error. */ onBeforeSubmit?: (variables: V, form?: UseFormReturn) => V | false | Promise /** * Called after the mutation has been executed. Allows you to handle the result of the mutation. * * When an error is thrown, it will be set as an error */ onComplete?: ( result: ApolloLink.Result>, variables: V, form?: UseFormReturn, ) => void | Promise /** * @deprecated Not used anymore, is now the default * * Changes: * * - Restores `defaultValues` functionality to original functionality, use `values` instead. * - Does not reset the form after submission, use `values` instead. * - Does not 'encode' the variables, use onBeforeSubmit instead. * * Future plans: * * - Remove the useMutation/useLazyQuery tuple and use a reguler client.mutation() call. * - Write graphql errors to setError('root') * - Remove onBeforeSubmit, onComplete and the handleSubmit rewrite with a single mutate() callback. * * ```ts * const { handleSubmit } = useFormGqlMutation() * * const submit = handleSubmit((formValues, mutate) => { * // onBeforeSubmit now simply is code before mutate() where you can return early for example or set errors. * const result = mutate() // executes the mutation and automatically sets generic errors with setError('root') * // onComplete: now simply use the result after the form, to for example reset the form, or do other things. * }) * ``` */ experimental_useV2?: boolean /** * To restore the previous functionality of the useFormGqlMutation, set this to true. * * @deprecated Will be removed in the next version. */ deprecated_useV1?: boolean /** * Only submit the form when there are dirty fields. If all fields are clean, we skip the * submission. * * Form is still set to isSubmitted and isSubmitSuccessful. */ skipUnchanged?: boolean } export type UseFormGraphQlOptions = UseFormProps & UseFormGraphQLCallbacks export const UseFormGqlSymbol = Symbol('UseFormGql') export type UseFormGqlMethods = Omit< UseGqlDocumentHandler, 'encode' | 'type' > & Pick, 'handleSubmit'> & { data?: MaybeMasked | null error?: ErrorLike | null submittedVariables?: V | null [UseFormGqlSymbol]: true } /** * Combines useMutation/useLazyQuery with react-hook-form's useForm: * * - Automatically extracts all required arguments for a query * - Casts Float/Int mutation input variables to a Number * - Updates the form when the query updates * - Resets the form after submitting the form when no modifications are found */ export function useFormGql( options: { document: TypedDocumentNode form: UseFormReturn tuple: | useMutation.ResultTuple | useLazyQuery.ResultTuple operationOptions?: | Omit, 'fetchPolicy' | 'variables'> | Omit, 'fetchPolicy' | 'variables'> defaultValues?: UseFormProps['defaultValues'] skipUnchanged?: boolean } & UseFormGraphQLCallbacks, ): UseFormGqlMethods { const { onComplete, onBeforeSubmit, document, form, tuple, operationOptions, skipUnchanged, defaultValues, deprecated_useV1 = false, } = options const { encode, type, ...gqlDocumentHandler } = useGqlDocumentHandler(document) const [execute, { data, error, loading }] = tuple const submittedVariables = useRef(null) const returnedError = useRef(null) // automatically updates the default values const initital = useRef(true) const controllerRef = useRef(null) const valuesString = JSON.stringify(defaultValues) useEffect(() => { if (!deprecated_useV1) return if (initital.current) { initital.current = false return } if (defaultValues instanceof Promise) return form.reset(defaultValues as DefaultValues, { keepDirtyValues: true }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [valuesString, form]) const beforeSubmit = useEventCallback( tryAsync((onBeforeSubmit ?? ((v) => v)) satisfies NonNullable), ) const complete = useEventCallback( tryAsync((onComplete ?? (() => undefined)) satisfies NonNullable), ) const handleSubmit: UseFormReturn['handleSubmit'] = (onValid, onInvalid) => form.handleSubmit(async (formValues, event) => { const hasDirtyFields = skipUnchanged ? Object.values(form?.formState.dirtyFields ?? []).filter(Boolean).length > 0 : true if (skipUnchanged && !hasDirtyFields) { console.info( `[useFormGql ${getOperationName(document)}] skipped submission, no dirty fields`, ) await onValid(formValues, event) return } returnedError.current = null submittedVariables.current = null // Combine defaults with the formValues and encode let variables = !deprecated_useV1 ? formValues : encode({ ...defaultValues, ...formValues }) // Wait for the onBeforeSubmit to complete const [onBeforeSubmitResult, onBeforeSubmitError] = await beforeSubmit(variables, form) if (onBeforeSubmitError) { // Check if it's a GraphQL error or a regular error if (CombinedGraphQLErrors.is(onBeforeSubmitError)) { returnedError.current = onBeforeSubmitError form.setError('root', { message: onBeforeSubmitError.message }) } else { const message = process.env.NODE_ENV === 'development' ? `An error was thrown during the onBeforeSubmit handler: ${onBeforeSubmitError.message}` : 'An unexpected error occurred, please contact the store owner' form.setError('root', { message }) returnedError.current = new CombinedGraphQLErrors({ errors: [{ message }] }) } return } if (onBeforeSubmitResult === false) return variables = onBeforeSubmitResult submittedVariables.current = variables if (!deprecated_useV1 && loading) controllerRef.current?.abort() controllerRef.current = new window.AbortController() const result = await execute({ ...operationOptions, variables, context: { ...(operationOptions as { context?: Record } | undefined)?.context, fetchOptions: { ...( operationOptions as | { context?: { fetchOptions?: Record } } | undefined )?.context?.fetchOptions, signal: controllerRef.current.signal, }, }, }) // If there are submission errors, set the error and return if (result.error) { const errorMessage = CombinedGraphQLErrors.is(result.error) ? result.error.errors.map((e) => e.message).join(', ') : result.error.message form.setError('root', { message: errorMessage }) return } const [, onCompleteError] = await complete(result, variables, form) if (onCompleteError) { if (CombinedGraphQLErrors.is(onCompleteError)) { returnedError.current = onCompleteError form.setError('root', { message: onCompleteError.message }) } else { const message = process.env.NODE_ENV === 'development' ? `An error was thrown during the onComplete handler: ${onCompleteError.message}` : 'An unexpected error occurred, please contact the store owner' form.setError('root', { message }) returnedError.current = new CombinedGraphQLErrors({ errors: [{ message }] }) } return } if (deprecated_useV1 && typeof diff(form.getValues(), formValues) === 'undefined') // Reset the state of the form if it is unmodified afterwards form.reset(formValues) await onValid(formValues, event) }, onInvalid) return { ...gqlDocumentHandler, handleSubmit, data, error: returnedError.current ?? error, submittedVariables: submittedVariables.current, [UseFormGqlSymbol]: true, } }