Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unable to Show Server Side Errors in Form #7581

Closed
Oktay28 opened this issue Apr 21, 2022 · 4 comments
Closed

Unable to Show Server Side Errors in Form #7581

Oktay28 opened this issue Apr 21, 2022 · 4 comments

Comments

@Oktay28
Copy link

Oktay28 commented Apr 21, 2022

What you were expecting:
To display input errors when server sends errors

What happened instead:
No errors are displayed

Related code:
useCreateController returns function 'save' that calls 'create' from 'useCreate'. The 'create' function itself does not return anything. It has a prop to return a promise 'returnPromise', but that prop is not passed to 'create' in 'save'. Third argument in useCreateController that is passed to 'create' has only 'onSuccess' | 'onError' and so it does no return anything without 'returnPromise' prop.
useAugmentedForm expects saveContext.save to return the errors, if there are any.

// from useCreate
if (returnPromise) {
   return mutation.mutateAsync(...);
}
mutation.mutate(...);

// from useAugmentedForm
if (onSubmit == null && saveContext?.save) {
    errors = await saveContext.save(values);
}

// from useCreateController
=> create(
   resource,
   { data },
   {
     onSuccess: ....,
     onError: ....,
     // here 'returnPromise: true' is missing
  }
)

saveContext.save comes from useCreateController. And saveContext.save(values) does not return anything, because the prop 'returnPromise' is not passed.

Same in useEditController

Environment

  • React-admin version: 4.0.1
  • React version: 17.0.2
@Oktay28 Oktay28 changed the title Missing Form Input Errors on Server Response Unable to Show Server Side Errors in Form Apr 21, 2022
@slax57
Copy link
Contributor

slax57 commented Apr 25, 2022

Hi,
I'm not sure I understood your issue correctly.
To me, the behaviour of catching and displaying server side validation errors works fine.
Please have a look at this codesandbox: https://codesandbox.io/s/fervent-resonance-phfwwq?file=/src/posts/PostCreate.tsx
If you create a post with title "error", and then click on "save and edit", you'll get the validation error "Custom error while calling create" which actually comes from the dataProvider (see dataProvider.tsx line 56).
For reference, I followed https://marmelab.com/react-admin/Validation.html#server-side-validation

If I'm missing your point, feel free to correct me and/or complete my sandbox to reproduce your actual issue.
Thanks

@Oktay28
Copy link
Author

Oktay28 commented Apr 26, 2022

Hello,

So you saying that 'Create' component can save the form, but can't handle server side errors itself? 'Create' uses 'useCreateController', that uses 'useCreate'. Why do I need to use second 'useCreate' but can't work directly with 'Create ' ?

   // from documentation
    const [create] = useCreate();
    const save = useCallback(
        async values => {
            try {
                await create('users', { data: values }, { returnPromise: true });
            } catch (error) {
                if (error.body.errors) {
                    return error.body.errors;
                }
            }
        },
        [create]
    );

     <Create>   // here it already uses 'useCreate'
        <SimpleForm onSubmit={save}>   // why I need to use my own submit handler while using 'Create' component?
           ...
       </SimpleForm>
    </Create>

@slax57
Copy link
Contributor

slax57 commented Apr 26, 2022

Since there is no standard format defining how your backend will answer with the validation errors, you need to write that logic manually at some point.
We could probably have some standardized way to output errors from the dataProvider, that the save context could then use, but at the moment we don't, so you need to do that in your save callback.

@Oktay28 Oktay28 closed this as completed Apr 26, 2022
@magicxor
Copy link

My current workaround for this issue (based on useCreateController.ts and useEditController.ts) until #7938 is merged:

IExceptionWrapper.ts (see ValidationProblemDetails class which is an extension of rfc7807)

export interface IExceptionWrapper {
  title: string;
  status: number;
  errors: Record<string, string[]>;
}

useCreateSaveWithServerValidation.ts

import {
  RedirectionSideEffect,
  TransformData,
  useCreate,
  useMutationMiddlewares,
  useNotify,
  useRedirect,
  useResourceContext,
  useResourceDefinition,
} from 'react-admin';
import { useCallback } from 'react';
import axios, { AxiosError } from 'axios';
import { IExceptionWrapper } from '../../common/interfaces/IExceptionWrapper';
import { serverSideErrTransform } from '../errorUtils';

/*
* see
* useCreateController.ts (react-admin source code)
* https://marmelab.com/react-admin/Validation.html#server-side-validation
* https://github.com/marmelab/react-admin/issues/7581#issuecomment-1109543482
* https://github.com/marmelab/react-admin/issues/8278
* https://github.com/marmelab/react-admin/issues/7608#issuecomment-1143529740
* https://github.com/marmelab/react-admin/issues/5992#issuecomment-872981065
* */

const getDefaultRedirectRoute = (hasShow: boolean | undefined, hasEdit: boolean | undefined) => {
  if (hasEdit) {
    return 'edit';
  }
  if (hasShow) {
    return 'show';
  }
  return 'list';
};

export interface UseCreateSaveWithServerValidationProps {
  resourceName?: string;
  redirectTo?: RedirectionSideEffect;
  transformFunc?: TransformData;
}

