diff --git a/cypress/e2e/create.cy.js b/cypress/e2e/create.cy.js index d2fffde30a8..6fcddc5a7fe 100644 --- a/cypress/e2e/create.cy.js +++ b/cypress/e2e/create.cy.js @@ -15,6 +15,38 @@ describe('Create Page', () => { CreatePage.waitUntilVisible(); }); + 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 }); + }); + it('should show the correct title in the appBar', () => { cy.get(CreatePage.elements.title).contains('Create Post'); }); diff --git a/cypress/support/CreatePage.js b/cypress/support/CreatePage.js index 584ef4ef0ac..80a517c7948 100644 --- a/cypress/support/CreatePage.js +++ b/cypress/support/CreatePage.js @@ -23,7 +23,7 @@ export default url => ({ title: '#react-admin-title', userMenu: 'button[aria-label="Profile"]', logout: '.logout', - nameError: '#name-helper-text', + nameError: '.MuiFormHelperText-root', }, navigate() { diff --git a/docs/Upgrade.md b/docs/Upgrade.md index 83c652f94c7..431177638e2 100644 --- a/docs/Upgrade.md +++ b/docs/Upgrade.md @@ -932,6 +932,14 @@ The `BulkActionProps` has been removed as it did not contain any prop. You can s The `data-generator-retail` package has been updated to provide types for all its records. In the process, we renamed the `commands` resource to `orders`. Accordingly, the `nb_commands` property of the `customers` resource has been renamed to `nb_orders` and the `command_id` property of the `invoices` and `reviews` resources has been renamed to `order_id`. +## Inputs default ids are auto-generated + +In previous versions, the input default id was the source of the input. In v5, inputs defaults ids are auto-generated with [React useId()](https://react.dev/reference/react/useId). + +**Tip:** You still can pass an id as prop of any [react-admin input](./Inputs.md) or use a [reference](https://fr.react.dev/reference/react/useRef). + +If you were using inputs ids in your tests, you should pass your own id to the dedicated input. + ## `` No Longer Clones Its Buttons `` used to clones the add, remove and reorder buttons and inject some props to them such as `onClick` and `className`. diff --git a/docs/useInput.md b/docs/useInput.md index 79fae12f116..bb41d2d1fbb 100644 --- a/docs/useInput.md +++ b/docs/useInput.md @@ -41,16 +41,16 @@ const TitleInput = ({ source, label }) => { ## Props -| Prop | Required | Type | Default | Description | -|----------------|----------|--------------------------------|---------|-------------------------------------------------------------------| -| `source` | Required | `string` | - | The name of the field in the record | -| `defaultValue` | Optional | `any` | - | The default value of the input | -| `format` | Optional | `Function` | - | A function to format the value from the record to the input value | -| `parse` | Optional | `Function` | - | A function to parse the value from the input to the record value | -| `validate` | Optional | `Function` | `Function[]` | - | A function or an array of functions to validate the input value | -| `id` | Optional | `string` | - | The id of the input | -| `onChange` | Optional | `Function` | - | A function to call when the input value changes | -| `onBlur` | Optional | `Function` | - | A function to call when the input is blurred | +| Prop | Required | Type | Default | Description | +|----------------|----------|--------------------------------|----------------- |-------------------------------------------------------------------| +| `source` | Required | `string` | - | The name of the field in the record | +| `defaultValue` | Optional | `any` | - | The default value of the input | +| `format` | Optional | `Function` | - | A function to format the value from the record to the input value | +| `parse` | Optional | `Function` | - | A function to parse the value from the input to the record value | +| `validate` | Optional | `Function` | `Function[]` | - | A function or an array of functions to validate the input value | +| `id` | Optional | `string` | `auto-generated` | The id of the input | +| `onChange` | Optional | `Function` | - | A function to call when the input value changes | +| `onBlur` | Optional | `Function` | - | A function to call when the input is blurred | Additional props are passed to [react-hook-form's `useController` hook](https://react-hook-form.com/docs/usecontroller). diff --git a/packages/ra-core/src/form/useInput.spec.tsx b/packages/ra-core/src/form/useInput.spec.tsx index 603416bdb6e..cfdcc35330c 100644 --- a/packages/ra-core/src/form/useInput.spec.tsx +++ b/packages/ra-core/src/form/useInput.spec.tsx @@ -58,7 +58,7 @@ describe('useInput', () => { ); - expect(inputProps.id).toEqual('title'); + expect(inputProps.id).toEqual(':r0:'); expect(inputProps.isRequired).toEqual(true); expect(inputProps.field).toBeDefined(); expect(inputProps.field.name).toEqual('title'); diff --git a/packages/ra-core/src/form/useInput.stories.tsx b/packages/ra-core/src/form/useInput.stories.tsx new file mode 100644 index 00000000000..130bef255d9 --- /dev/null +++ b/packages/ra-core/src/form/useInput.stories.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { CoreAdminContext } from '../core'; +import { Form } from './Form'; +import { useInput } from './useInput'; + +export default { + title: 'ra-core/form/useInput', +}; + +const Input = ({ source }) => { + const { id, field, fieldState } = useInput({ source }); + + return ( + + ); +}; + +export const Basic = () => { + const [submittedData, setSubmittedData] = React.useState(); + return ( + +
setSubmittedData(data)}> +
+ + + +
+ +
+
{JSON.stringify(submittedData, null, 2)}
+
+ ); +}; diff --git a/packages/ra-core/src/form/useInput.ts b/packages/ra-core/src/form/useInput.ts index f0b56bf4356..9e0eca30db3 100644 --- a/packages/ra-core/src/form/useInput.ts +++ b/packages/ra-core/src/form/useInput.ts @@ -1,4 +1,4 @@ -import { ReactElement, useEffect } from 'react'; +import { ReactElement, useEffect, useId } from 'react'; import { ControllerFieldState, ControllerRenderProps, @@ -44,6 +44,7 @@ export const useInput = ( const formGroupName = useFormGroupContext(); const formGroups = useFormGroups(); const record = useRecordContext(); + const defaultId = useId(); if ( !source && @@ -132,7 +133,7 @@ export const useInput = ( }; return { - id: id || finalSource, + id: id || defaultId, field, fieldState, formState, diff --git a/packages/ra-input-rich-text/src/RichTextInput.spec.tsx b/packages/ra-input-rich-text/src/RichTextInput.spec.tsx index 9b94f24b133..8c353d17371 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.spec.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.spec.tsx @@ -9,7 +9,7 @@ describe('', () => { const { container, rerender } = render(); await waitFor(() => { - expect(container.querySelector('#body')?.innerHTML).toEqual( + expect(container.querySelector('.ProseMirror')?.innerHTML).toEqual( '

Hello world!

' ); }); @@ -18,7 +18,7 @@ describe('', () => { rerender(); await waitFor(() => { - expect(container.querySelector('#body')?.innerHTML).toEqual( + expect(container.querySelector('.ProseMirror')?.innerHTML).toEqual( '

Goodbye world!

' ); }); diff --git a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx index 664ba9531f6..5c9374b466f 100644 --- a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx @@ -86,9 +86,10 @@ describe('', () => { ); expect(screen.queryByText('People')).not.toBeNull(); const input1 = screen.getByLabelText('Leo Tolstoi'); - expect(input1.id).toBe('type_123'); + expect(input1.id).toMatch(/:r\d:/); const input2 = screen.getByLabelText('Jane Austen'); - expect(input2.id).toBe('type_456'); + expect(input2.id).toMatch(/:r\d:/); + expect(input2.id).not.toEqual(input1.id); }); it('should trigger custom onChange when clicking radio button', async () => {