Skip to content

Commit 5dedc3a

Browse files
authored
Merge pull request #8999 from marmelab/unique-validator
Add unique validator
2 parents fdfc40d + f8d98ec commit 5dedc3a

File tree

18 files changed

+887
-5
lines changed

18 files changed

+887
-5
lines changed

cypress/e2e/create.cy.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,4 +373,36 @@ describe('Create Page', () => {
373373
'Test body'
374374
);
375375
});
376+
377+
it('should validate unique fields', () => {
378+
CreatePage.logout();
379+
LoginPage.login('admin', 'password');
380+
381+
UserCreatePage.navigate();
382+
UserCreatePage.setValues([
383+
{
384+
type: 'input',
385+
name: 'name',
386+
value: 'Annamarie Mayer',
387+
},
388+
]);
389+
cy.get(UserCreatePage.elements.input('name')).blur();
390+
391+
cy.get(CreatePage.elements.nameError)
392+
.should('exist')
393+
.contains('Must be unique', { timeout: 10000 });
394+
395+
UserCreatePage.setValues([
396+
{
397+
type: 'input',
398+
name: 'name',
399+
value: 'Annamarie NotMayer',
400+
},
401+
]);
402+
cy.get(UserCreatePage.elements.input('name')).blur();
403+
404+
cy.get(CreatePage.elements.nameError)
405+
.should('exist')
406+
.should('not.contain', 'Must be unique', { timeout: 10000 });
407+
});
376408
});