export const useCreateSaveWithServerValidation = ({ resourceName, redirectTo, transformFunc }: UseCreateSaveWithServerValidationProps = {}) => {
  const [create] = useCreate();
  const { getMutateWithMiddlewares } = useMutationMiddlewares();
  const notify = useNotify();
  const redirect = useRedirect();
  const resourceContext = useResourceContext();
  const resource = resourceName ?? resourceContext;
  const { hasEdit, hasShow } = useResourceDefinition({ resource });
  const finalRedirectTo = redirectTo ?? getDefaultRedirectRoute(hasShow, hasEdit);

  return useCallback(
    async (formValues: object) => {
      try {
        const transformedData = await Promise.resolve(transformFunc ? transformFunc(formValues) : formValues);
        const mutate = getMutateWithMiddlewares(create);

        await mutate(
          resource,
          { data: transformedData }, // custom meta omitted
          {
            returnPromise: true,

            // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unused-vars
            onSuccess: async (data: any, variables: any, context: unknown) => {
              // custom onSuccess omitted

              notify('ra.notification.created', {
                type: 'info',
                messageArgs: { smart_count: 1 },
              });
              redirect(finalRedirectTo, resource, data.id, data);
              return undefined;
            },

            // custom onError omitted
            onError: ((error: Error | string) => {
              notify(
                typeof error === 'string'
                  ? error
                  : error.message
                  || 'ra.notification.http_error',
                {
                  type: 'warning',
                  messageArgs: {
                    _:
                      typeof error === 'string'
                        ? error
                        : error && error.message
                          ? error.message
                          : undefined,
                  },
                },
              );
            }),
          },
        );

        return undefined;
      } catch (error: unknown) {
        if (axios.isAxiosError(error)) {
          const axiosError = error as AxiosError;
          const responseData = axiosError.response?.data;
          if (!!responseData
            && typeof responseData === 'object'
            && Object.hasOwn(responseData, 'errors')) {
            const responseDataTyped = responseData as IExceptionWrapper;
            return serverSideErrTransform(responseDataTyped.errors);
          } else {
            throw error;
          }
        } else {
          throw error;
        }
      }
    },
    [create, finalRedirectTo, getMutateWithMiddlewares, notify, redirect, resource, transformFunc],
  );
};

useEditSaveWithServerValidation.ts

import {
  MutationMode,
  RedirectionSideEffect,
  TransformData,
  useMutationMiddlewares,
  useNotify,
  useRedirect,
  useResourceContext,
  useUpdate,
} from 'react-admin';
import { useCallback } from 'react';
import axios, { AxiosError } from 'axios';
import { IExceptionWrapper } from '../../common/interfaces/IExceptionWrapper';
import { serverSideErrTransform } from '../errorUtils';
import { useParams } from 'react-router-dom';

/*
* see
* useEditController.ts (react-admin source code)
* https://marmelab.com/react-admin/Validation.html#server-side-validation
* https://github.com/marmelab/react-admin/issues/7581#issuecomment-1109543482
* https://github.com/marmelab/react-admin/issues/8278
* https://github.com/marmelab/react-admin/issues/7608#issuecomment-1143529740
* https://github.com/marmelab/react-admin/issues/5992#issuecomment-872981065
* */

export interface UseEditSaveWithServerValidationProps {
  resourceName?: string;
  recordId?: number;
  mutationMode?: MutationMode;
  redirectTo?: RedirectionSideEffect;
  transformFunc?: TransformData;
}

function exists(value: number | undefined) {
  return !(value === null || value === undefined);
}

const DefaultRedirect = 'list';

export const useEditSaveWithServerValidation = ({ resourceName, recordId, mutationMode, redirectTo, transformFunc }: UseEditSaveWithServerValidationProps = {}) => {
  const [update] = useUpdate(); // recordCached, otherMutationOptions, mutationMode omitted
  const { getMutateWithMiddlewares } = useMutationMiddlewares();
  const notify = useNotify();
  const redirect = useRedirect();
  const { id: routeId } = useParams<'id'>();
  const id = exists(recordId) ? recordId : decodeURIComponent(routeId ?? '');
  const resourceContext = useResourceContext();
  const resource = resourceName ?? resourceContext;
  const finalRedirectTo = redirectTo ?? DefaultRedirect;

  return useCallback(
    async (formValues: object) => {
      try {
        const transformedData = await Promise.resolve(transformFunc ? transformFunc(formValues) : formValues);
        const mutate = getMutateWithMiddlewares(update);

        await mutate(
          resource,
          { id, data: transformedData }, // meta omitted
          {
            returnPromise: true,

            // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unused-vars
            onSuccess: async (data: any, variables: any, context: unknown) => {
              // custom onSuccess omitted

              notify('ra.notification.updated', {
                type: 'info',
                messageArgs: { smart_count: 1 },
                undoable: mutationMode === 'undoable',
              });
              redirect(finalRedirectTo, resource, data?.id, data);
              return undefined;
            },

            // custom onError omitted
            onError: ((error: Error | string) => {
              notify(
                typeof error === 'string'
                  ? error
                  : error.message
                  || 'ra.notification.http_error',
                {
                  type: 'warning',
                  messageArgs: {
                    _:
                      typeof error === 'string'
                        ? error
                        : error && error.message
                          ? error.message
                          : undefined,
                  },
                },
              );
            }),
          },
        );

        return undefined;
      } catch (error: unknown) {
        if (axios.isAxiosError(error)) {
          const axiosError = error as AxiosError;
          const responseData = axiosError.response?.data;
          if (!!responseData
            && typeof responseData === 'object'
            && Object.hasOwn(responseData, 'errors')) {
            const responseDataTyped = responseData as IExceptionWrapper;
            return serverSideErrTransform(responseDataTyped.errors);
          } else {
            throw error;
          }
        } else {
          throw error;
        }
      }
    },
    [finalRedirectTo, getMutateWithMiddlewares, id, mutationMode, notify, redirect, resource, transformFunc, update],
  );
};

I use these hooks like this

const save = useEditSaveWithServerValidation({ transformFunc: transformUserData, redirectTo: false });

// ...

<SimpleForm onSubmit={save} toolbar={<SaveDeleteToolbar />} warnWhenUnsavedChanges>
  <UserEditInputs />
</SimpleForm>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants