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

Add server side validation support #7938

Merged
merged 9 commits into from
Feb 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cypress/e2e/edit.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ describe('Edit Page', () => {
cy.get('body').click('left'); // dismiss notification

cy.get('div[role="alert"]').should(el =>
expect(el).to.have.text('this title cannot be used')
expect(el).to.have.text('The form is invalid')
);

cy.get(ListPagePosts.elements.recordRows)
Expand Down
126 changes: 81 additions & 45 deletions docs/Validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,52 +362,88 @@ const CustomerCreate = () => (

## Server-Side Validation

You can use the errors returned by the dataProvider mutation as a source for the validation. In order to display the validation errors, a custom `save` function needs to be used:
Server-side validation is supported out of the box for `pessimistic` mode only. It requires that the dataProvider throws an error with the following shape:

{% raw %}
```jsx
import * as React from 'react';
import { useCallback } from 'react';
import { Create, SimpleForm, TextInput, useCreate, useRedirect, useNotify } from 'react-admin';

export const UserCreate = () => {
const redirect = useRedirect();
const notify = useNotify();

const [create] = useCreate();
const save = useCallback(
async values => {
try {
await create(
'users',
{ data: values },
{ returnPromise: true }
);
notify('ra.notification.created', {
type: 'info',
messageArgs: { smart_count: 1 },
});
redirect('list');
} catch (error) {
if (error.body.errors) {
// The shape of the returned validation errors must match the shape of the form
return error.body.errors;
```
{
body: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the "body" key? Why can't the dataProvider only reject with ({ errors: { source: 'error message' } }?

Also, your snippet is both a type description and an example, and so it's not enough informative. I'd use a more complete example:

{ 
  errors: {
    title: 'An article with this title already exists. The title must be unique.',
    date: 'The date is required',
    tags: { message: "The tag 'agrriculture' doesn't exist" },
  }
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because that's the current shape of our HttpError. Our data providers put the response json inside the body property of the error they throw

errors: {
title: 'An article with this title already exists. The title must be unique.',
date: 'The date is required',
tags: { message: "The tag 'agrriculture' doesn't exist" },
}
}
}
```

**Tip**: The shape of the returned validation errors must match the form shape: each key needs to match a `source` prop.

**Tip**: The returned validation errors might have any validation format we support (simple strings, translation strings or translation objects with a `message` attribute) for each key.

**Tip**: If your data provider leverages React Admin's [`httpClient`](https://marmelab.com/react-admin/DataProviderWriting.html#example-rest-implementation), all error response bodies are wrapped and thrown as `HttpError`. This means your API only needs to return an invalid response with a json body containing the `errors` key.

```js
import { fetchUtils } from "react-admin";

const httpClient = fetchUtils.fetchJson;

const apiUrl = 'https://my.api.com/';
/*
Example response from the API when there are validation errors:

{
"errors": {
"title": "An article with this title already exists. The title must be unique.",
"date": "The date is required",
"tags": { "message": "The tag 'agrriculture' doesn't exist" },
}
}
*/

const myDataProvider = {
create: (resource, params) =>
httpClient(`${apiUrl}/${resource}`, {
method: 'POST',
body: JSON.stringify(params.data),
}).then(({ json }) => ({
data: { ...params.data, id: json.id },
})),
}
```

**Tip:** If you are not using React Admin's `httpClient`, you can still wrap errors in an `HttpError` to return them with the correct shape:

```js
import { HttpError } from 'react-admin'

const myDataProvider = {
create: async (resource, { data }) => {
const response = await fetch(`${process.env.API_URL}/${resource}`, {
method: 'POST',
body: JSON.stringify(data),
});

const body = response.json();
/*
body should be something like:
{
errors: {
title: "An article with this title already exists. The title must be unique.",
date: "The date is required",
tags: { message: "The tag 'agrriculture' doesn't exist" },
}
}
},
[create, notify, redirect]
);

return (
<Create>
<SimpleForm onSubmit={save}>
<TextInput label="First Name" source="firstName" />
<TextInput label="Age" source="age" />
</SimpleForm>
</Create>
);
};
```
{% endraw %}
*/

if (status < 200 || status >= 300) {
throw new HttpError(
(body && body.message) || status,
status,
body
);
}

**Tip**: The shape of the returned validation errors must correspond to the form: a key needs to match a `source` prop.
return body;
}
}
```
10 changes: 8 additions & 2 deletions examples/simple/src/dataProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fakeRestProvider from 'ra-data-fakerest';
import { DataProvider, withLifecycleCallbacks } from 'react-admin';
import { DataProvider, HttpError, withLifecycleCallbacks } from 'react-admin';
import get from 'lodash/get';
import data from './data';
import addUploadFeature from './addUploadFeature';
Expand Down Expand Up @@ -96,7 +96,13 @@ const sometimesFailsDataProvider = new Proxy(uploadCapableDataProvider, {
params.data &&
params.data.title === 'f00bar'
) {
return Promise.reject(new Error('this title cannot be used'));
return Promise.reject(
new HttpError('The form is invalid', 400, {
errors: {
title: 'this title cannot be used',
},
})
);
}
return uploadCapableDataProvider[name](resource, params);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
SaveContextProvider,
useRegisterMutationMiddleware,
} from '../saveContext';
import { DataProvider } from '../..';

describe('useCreateController', () => {
describe('getRecordFromLocation', () => {
Expand Down Expand Up @@ -502,4 +503,33 @@ describe('useCreateController', () => {
expect.any(Function)
);
});

it('should return errors from the create call', async () => {
const create = jest.fn().mockImplementationOnce(() => {
return Promise.reject({ body: { errors: { foo: 'invalid' } } });
});
const dataProvider = ({
create,
} as unknown) as DataProvider;
let saveCallback;
render(
<CoreAdminContext dataProvider={dataProvider}>
<CreateController {...defaultProps}>
{({ save, record }) => {
saveCallback = save;
return <div />;
}}
</CreateController>
</CoreAdminContext>
);
await new Promise(resolve => setTimeout(resolve, 10));
let errors;
await act(async () => {
errors = await saveCallback({ foo: 'bar' });
});
expect(errors).toEqual({ foo: 'invalid' });
expect(create).toHaveBeenCalledWith('posts', {
data: { foo: 'bar' },
});
});
});
112 changes: 64 additions & 48 deletions packages/ra-core/src/controller/create/useCreateController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { Location } from 'history';
import { UseMutationOptions } from 'react-query';

import { useAuthenticated } from '../../auth';
import { useCreate, UseCreateMutateParams } from '../../dataProvider';
import {
HttpError,
useCreate,
UseCreateMutateParams,
} from '../../dataProvider';
import { useRedirect, RedirectionSideEffect } from '../../routing';
import { useNotify } from '../../notification';
import { SaveContextValue, useMutationMiddlewares } from '../saveContext';
Expand Down Expand Up @@ -74,7 +78,7 @@ export const useCreateController = <
const [create, { isLoading: saving }] = useCreate<
RecordType,
MutationOptionsError
>(resource, undefined, otherMutationOptions);
>(resource, undefined, { ...otherMutationOptions, returnPromise: true });

const save = useCallback(
(
Expand All @@ -91,55 +95,67 @@ export const useCreateController = <
: transform
? transform(data)
: data
).then((data: Partial<RecordType>) => {
).then(async (data: Partial<RecordType>) => {
const mutate = getMutateWithMiddlewares(create);
mutate(
resource,
{ data, meta },
{
onSuccess: async (data, variables, context) => {
if (onSuccessFromSave) {
return onSuccessFromSave(
data,
variables,
context
);
}
if (onSuccess) {
return onSuccess(data, variables, context);
}
try {
await mutate(
resource,
{ data, meta },
{
onSuccess: async (data, variables, context) => {
if (onSuccessFromSave) {
return onSuccessFromSave(
data,
variables,
context
);
}
if (onSuccess) {
return onSuccess(data, variables, context);
}

notify('ra.notification.created', {
type: 'info',
messageArgs: { smart_count: 1 },
});
redirect(finalRedirectTo, resource, data.id, data);
},
onError: onErrorFromSave
? onErrorFromSave
: onError
? onError
: (error: Error | string) => {
notify(
typeof error === 'string'
? error
: error.message ||
'ra.notification.http_error',
{
type: 'error',
messageArgs: {
_:
typeof error === 'string'
? error
: error && error.message
? error.message
: undefined,
},
}
);
},
notify('ra.notification.created', {
type: 'info',
messageArgs: { smart_count: 1 },
});
redirect(
finalRedirectTo,
resource,
data.id,
data
);
},
onError: onErrorFromSave
? onErrorFromSave
: onError
? onError
: (error: Error | string) => {
notify(
typeof error === 'string'
? error
: error.message ||
'ra.notification.http_error',
{
type: 'error',
messageArgs: {
_:
typeof error === 'string'
? error
: error &&
error.message
? error.message
: undefined,
},
}
);
},
}
);
} catch (error) {
if ((error as HttpError).body?.errors != null) {
return (error as HttpError).body.errors;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is weird: the save promise resolves when there is a validation error in the save process? I'd expect it to reject.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how the useAugmentedForm has been implemented. If the submit function returns something, we consider this is an error object. This was done like this probably because it worked that way in final-form.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we improve that in a backward compatible way?

}
);
}
}),
[
create,
Expand Down
34 changes: 34 additions & 0 deletions packages/ra-core/src/controller/edit/useEditController.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -906,4 +906,38 @@ describe('useEditController', () => {
expect.any(Function)
);
});

it('should return errors from the update call in pessimistic mode', async () => {
let post = { id: 12 };
const update = jest.fn().mockImplementationOnce(() => {
return Promise.reject({ body: { errors: { foo: 'invalid' } } });
});
const dataProvider = ({
getOne: () => Promise.resolve({ data: post }),
update,
} as unknown) as DataProvider;
let saveCallback;
render(
<CoreAdminContext dataProvider={dataProvider}>
<EditController {...defaultProps} mutationMode="pessimistic">
{({ save, record }) => {
saveCallback = save;
return <>{JSON.stringify(record)}</>;
}}
</EditController>
</CoreAdminContext>
);
await screen.findByText('{"id":12}');
let errors;
await act(async () => {
errors = await saveCallback({ foo: 'bar' });
});
expect(errors).toEqual({ foo: 'invalid' });
screen.getByText('{"id":12}');
expect(update).toHaveBeenCalledWith('posts', {
id: 12,
data: { foo: 'bar' },
previousData: { id: 12 },
});
});
});
Loading