cypress/support/CreatePage.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default url => ({
2323
title: '#react-admin-title',
2424
userMenu: 'button[aria-label="Profile"]',
2525
logout: '.logout',
26+
nameError: '#name-helper-text',
2627
},
2728

2829
navigate() {

docs/Reference.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,14 +295,15 @@ title: "Index"
295295
* [`useTranslate`](./useTranslate.md)
296296

297297
**- U -**
298-
* [`useUpdate`](./useUpdate.md)
299-
* [`useUpdateMany`](./useUpdateMany.md)
298+
* [`useUnique`](./useUnique.md)
300299
* [`useUnlock`](./useUnlock.md)<img class="icon" src="./img/premium.svg" />
301300
* [`useUnselect`](./useUnselect.md)
302301
* [`useUnselectAll`](./useUnselectAll.md)
302+
* [`useUpdate`](./useUpdate.md)
303+
* [`useUpdateMany`](./useUpdateMany.md)
303304

304305
**- W -**
305306
* [`useWarnWhenUnsavedChanges`](./EditTutorial.md#warning-about-unsaved-changes)
306-
* [ `withLifecycleCallbacks`](./withLifecycleCallbacks.md)
307+
* [`withLifecycleCallbacks`](./withLifecycleCallbacks.md)
307308

308309
</div>

docs/Validation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ Alternatively, you can specify a `validate` prop directly in `<Input>` component
106106
* `email(message)` to check that the input is a valid email address,
107107
* `regex(pattern, message)` to validate that the input matches a regex,
108108
* `choices(list, message)` to validate that the input is within a given list,
109+
* `unique()` to validate that the input is unique (see [`useUnique`](./useUnique.md)),
109110

110111
Example usage:
111112

docs/img/useUnique.mp4

16.6 KB
Binary file not shown.

docs/img/useUnique.webm

14.2 KB
Binary file not shown.

docs/navigation.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
<li {% if page.path == 'useEditContext.md' %} class="active" {% endif %}><a class="nav-link" href="./useEditContext.html"><code>useEditContext</code></a></li>
119119
<li {% if page.path == 'useEditController.md' %} class="active" {% endif %}><a class="nav-link" href="./useEditController.html"><code>useEditController</code></a></li>
120120
<li {% if page.path == 'useSaveContext.md' %} class="active" {% endif %}><a class="nav-link" href="./useSaveContext.html"><code>useSaveContext</code></a></li>
121+
<li {% if page.path == 'useUnique.md' %} class="active" {% endif %}><a class="nav-link" href="./useUnique.html"><code>useUnique</code></a></li>
121122
</ul>
122123

123124
<ul><div>Show Page</div>

docs/useUnique.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
---
2+
layout: default
3+
title: "useUnique"
4+
---
5+
6+
# `useUnique`
7+
8+
Validating the uniqueness of a field is a common requirement so React-admin provides the `useUnique` hook that returns a validator for this use case.
9+
10+
It will call the [`dataProvider.getList`](./DataProviderWriting.md#request-format) method with a filter to check whether a record exists with the current value of the input for the field matching the input source.
11+
12+
<video controls autoplay playsinline muted loop>
13+
<source src="./img/useUnique.webm" type="video/webm"/>
14+
<source src="./img/useUnique.mp4" type="video/mp4"/>
15+
Your browser does not support the video tag.
16+
</video>
17+
18+
## Usage
19+
20+
```js
21+
import { SimpleForm, TextInput, useUnique } from 'react-admin';
22+
23+
const UserCreateForm = () => {
24+
const unique = useUnique();
25+
return (
26+
<SimpleForm>
27+
<TextInput source="username" validate={unique()} />
28+
</SimpleForm>
29+
);
30+
};
31+
```
32+
33+
## Options
34+
35+
| Option | Required | Type | Default | Description |
36+
| ------------------- | -------- | -------------- | -------- | ---------------------------------------------------------------------------------- |
37+
| `message` | Optional | `string` | `ra.validation.unique` | A custom message to display when the validation fails |
38+
| `debounce` | Optional | `number` | 1000 | The number of milliseconds to wait for new changes before validating |
39+
| `filter` | Optional | `object` | - | Additional filters to pass to the `dataProvider.getList` call |
40+
| `resource` | Optional | `string` | current from Context | The resource targeted by the `dataProvider.getList` call |
41+
42+
## `message`
43+
44+
A custom message to display when the validation fails. Defaults to `Must be unique` (translation key: `ra.validation.unique`).
45+
It accepts a translation key. The [`translate` function](./useTranslate.md) will be called with the following parameters:
46+
- `source`: the input name
47+
- `label`: the translated input label
48+
- `value`: the current input value
49+
50+
```jsx
51+
import { SimpleForm, TextInput, useUnique } from 'react-admin';
52+
import polyglotI18nProvider from 'ra-i18n-polyglot';
53+
54+
const i18nProvider = polyglotI18nProvider(() =>
55+
mergeTranslations(englishMessages, {
56+
myapp: {
57+
validation: {
58+
unique: 'Value %{value} is already used for %{field}',
59+
},
60+
},
61+
})
62+
);
63+
64+
const UserCreateForm = () => {
65+
const unique = useUnique();
66+
return (
67+
<SimpleForm>
68+
<TextInput source="username" validate={unique({ message: 'myapp.validation.unique' })} />
69+
</SimpleForm>
70+
);
71+
};
72+
```
73+
74+
## `debounce`
75+
76+
The number of milliseconds to wait for new changes before actually calling the [`dataProvider.getList`](./DataProviderWriting.md#request-format) method.
77+
78+
79+
```jsx
80+
import { SimpleForm, TextInput, useUnique } from 'react-admin';
81+
82+
const UserCreateForm = () => {
83+
const unique = useUnique();
84+
return (
85+
<SimpleForm>
86+
<TextInput source="username" validate={unique({ debounce: 2000 })} />
87+
</SimpleForm>
88+
);
89+
};
90+
```
91+
92+
## `resource`
93+
94+
The resource targeted by the [`dataProvider.getList`](./DataProviderWriting.md#request-format) call. Defaults to the resource from the nearest [`ResourceContext`](./Resource.md#resource-context).
95+
96+
This can be useful for custom pages instead of setting up a [`ResourceContext`](./Resource.md#resource-context).
97+
98+
```jsx
99+
import { PasswordInput, SimpleForm, TextInput, useUnique } from 'react-admin';
100+
101+
const UserCreateForm = () => {
102+
const unique = useUnique();
103+
return (
104+
<SimpleForm>
105+
<TextInput source="username" validate={unique({ resource: 'users' })} />
106+
<PasswordInput source="password" />
107+
</SimpleForm>
108+
);
109+
};
110+
```
111+
112+
## `filter`
113+
114+
Additional filters to pass to the [`dataProvider.getList`](./DataProviderWriting.md#request-format) method. This is useful when the value should be unique across a subset of the resource records, for instance, usernames in an organization:
115+
116+
```jsx
117+
import { FormDataConsumer, ReferenceInput, SimpleForm, TextInput, useUnique } from 'react-admin';
118+
119+
const UserCreateForm = () => {
120+
const unique = useUnique();
121+
return (
122+
<SimpleForm>
123+
<ReferenceInput source="organization_id" reference="organizations">
124+
<FormDataConsumer>
125+
{({ formData }) => (
126+
<TextInput
127+
source="username"
128+
validate={unique({
129+
filter: {
130+
organization_id: formData.organization_id,
131+
},
132+
})}
133+
/>
134+
)}
135+
</FormDataConsumer>
136+
</SimpleForm>
137+
);
138+
};
139+
```

examples/simple/src/users/UserCreate.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
required,
1212
useNotify,
1313
usePermissions,
14+
useUnique,
1415
} from 'react-admin';
1516

1617
import Aside from './Aside';
@@ -26,7 +27,7 @@ const UserEditToolbar = ({ permissions, ...props }) => {
2627
<SaveButton
2728
label="user.action.save_and_add"
2829
mutationOptions={{
29-
onSuccess: data => {
30+
onSuccess: () => {
3031
notify('ra.notification.created', {
3132
type: 'info',
3233
messageArgs: {
@@ -53,6 +54,7 @@ const isValidName = async value =>
5354

5455
const UserCreate = () => {
5556
const { permissions } = usePermissions();
57+
const unique = useUnique();
5658
return (
5759
<Create aside={<Aside />} redirect="show">
5860
<TabbedForm
@@ -65,7 +67,7 @@ const UserCreate = () => {
6567
source="name"
6668
defaultValue="Slim Shady"
6769
autoFocus
68-
validate={[required(), isValidName]}
70+
validate={[required(), isValidName, unique()]}
6971
/>
7072
</TabbedForm.Tab>
7173
{permissions === 'admin' && (

packages/ra-core/src/form/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,5 @@ export * from './useNotifyIsFormInvalid';
4545
export * from './useAugmentedForm';
4646
export * from './useInput';
4747
export * from './useSuggestions';
48+
export * from './useUnique';
4849
export * from './useWarnWhenUnsavedChanges';

0 commit comments

Comments
 (0)