Skip to content

Commit

Permalink
Merge pull request #7938 from marmelab/refactor-saving-status
Browse files Browse the repository at this point in the history
Add server side validation support
  • Loading branch information
slax57 authored Feb 24, 2023
2 parents 78a3567 + 03c8bac commit c49228b
Show file tree
Hide file tree
Showing 11 changed files with 291 additions and 161 deletions.
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: {
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;
}
);
}
}),
[
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

0 comments on commit c49228b

Please sign in to comment.