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 unique validator #8999

Merged
merged 16 commits into from
Jun 16, 2023
32 changes: 32 additions & 0 deletions cypress/e2e/create.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -373,4 +373,36 @@ describe('Create Page', () => {
'Test body'
);
});

it('should validate unique fields', () => {
CreatePage.logout();
LoginPage.login('admin', 'password');

UserCreatePage.navigate();
UserCreatePage.setValues([
{
type: 'input',
name: 'name',
value: 'Annamarie Mayer',
},
]);
cy.get(UserCreatePage.elements.input('name')).blur();

cy.get(CreatePage.elements.nameError)
.should('exist')
.contains('Must be unique', { timeout: 10000 });

UserCreatePage.setValues([
{
type: 'input',
name: 'name',
value: 'Annamarie NotMayer',
},
]);
cy.get(UserCreatePage.elements.input('name')).blur();

cy.get(CreatePage.elements.nameError)
.should('exist')
.should('not.contain', 'Must be unique', { timeout: 10000 });
});
});
1 change: 1 addition & 0 deletions cypress/support/CreatePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default url => ({
title: '#react-admin-title',
userMenu: 'button[aria-label="Profile"]',
logout: '.logout',
nameError: '#name-helper-text',
},

navigate() {
Expand Down
7 changes: 4 additions & 3 deletions docs/Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,14 +295,15 @@ title: "Index"
* [`useTranslate`](./useTranslate.md)

**- U -**
* [`useUpdate`](./useUpdate.md)
* [`useUpdateMany`](./useUpdateMany.md)
* [`useUnique`](./useUnique.md)
* [`useUnlock`](./useUnlock.md)<img class="icon" src="./img/premium.svg" />
* [`useUnselect`](./useUnselect.md)
* [`useUnselectAll`](./useUnselectAll.md)
* [`useUpdate`](./useUpdate.md)
* [`useUpdateMany`](./useUpdateMany.md)

**- W -**
* [`useWarnWhenUnsavedChanges`](./EditTutorial.md#warning-about-unsaved-changes)
* [ `withLifecycleCallbacks`](./withLifecycleCallbacks.md)
* [`withLifecycleCallbacks`](./withLifecycleCallbacks.md)

</div>
1 change: 1 addition & 0 deletions docs/Validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ Alternatively, you can specify a `validate` prop directly in `<Input>` component
* `email(message)` to check that the input is a valid email address,
* `regex(pattern, message)` to validate that the input matches a regex,
* `choices(list, message)` to validate that the input is within a given list,
* `unique()` to validate that the input is unique (see [`useUnique`](./useUnique.md)),

Example usage:

Expand Down
Binary file added docs/img/useUnique.mp4
Binary file not shown.
Binary file added docs/img/useUnique.webm
Binary file not shown.
1 change: 1 addition & 0 deletions docs/navigation.html
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
<li {% if page.path == 'useEditContext.md' %} class="active" {% endif %}><a class="nav-link" href="./useEditContext.html"><code>useEditContext</code></a></li>
<li {% if page.path == 'useEditController.md' %} class="active" {% endif %}><a class="nav-link" href="./useEditController.html"><code>useEditController</code></a></li>
<li {% if page.path == 'useSaveContext.md' %} class="active" {% endif %}><a class="nav-link" href="./useSaveContext.html"><code>useSaveContext</code></a></li>
<li {% if page.path == 'useUnique.md' %} class="active" {% endif %}><a class="nav-link" href="./useUnique.html"><code>useUnique</code></a></li>
</ul>

<ul><div>Show Page</div>
Expand Down
139 changes: 139 additions & 0 deletions docs/useUnique.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
layout: default
title: "useUnique"
---

# `useUnique`

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.

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.

<video controls autoplay playsinline muted loop>
<source src="./img/useUnique.webm" type="video/webm"/>
<source src="./img/useUnique.mp4" type="video/mp4"/>
Your browser does not support the video tag.
</video>

slax57 marked this conversation as resolved.
Show resolved Hide resolved
## Usage

```js
import { SimpleForm, TextInput, useUnique } from 'react-admin';

const UserCreateForm = () => {
slax57 marked this conversation as resolved.
Show resolved Hide resolved
const unique = useUnique();
return (
<SimpleForm>
<TextInput source="username" validate={unique()} />
</SimpleForm>
);
};
```

## Options

| Option | Required | Type | Default | Description |
| ------------------- | -------- | -------------- | -------- | ---------------------------------------------------------------------------------- |
| `message` | Optional | `string` | `ra.validation.unique` | A custom message to display when the validation fails |
| `debounce` | Optional | `number` | 1000 | The number of milliseconds to wait for new changes before validating |
| `filter` | Optional | `object` | - | Additional filters to pass to the `dataProvider.getList` call |
| `resource` | Optional | `string` | current from Context | The resource targeted by the `dataProvider.getList` call |

## `message`

A custom message to display when the validation fails. Defaults to `Must be unique` (translation key: `ra.validation.unique`).
It accepts a translation key. The [`translate` function](./useTranslate.md) will be called with the following parameters:
- `source`: the input name
- `label`: the translated input label
- `value`: the current input value

```jsx
import { SimpleForm, TextInput, useUnique } from 'react-admin';
import polyglotI18nProvider from 'ra-i18n-polyglot';

const i18nProvider = polyglotI18nProvider(() =>
slax57 marked this conversation as resolved.
Show resolved Hide resolved
mergeTranslations(englishMessages, {
myapp: {
validation: {
unique: 'Value %{value} is already used for %{field}',
},
},
})
);

const UserCreateForm = () => {
const unique = useUnique();
return (
<SimpleForm>
<TextInput source="username" validate={unique({ message: 'myapp.validation.unique' })} />
</SimpleForm>
);
};
```

## `debounce`

The number of milliseconds to wait for new changes before actually calling the [`dataProvider.getList`](./DataProviderWriting.md#request-format) method.

Copy link
Member

Choose a reason for hiding this comment

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

Why would you want to change the resource? please add an example

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That's just what we do everywhere we need the resource?


```jsx
import { SimpleForm, TextInput, useUnique } from 'react-admin';

const UserCreateForm = () => {
const unique = useUnique();
return (
<SimpleForm>
<TextInput source="username" validate={unique({ debounce: 2000 })} />
</SimpleForm>
);
};
```

## `resource`

The resource targeted by the [`dataProvider.getList`](./DataProviderWriting.md#request-format) call. Defaults to the resource from the nearest [`ResourceContext`](./Resource.md#resource-context).

This can be useful for custom pages instead of setting up a [`ResourceContext`](./Resource.md#resource-context).

```jsx
import { PasswordInput, SimpleForm, TextInput, useUnique } from 'react-admin';

const UserCreateForm = () => {
const unique = useUnique();
return (
<SimpleForm>
<TextInput source="username" validate={unique({ resource: 'users' })} />
<PasswordInput source="password" />
</SimpleForm>
);
};
```

## `filter`

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:

```jsx
import { FormDataConsumer, ReferenceInput, SimpleForm, TextInput, useUnique } from 'react-admin';

const UserCreateForm = () => {
slax57 marked this conversation as resolved.
Show resolved Hide resolved
const unique = useUnique();
return (
<SimpleForm>
<ReferenceInput source="organization_id" reference="organizations">
<FormDataConsumer>
{({ formData }) => (
<TextInput
source="username"
validate={unique({
filter: {
organization_id: formData.organization_id,
},
})}
/>
)}
</FormDataConsumer>
</SimpleForm>
);
};
```
6 changes: 4 additions & 2 deletions examples/simple/src/users/UserCreate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
required,
useNotify,
usePermissions,
useUnique,
} from 'react-admin';

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

const UserCreate = () => {
const { permissions } = usePermissions();
const unique = useUnique();
return (
<Create aside={<Aside />} redirect="show">
<TabbedForm
Expand All @@ -65,7 +67,7 @@ const UserCreate = () => {
source="name"
defaultValue="Slim Shady"
autoFocus
validate={[required(), isValidName]}
validate={[required(), isValidName, unique()]}
/>
</TabbedForm.Tab>
{permissions === 'admin' && (
Expand Down
1 change: 1 addition & 0 deletions packages/ra-core/src/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@ export * from './useNotifyIsFormInvalid';
export * from './useAugmentedForm';
export * from './useInput';
export * from './useSuggestions';
export * from './useUnique';
export * from './useWarnWhenUnsavedChanges';
Loading