diff --git a/Makefile b/Makefile index 3e9445aab6b..a8a48cc77cd 100644 --- a/Makefile +++ b/Makefile @@ -114,7 +114,7 @@ build-create-react-admin: @echo "Transpiling create-react-admin files..."; @cd ./packages/create-react-admin && yarn build -build: build-ra-core build-ra-ui-materialui build-ra-data-fakerest build-ra-data-json-server build-ra-data-localforage build-ra-data-localstorage build-ra-data-simple-rest build-ra-data-graphql build-ra-data-graphql-simple build-ra-i18n-polyglot build-ra-input-rich-text build-data-generator build-ra-language-english build-ra-language-french build-ra-i18n-i18next build-react-admin build-ra-no-code build-create-react-admin ## compile ES6 files to JS +build: build-ra-core build-ra-data-fakerest build-ra-ui-materialui build-ra-data-json-server build-ra-data-localforage build-ra-data-localstorage build-ra-data-simple-rest build-ra-data-graphql build-ra-data-graphql-simple build-ra-i18n-polyglot build-ra-input-rich-text build-data-generator build-ra-language-english build-ra-language-french build-ra-i18n-i18next build-react-admin build-ra-no-code build-create-react-admin ## compile ES6 files to JS doc: ## compile doc as html and launch doc web server @yarn doc diff --git a/docs/AccordionForm.md b/docs/AccordionForm.md index 277136a8b3e..cdcaf5906e0 100644 --- a/docs/AccordionForm.md +++ b/docs/AccordionForm.md @@ -375,15 +375,16 @@ This component renders a [Material UI `` component](https://mui.com/c Here are all the props you can set on the `` component: -| Prop | Required | Type | Default | Description | -| ----------------- | -------- | ----------- | ------- | ------------------------------------------------------------------------------------------------ | -| `label` | Required | `string` | - | The main label used as the accordion summary. Appears in red when the accordion has errors | -| `children` | Required | `ReactNode` | - | A list of `` elements | -| `secondary` | Optional | `string` | - | The secondary label used as the accordion summary | -| `defaultExpanded` | Optional | `boolean` | `false` | Set to true to have the accordion expanded by default (except if autoClose = true on the parent) | -| `disabled` | Optional | `boolean` | `false` | If true, the accordion will be displayed in a disabled state. | -| `square` | Optional | `boolean` | `false` | If true, rounded corners are disabled. | -| `sx` | Optional | `Object` | - | An object containing the MUI style overrides to apply to the root component. | +| Prop | Required | Type | Default | Description | +| ----------------- | -------- | ----------------------- | ------- | ------------------------------------------------------------------------------------------------ | +| `children` | Required | `ReactNode` | - | A list of `` elements | +| `defaultExpanded` | Optional | `boolean` | `false` | Set to true to have the accordion expanded by default (except if autoClose = true on the parent) | +| `disabled` | Optional | `boolean` | `false` | If true, the accordion will be displayed in a disabled state. | +| `id` | Optional | `string` | - | An id for this Accordion to be used in the [`useFormGroup`](./Upgrade.md#useformgroup-hook-returned-state-has-changed) hook and for CSS classes. | +| `label` | Required | `string` or `ReactNode` | - | The main label used as the accordion summary. Appears in red when the accordion has errors | +| `secondary` | Optional | `string` or `ReactNode` | - | The secondary label used as the accordion summary | +| `square` | Optional | `boolean` | `false` | If true, rounded corners are disabled. | +| `sx` | Optional | `Object` | - | An object containing the MUI style overrides to apply to the root component. | ```tsx import { @@ -413,6 +414,8 @@ const CustomerEdit = () => ( ); ``` +**Warning**: To use an `` with the `autoClose` prop and a React node element as a `label`, you **must** specify an `id`. + ## `` Renders children (Inputs) inside a Material UI `` element without a Card style. To be used as child of a `` or a `` element. @@ -430,19 +433,20 @@ Prefer `` to `` to always display a list of imp Here are all the props you can set on the `` component: -| Prop | Required | Type | Default | Description | -| ------------------ | -------- | ----------- | ------- | ------------------------------------------------------------- | -| `Accordion` | Optional | `Component` | - | The component to use as the accordion. | -| `AccordionDetails` | Optional | `Component` | - | The component to use as the accordion details. | -| `AccordionSummary` | Optional | `Component` | - | The component to use as the accordion summary. | -| `label` | Required | `string` | - | The main label used as the accordion summary. | -| `children` | Required | `ReactNode` | - | A list of `` elements | -| `fullWidth` | Optional | `boolean` | `false` | If true, the Accordion takes the entire form width. | -| `className` | Optional | `string` | - | A class name to style the underlying `` | -| `secondary` | Optional | `string` | - | The secondary label used as the accordion summary | -| `defaultExpanded` | Optional | `boolean` | `false` | Set to true to have the accordion expanded by default | -| `disabled` | Optional | `boolean` | `false` | If true, the accordion will be displayed in a disabled state. | -| `square` | Optional | `boolean` | `false` | If true, rounded corners are disabled. | +| Prop | Required | Type | Default | Description | +| ------------------ | -------- | ----------------------- | ------- | ------------------------------------------------------------- | +| `Accordion` | Optional | `Component` | - | The component to use as the accordion. | +| `AccordionDetails` | Optional | `Component` | - | The component to use as the accordion details. | +| `AccordionSummary` | Optional | `Component` | - | The component to use as the accordion summary. | +| `children` | Required | `ReactNode` | - | A list of `` elements | +| `className` | Optional | `string` | - | A class name to style the underlying `` | +| `defaultExpanded` | Optional | `boolean` | `false` | Set to true to have the accordion expanded by default | +| `disabled` | Optional | `boolean` | `false` | If true, the accordion will be displayed in a disabled state. | +| `fullWidth` | Optional | `boolean` | `false` | If true, the Accordion takes the entire form width. | +| `id` | Optional | `string` | - | An id for this Accordion to be used for CSS classes. | +| `label` | Required | `string` or `ReactNode` | - | The main label used as the accordion summary. | +| `secondary` | Optional | `string` or `ReactNode` | - | The secondary label used as the accordion summary | +| `square` | Optional | `boolean` | `false` | If true, rounded corners are disabled. | ```tsx import { diff --git a/docs/FieldsForRelationships.md b/docs/FieldsForRelationships.md index a0ae4bbb9e4..6664c534bf7 100644 --- a/docs/FieldsForRelationships.md +++ b/docs/FieldsForRelationships.md @@ -16,6 +16,8 @@ React-admin handles relationships *regardless of the capacity of the API to mana React-admin provides helpers to fetch related records, depending on the type of relationship, and how the API implements it. + + ## One-To-Many When one record has many related records, this is called a one-to-many relationship. For instance, if an author has written several books, `authors` has a one-to-many relationship with `books`. diff --git a/docs/Inputs.md b/docs/Inputs.md index 33c4f4718fa..e3a8466e6b9 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -43,7 +43,8 @@ All input components accept the following props: | `source` | Required | `string` | - | Name of the entity property to use for the input value | | `className` | Optional | `string` | - | Class name (usually generated by JSS) to customize the look and feel of the field element itself | | `defaultValue` | Optional | `any` | - | Default value of the input. | -| `disabled` | Optional | `boolean` | - | If true, the input is disabled. | +| `readOnly` | Optional | `boolean` | `false` | If true, the input is in read-only mode. | +| `disabled` | Optional | `boolean` | `false` | If true, the input is disabled. | | `format` | Optional | `Function` | `value => value == null ? '' : value` | Callback taking the value from the form state, and returning the input value. | | `fullWidth` | Optional | `boolean` | `true` | If `false`, the input will not expand to fill the form width | | `helperText` | Optional | `string` | - | Text to be displayed under the input (cannot be used inside a filter) | @@ -150,21 +151,29 @@ export const PostCreate = () => ( ); ``` +## `readOnly` + +The `readOnly` prop set to true makes the element not mutable, meaning the user can not edit the control. + +```tsx + +``` + +Contrary to disabled controls, read-only controls are still focusable and are submitted with the form. + ## `disabled` -If `true`, the input is disabled and the user can't change the value. +The `disabled` prop set to true makes the element not mutable, focusable, or even submitted with the form. ```tsx ``` -**Tip**: The form framework used by react-admin, react-hook-form, [considers](https://github.com/react-hook-form/react-hook-form/pull/10805) that a `disabled` input shouldn't submit any value. So react-hook-form sets the value of all `disabled` inputs to `undefined`. As a consequence, a form with a `disabled` input is always considered `dirty` (i.e. react-hook-form considers that the form values and the initial record values are different), and it triggers [the `warnWhenUnsavedChanges` feature](./Forms.md#warning-about-unsaved-changes) when leaving the form, even though the user changed nothing. The workaround is to set the `disabled` prop on the underlying input component, as follows: +Contrary to read-only controls, disabled controls can not receive focus and are not submitted with the form. -{% raw %} -```jsx - -``` -{% endraw %} +**Warning:** Note that `disabled` inputs are **not** included in the form values, and hence may trigger `warnWhenUnsavedChanges` if the input previously had a value in the record. + +**Tip:** To include the input in the form values, you can use `readOnly` instead of `disabled`. ## `format` diff --git a/docs/Layout.md b/docs/Layout.md index 78ed2511c82..c340feb8d0b 100644 --- a/docs/Layout.md +++ b/docs/Layout.md @@ -369,7 +369,7 @@ This property accepts the following subclasses: To override the style of `` using the [application-wide style overrides](./AppTheme.md#theming-individual-components), use the `RaLayout` key. -**Tip**: If you need to override global styles (like the default font size or family), you should [write a custom theme](./AppTheme.md) rather than override the `` prop. And if you need to tweak the default layout to add a right column or move the menu to the top, you're probably better off [writing your own layout component](./Layout.md#writing-a-layout-from-scratch). +**Tip**: If you need to override global styles (like the default font size or family), you should [write a custom theme](./AppTheme.md#writing-a-custom-theme) rather than override the `` prop. And if you need to tweak the default layout to add a right column or move the menu to the top, you're probably better off [writing your own layout component](./Layout.md#writing-a-layout-from-scratch). ## Adding A Custom Context diff --git a/docs/ReferenceArrayField.md b/docs/ReferenceArrayField.md index 7c28b929399..df5804a09d0 100644 --- a/docs/ReferenceArrayField.md +++ b/docs/ReferenceArrayField.md @@ -7,7 +7,15 @@ title: "The ReferenceArrayField Component" Use `` to display a list of related records, via a one-to-many relationship materialized by an array of foreign keys. -![ReferenceArrayField](./img/reference-array-field.png) + + +`` fetches a list of referenced records (using the `dataProvider.getMany()` method), and puts them in a [`ListContext`](./useListContext.md). It then renders each related record, using its [`recordRepresentation`](./Resource.md#recordrepresentation), in a [``](./ChipField.md). + +**Tip**: If the relationship is materialized by a foreign key on the referenced resource, use [the `` component](./ReferenceManyField.md) instead. + +**Tip**: To edit the records of a one-to-many relationship, use [the `` component](./ReferenceArrayInput.md). + +## Usage For instance, let's consider a model where a `post` has many `tags`, materialized to a `tags_ids` field containing an array of ids: @@ -35,24 +43,9 @@ A typical `post` record therefore looks like this: } ``` -In that case, use `` to display the post tags names as follows: +In that case, use `` to display the post tag names as Chips as follows: ```jsx - -``` - -`` fetches a list of referenced records (using the `dataProvider.getMany()` method), and puts them in a [`ListContext`](./useListContext.md). It then renders each related record, using its [`recordRepresentation`](./Resource.md#recordrepresentation), in a [``](./ChipField.md). - -**Tip**: If the relationship is materialized by a foreign key on the referenced resource, use [the `` component](./ReferenceManyField.md) instead. - -## Usage - -`` expects a `reference` attribute, which specifies the resource to fetch for the related records. It also expects a `source` attribute, which defines the field containing the list of ids to look for in the referenced resource. - -For instance, if each post contains a list of tag ids (e.g. `{ id: 1234, title: 'Lorem Ipsum', tag_ids: [1, 23, 4] }`), here is how to fetch the list of tags for each post in a list, and display the `name` for each `tag` in a ``: - -```jsx -import * as React from "react"; import { List, Datagrid, ReferenceArrayField, TextField } from 'react-admin'; export const PostList = () => ( @@ -60,16 +53,20 @@ export const PostList = () => ( - + ); ``` +![ReferenceArrayField](./img/reference-array-field.png) + +`` expects a `reference` attribute, which specifies the resource to fetch for the related records. It also expects a `source` attribute, which defines the field containing the list of ids to look for in the referenced resource. + `` fetches the `tag` resources related to each `post` resource by matching `post.tag_ids` to `tag.id`. By default, it renders one string by related record, via a [``](./SingleFieldList.md) with a [``](./ChipField.md) child using the resource [`recordRepresentation`](./Resource.md#recordrepresentation) as source -Configure the `` to render related records in a meaningul way. For instance, for the `tags` resource, if you want the `` to display the tag `name`: +Configure the `` to render related records in a meaningful way. For instance, for the `tags` resource, if you want the `` to display the tag `name`: ```jsx diff --git a/docs/ReferenceField.md b/docs/ReferenceField.md index da727062c10..ed82bd81f5d 100644 --- a/docs/ReferenceField.md +++ b/docs/ReferenceField.md @@ -7,7 +7,9 @@ title: "The ReferenceField Component" `` is useful for displaying many-to-one and one-to-one relationships, e.g. the details of a user when rendering a post authored by that user. -![ReferenceField](./img/reference_field_show.png) + + +## Usage For instance, let's consider a model where a `post` has one author from the `users` resource, referenced by a `user_id` field. @@ -22,18 +24,35 @@ For instance, let's consider a model where a `post` has one author from the `use └──────────────┘ ``` -In that case, use `` to display the post author's id as follows: +In that case, use `` to display the post author's as follows: ```jsx - +import { Show, SimpleShowLayout, ReferenceField, TextField, DateField } from 'react-admin'; + +export const PostShow = () => ( + + + + + + + + +); ``` -`` fetches the data, puts it in a [`RecordContext`](./useRecordContext.md), and renders the [`recordRepresentation`](./Resource.md#recordrepresentation) (the record `id` field by default). +`` fetches the data, puts it in a [`RecordContext`](./useRecordContext.md), and renders the [`recordRepresentation`](./Resource.md#recordrepresentation) (the record `id` field by default) wrapped in a link to the related user `` page. + +![ReferenceField](./img/reference_field_show.png) So it's a good idea to configure the `` to render related records in a meaningul way. For instance, for the `users` resource, if you want the `` to display the full name of the author: ```jsx - `${record.first_name} ${record.last_name}`} /> + `${record.first_name} ${record.last_name}`} +/> ``` Alternately, if you pass a child component, `` will render it instead of the `recordRepresentation`. Usual child components for `` are other `` components (e.g. [``](./TextField.md)). @@ -48,27 +67,6 @@ This component fetches a referenced record (`users` in this example) using the ` It uses `dataProvider.getMany()` instead of `dataProvider.getOne()` [for performance reasons](#performance). When using several `` in the same page (e.g. in a ``), this allows to call the `dataProvider` once instead of once per row. -## Usage - -Here is how to render both a post and the author name in a show view: - -```jsx -import { Show, SimpleShowLayout, ReferenceField, TextField, DateField } from 'react-admin'; - -export const PostShow = () => ( - - - - - - - - -); -``` - -With this configuration, `` wraps the user's name in a link to the related user `` page. - ## Props | Prop | Required | Type | Default | Description | diff --git a/docs/ReferenceManyField.md b/docs/ReferenceManyField.md index 0ea09616b8a..832edb923d6 100644 --- a/docs/ReferenceManyField.md +++ b/docs/ReferenceManyField.md @@ -7,7 +7,15 @@ title: "The ReferenceManyField Component" `` is useful for displaying a list of related records via a one-to-many relationship, when the foreign key is carried by the referenced resource. -![referenceManyField](./img/reference_many_field.png) + + +This component fetches a list of referenced records by a reverse lookup of the current `record.id` in the `target` field of another resource (using the `dataProvider.getManyReference()` REST method), and puts them in a [`ListContext`](./useListContext.md). Its children can then use the data from this context. The most common case is to use [``](./SingleFieldList.md) or [``](./Datagrid.md) as child. + +**Tip**: If the relationship is materialized by an array of ids in the initial record, use [the `` component](./ReferenceArrayField.md) instead. + +**Tip**: To edit the records of a one-to-many relationship, use [the `` component](./ReferenceManyInput.md). + +## Usage For instance, if an `author` has many `books`, and each book resource exposes an `author_id` field: @@ -25,12 +33,14 @@ For instance, if an `author` has many `books`, and each book resource exposes an `` can render the titles of all the books by a given author. ```jsx +import { Show, SimpleShowLayout, ReferenceManyField, Datagrid, TextField, DateField } from 'react-admin'; + const AuthorShow = () => ( - + @@ -41,42 +51,13 @@ const AuthorShow = () => ( ); ``` -This component fetches a list of referenced records by a reverse lookup of the current `record.id` in the `target` field of another resource (using the `dataProvider.getManyReference()` REST method), and puts them in a [`ListContext`](./useListContext.md). Its children can then use the data from this context. The most common case is to use [``](./SingleFieldList.md) or [``](./Datagrid.md) as child. - -**Tip**: If the relationship is materialized by an array of ids in the initial record, use [the `` component](./ReferenceArrayField.md) instead. - -**Tip**: To edit the records of a one-to-many relationship, use [the `` component](./ReferenceManyInput.md). - -## Usage - -For instance, here is how to show the title of the books written by a particular author in a show view. - -```jsx -import { Show, SimpleShowLayout, TextField, ReferenceManyField, Datagrid, DateField } from 'react-admin'; - -export const AuthorShow = () => ( - - - - - - - - - - - - - -); -``` +![referenceManyField](./img/reference_many_field.png) `` accepts a `reference` attribute, which specifies the resource to fetch for the related record. It also accepts a `source` attribute which defines the field containing the value to look for in the `target` field of the referenced resource. By default, this is the `id` of the resource (`authors.id` in the previous example). You can also use `` in a list, e.g. to display the authors of the comments related to each post in a list by matching `post.id` to `comment.post_id`: ```jsx -import * as React from "react"; import { List, Datagrid, ChipField, ReferenceManyField, SingleFieldList, TextField } from 'react-admin'; export const PostList = () => ( diff --git a/docs/ReferenceManyToManyInput.md b/docs/ReferenceManyToManyInput.md index 60d4db34047..89a72d0cbd1 100644 --- a/docs/ReferenceManyToManyInput.md +++ b/docs/ReferenceManyToManyInput.md @@ -96,6 +96,7 @@ const BandEdit = () => ( ); ``` +**Limitation**: `` cannot be used to filter a list. ## Props diff --git a/docs/SimpleFormIterator.md b/docs/SimpleFormIterator.md index fd09517578c..903f9dc410d 100644 --- a/docs/SimpleFormIterator.md +++ b/docs/SimpleFormIterator.md @@ -85,6 +85,7 @@ const OrderEdit = () => ( | `inline` | Optional | `boolean` | `false` | When true, inputs are put on the same line | | `removeButton` | Optional | `ReactElement` | - | Component to render for the remove button | | `reOrderButtons` | Optional | `ReactElement` | - | Component to render for the up / down button | +| `disabled` | Optional | `boolean` | `false` | If true, all buttons are disabled. | | `sx` | Optional | `SxProps` | - | Material UI shortcut for defining custom styles | ## `addButton` @@ -345,6 +346,34 @@ const OrderEdit = () => ( ); ``` +## `readOnly` + +The `readOnly` prop set to true makes the children input not mutable, meaning the user can not edit them. + +```jsx + + + + + +``` + +Contrary to disabled controls, read-only controls are still focusable and are submitted with the form. + +## `disabled` + +The `disabled` prop set to true makes the children input not mutable, focusable, or even submitted with the form. + +```jsx + + + + + +``` + +Contrary to read-only controls, disabled controls can not receive focus and are not submitted with the form. + ## `sx` You can override the style of the root element (a `
` element) as well as those of the inner components thanks to the `sx` property (see [the `sx` documentation](./SX.md) for syntax and examples). diff --git a/docs/StackedFilters.md b/docs/StackedFilters.md index 65ae010d88e..7b070e4c893 100644 --- a/docs/StackedFilters.md +++ b/docs/StackedFilters.md @@ -66,6 +66,8 @@ export const PostListToolbar = () => ( ); ``` +You must also update your data provider to support filters with operators. See the [data provider configuration](#data-provider-configuration) section below. + ## Filters Configuration Define the filter configuration in the `` prop. The value must be an object defining the operators and UI for each field that can be used as a filter. @@ -145,6 +147,61 @@ const postListFilters: FiltersConfig = { }; ``` +## Data Provider Configuration + +In react-admin, `dataProvider.getList()` accepts a `filter` parameter to filter the records. There is no notion of *operators* in this parameter, as the expected format is an object like `{ field: value }`. As `StackedFilters` needs operators, it uses a convention to concatenate the field name and the operator with an underscore. + +For instance, if the Post resource has a `title` field, and you configure `` to allow filtering on this field as a text field, the `dataProvider.getList()` may receive the following `filter` parameter: + +- title_eq +- title_neq +- title_q + +The actual suffixes depend on the type of filter configured in `` (see [filters configuration builders](#filter-configuration-builders) above). Here is an typical call to `dataProvider.getList()` with a posts list using ``: + +```jsx +const { data } = useGetList('posts', { + filter: { + title_q: 'lorem', + date_gte: '2021-01-01', + views_eq: 0, + tags_inc_any: [1, 2], + }, + pagination: { page: 1, perPage: 10 }, + sort: { field: 'title', order: 'ASC' }, +}); +``` + +It's up to your data provider to convert the `filter` parameter into a query that your API understands. + +For instance, if your API expects filters as an array of criteria objects (`[{ field, operator, value }]`), `dataProvider.getList()` should convert the `filter` parameter as follows: + +```jsx +const dataProvider = { + // ... + getList: async (resource, params) => { + const { filter } = params; + const filterFields = Object.keys(filter); + const criteria = []; + // eq operator + filterFields.filter(field => field.endsWith('_eq')).forEach(field => { + criteria.push({ field: field.replace('_eq', ''), operator: 'eq', value: filter[field] }); + }); + // neq operator + filterFields.filter(field => field.endsWith('_neq')).forEach(field => { + criteria.push({ field: field.replace('_neq', ''), operator: 'neq', value: filter[field] }); + }); + // q operator + filterFields.filter(field => field.endsWith('_q')).forEach(field => { + criteria.push({ field: field.replace('_q', ''), operator: 'q', value: filter[field] }); + }); + // ... + }, +} +``` + +Few of the [existing data providers](./DataProviderList.md) implement this convention. this means you'll probably have to adapt your data provider to support the operators used by ``. + ## Props | Prop | Required | Type | Default | Description | diff --git a/docs/TranslatableInputs.md b/docs/TranslatableInputs.md index fa2efe1f92b..c604b74be27 100644 --- a/docs/TranslatableInputs.md +++ b/docs/TranslatableInputs.md @@ -197,4 +197,4 @@ You can add validators to any of the inputs inside a `TranslatableInputs`. If an -``` \ No newline at end of file +``` diff --git a/docs/useNotify.md b/docs/useNotify.md index 72418c84a50..47faa67d20f 100644 --- a/docs/useNotify.md +++ b/docs/useNotify.md @@ -149,6 +149,7 @@ notify('This is an error', { type: 'error' }); When using `useNotify` as a side effect for an `undoable` mutation, you MUST set the `undoable` option to `true`, otherwise the "undo" button will not appear, and the actual update will never occur. +{% raw %} ```jsx import * as React from 'react'; import { useNotify, Edit, SimpleForm } from 'react-admin'; @@ -169,6 +170,7 @@ const PostEdit = () => { ); } ``` +{% endraw %} ## Custom Notification Content diff --git a/packages/ra-core/src/form/useInput.ts b/packages/ra-core/src/form/useInput.ts index 381d47fb59b..9d3da50b689 100644 --- a/packages/ra-core/src/form/useInput.ts +++ b/packages/ra-core/src/form/useInput.ts @@ -168,6 +168,8 @@ export type InputProps = Omit< resource?: string; source: string; validate?: Validator | Validator[]; + readOnly?: boolean; + disabled?: boolean; }; export type UseInputValue = { diff --git a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx index 9a9a2753718..f7fff5d7f6b 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx @@ -84,6 +84,19 @@ export const Disabled = (props: Partial) => ( ); +export const ReadOnly = (props: Partial) => ( + + {}} + {...props} + > + + + + +); + export const Small = (props: Partial) => ( { icon = defaultIcon, sx, className, + resource: resourceProp, } = props; - const { resource, sort, setSort } = useListSortContext(); + const { + resource: resourceFromContext, + sort, + setSort, + } = useListSortContext(); + const resource = resourceProp || resourceFromContext; const translate = useTranslate(); const translateLabel = useTranslateLabel(); const isXSmall = useMediaQuery((theme: Theme) => diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx index d4467c1e0c0..996d7d5174e 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx @@ -248,6 +248,66 @@ describe('', () => { }); }); + it('should not clear errors of children when unmounted', async () => { + let setArrayInputVisible; + + const MyArrayInput = () => { + const [visible, setVisible] = React.useState(true); + + setArrayInputVisible = setVisible; + + return visible ? ( + + + + + + + ) : null; + }; + + render( + + + ({ + arr: [{ foo: 'Must be "baz"' }, {}], + })} + > + + + + + ); + + // change one input to enable the SaveButton (which is disabled when the form is pristine) + fireEvent.change( + screen.getAllByLabelText('resources.bar.fields.arr.id')[0], + { + target: { value: '42' }, + } + ); + fireEvent.click(await screen.findByLabelText('ra.action.save')); + + await screen.findByText('Must be "baz"'); + + setArrayInputVisible(false); + await waitFor(() => { + expect(screen.queryByText('Must be "baz"')).toBeNull(); + }); + + // ensure errors are still there after re-mount + setArrayInputVisible(true); + await screen.findByText('Must be "baz"'); + }); + it('should allow to have a helperText', () => { render( diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.stories.tsx index 84b00298104..d90681669ba 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.stories.tsx @@ -101,6 +101,40 @@ export const Disabled = () => ( + + + + + + ); + }} + /> + + +); + +export const ReadOnly = () => ( + + + { + return ( + { + console.log(data); + }, + }} + > + + + + + + + @@ -664,8 +698,8 @@ const BookEditGlobalValidation = () => { }} > - {/* - We still need `validate={required()}` to indicate fields are required + {/* + We still need `validate={required()}` to indicate fields are required with a '*' symbol after the label, but the real validation happens in `globalValidator` */} @@ -696,8 +730,8 @@ const CreateGlobalValidationInFormTab = () => { }} > - {/* - We still need `validate={required()}` to indicate fields are required + {/* + We still need `validate={required()}` to indicate fields are required with a '*' symbol after the label, but the real validation happens in `globalValidator` */} diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx index 6875a03c945..d7576bd5a7f 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx @@ -86,6 +86,7 @@ export const ArrayInput = (props: ArrayInputProps) => { validate, variant, disabled, + readOnly, margin = 'dense', ...rest } = props; @@ -128,7 +129,12 @@ export const ArrayInput = (props: ArrayInputProps) => { formGroups.registerField(finalSource, formGroupName); return () => { - unregister(finalSource, { keepValue: true }); + unregister(finalSource, { + keepValue: true, + keepError: true, + keepDirty: true, + keepTouched: true, + }); formGroups && formGroupName != null && formGroups.unregisterField(finalSource, formGroupName); diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.stories.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.stories.tsx index c325a5ef55f..2f61d77d026 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.stories.tsx @@ -86,6 +86,38 @@ export const Inline = () => ( ); +export const ReadOnly = () => ( + + + + + + + + + + + + + +); + +export const Disabled = () => ( + + + + + + + + + + + + + +); + export const DisableAdd = () => ( diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.tsx index f99beb08059..cd723e527aa 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.tsx @@ -239,6 +239,7 @@ export interface SimpleFormIteratorProps extends Partial { addButton?: ReactElement; children?: ReactNode; className?: string; + readOnly?: boolean; disabled?: boolean; disableAdd?: boolean; disableClear?: boolean; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx index 20c0ed399d9..3f0d228df0d 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx @@ -833,7 +833,7 @@ describe('', () => { selector: 'input', }) ); - expect(screen.queryAllByRole('option')).toHaveLength(2); + expect(screen.queryAllByRole('option')).toHaveLength(3); expect(screen.getByText('Technical')).not.toBeNull(); expect(screen.getByText('Programming')).not.toBeNull(); }); @@ -880,7 +880,9 @@ describe('', () => { }); expect(screen.getByText('Technical')).not.toBeNull(); expect(screen.getByText('Programming')).not.toBeNull(); - expect(screen.getByText('ra.action.create_item')).not.toBeNull(); + await waitFor(() => { + expect(screen.getByText('ra.action.create_item')).not.toBeNull(); + }); }); it('should support creation of a new choice through the onCreate event when optionText is a function', async () => { @@ -1108,8 +1110,9 @@ describe('', () => { ] ); }); + screen.getByRole('combobox').blur(); expect(screen.getByDisplayValue('Russian')).not.toBeNull(); - screen.getAllByRole('combobox')[0].focus(); + screen.getByRole('combobox').focus(); fireEvent.click(await screen.findByText('Victor Hugo')); await waitFor(() => { expect(onChange).toHaveBeenCalledWith( diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.stories.tsx index 5a76f9161c7..58f00e54049 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.stories.tsx @@ -61,6 +61,62 @@ export const Basic = () => ( ); +export const ReadOnly = () => ( + + + + + + + + +); + +export const Disabled = () => ( + + + + + + + + +); + export const OnChange = ({ onChange = (value, records) => console.log({ value, records }), }: Pick) => ( diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx index 4f55b89f48c..c7a681dbebd 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx @@ -81,7 +81,7 @@ export const AutocompleteArrayInput = < {...props} multiple - defaultValue={defaultValue ?? []} + defaultValue={defaultValue ?? (props.disabled ? undefined : [])} /> ); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx index fd8d43a71e6..42a3ca97794 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx @@ -1052,198 +1052,341 @@ describe('', () => { }); }); - it('should support creation of a new choice through the onCreate event', async () => { - const choices = [ - { id: 'ang', name: 'Angular' }, - { id: 'rea', name: 'React' }, - ]; - const handleCreate = filter => { - const newChoice = { - id: 'js_fatigue', - name: filter, + describe('onCreate', () => { + it('should include an option with the createLabel when the input is empty', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const handleCreate = filter => { + const newChoice = { + id: 'js_fatigue', + name: filter, + }; + choices.push(newChoice); + return newChoice; }; - choices.push(newChoice); - return newChoice; - }; - const { rerender } = render( - - - - - - ); + render( + + + + + + ); - const input = screen.getByLabelText( - 'resources.posts.fields.language' - ) as HTMLInputElement; - input.focus(); - fireEvent.change(input, { target: { value: 'New Kid On The Block' } }); - fireEvent.click(screen.getByText('ra.action.create_item')); - await new Promise(resolve => setTimeout(resolve)); - rerender( - - - - - - ); - expect( - screen.queryByDisplayValue('New Kid On The Block') - ).not.toBeNull(); - fireEvent.click(screen.getByLabelText('ra.action.clear_input_value')); - fireEvent.blur(input); - fireEvent.focus(input); - expect(screen.queryByText('New Kid On The Block')).not.toBeNull(); - }); + const input = screen.getByLabelText( + 'resources.posts.fields.language' + ) as HTMLInputElement; + input.focus(); + fireEvent.change(input, { + target: { value: '' }, + }); - it('should support creation of a new choice through the onCreate event with a promise', async () => { - const choices = [ - { id: 'ang', name: 'Angular' }, - { id: 'rea', name: 'React' }, - ]; - const handleCreate = filter => { - return new Promise(resolve => { + expect(screen.queryByText('ra.action.create')).not.toBeNull(); + expect(screen.queryByText('ra.action.create_item')).toBeNull(); + }); + it('should include an option with the createItemLabel when the input not empty', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const handleCreate = filter => { const newChoice = { id: 'js_fatigue', name: filter, }; choices.push(newChoice); - setTimeout(() => resolve(newChoice), 100); - }); - }; + return newChoice; + }; - const { rerender } = render( - - - - - - ); + render( + + + + + + ); - const input = screen.getByLabelText( - 'resources.posts.fields.language' - ) as HTMLInputElement; - input.focus(); - fireEvent.change(input, { target: { value: 'New Kid On The Block' } }); - fireEvent.click(screen.getByText('ra.action.create_item')); - await new Promise(resolve => setTimeout(resolve, 100)); - rerender( - - - - - - ); - expect( - screen.queryByDisplayValue('New Kid On The Block') - ).not.toBeNull(); - fireEvent.click(screen.getByLabelText('ra.action.clear_input_value')); - fireEvent.blur(input); - fireEvent.focus(input); - expect(screen.queryByText('New Kid On The Block')).not.toBeNull(); - }); + const input = screen.getByLabelText( + 'resources.posts.fields.language' + ) as HTMLInputElement; + input.focus(); + fireEvent.change(input, { + target: { value: 'foo' }, + }); + + expect(screen.queryByText('ra.action.create')).toBeNull(); + expect(screen.queryByText('ra.action.create_item')).not.toBeNull(); + }); + it('should not include a create option when the input matches an option', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const handleCreate = filter => { + const newChoice = { + id: 'js_fatigue', + name: filter, + }; + choices.push(newChoice); + return newChoice; + }; - it('should support creation of a new choice through the create element', async () => { - const choices = [ - { id: 'ang', name: 'Angular' }, - { id: 'rea', name: 'React' }, - ]; - const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; + render( + + + + + + ); - const Create = () => { - const context = useCreateSuggestionContext(); - const handleClick = () => { + const input = screen.getByLabelText( + 'resources.posts.fields.language' + ) as HTMLInputElement; + input.focus(); + fireEvent.change(input, { + target: { value: 'React' }, + }); + expect(screen.queryByText('ra.action.create')).toBeNull(); + expect(screen.queryByText('ra.action.create_item')).toBeNull(); + }); + it('should allow the creation of a new choice', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const handleCreate = filter => { + const newChoice = { + id: 'js_fatigue', + name: filter, + }; choices.push(newChoice); - context.onCreate(newChoice); + return newChoice; }; - return ; - }; + const { rerender } = render( + + + + + + ); - const { rerender } = render( - - - } - /> - - - ); + const input = screen.getByLabelText( + 'resources.posts.fields.language' + ) as HTMLInputElement; + input.focus(); + fireEvent.change(input, { + target: { value: 'New Kid On The Block' }, + }); + fireEvent.click(screen.getByText('ra.action.create_item')); + await new Promise(resolve => setTimeout(resolve)); + rerender( + + + + + + ); + expect( + screen.queryByDisplayValue('New Kid On The Block') + ).not.toBeNull(); + fireEvent.click( + screen.getByLabelText('ra.action.clear_input_value') + ); + fireEvent.blur(input); + fireEvent.focus(input); + expect(screen.queryByText('New Kid On The Block')).not.toBeNull(); + }); - const input = screen.getByLabelText( - 'resources.posts.fields.language' - ) as HTMLInputElement; - fireEvent.change(input, { target: { value: 'New Kid On The Block' } }); - fireEvent.click(screen.getByText('ra.action.create_item')); - fireEvent.click(screen.getByText('Get the kid')); - await new Promise(resolve => setTimeout(resolve)); - rerender( - - - } - /> - - - ); - expect( - screen.queryByDisplayValue('New Kid On The Block') - ).not.toBeNull(); - fireEvent.click(screen.getByLabelText('ra.action.clear_input_value')); - fireEvent.blur(input); - fireEvent.focus(input); - expect(screen.queryByText('New Kid On The Block')).not.toBeNull(); + it('should allow the creation of a new choice with a promise', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const handleCreate = filter => { + return new Promise(resolve => { + const newChoice = { + id: 'js_fatigue', + name: filter, + }; + choices.push(newChoice); + setTimeout(() => resolve(newChoice), 100); + }); + }; + + const { rerender } = render( + + + + + + ); + + const input = screen.getByLabelText( + 'resources.posts.fields.language' + ) as HTMLInputElement; + input.focus(); + fireEvent.change(input, { + target: { value: 'New Kid On The Block' }, + }); + fireEvent.click(screen.getByText('ra.action.create_item')); + await new Promise(resolve => setTimeout(resolve, 100)); + rerender( + + + + + + ); + expect( + screen.queryByDisplayValue('New Kid On The Block') + ).not.toBeNull(); + fireEvent.click( + screen.getByLabelText('ra.action.clear_input_value') + ); + fireEvent.blur(input); + fireEvent.focus(input); + expect(screen.queryByText('New Kid On The Block')).not.toBeNull(); + }); + }); + describe('create', () => { + it('should allow the creation of a new choice', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const newChoice = { + id: 'js_fatigue', + name: 'New Kid On The Block', + }; + + const Create = () => { + const context = useCreateSuggestionContext(); + const handleClick = () => { + choices.push(newChoice); + context.onCreate(newChoice); + }; + + return ; + }; + + const { rerender } = render( + + + } + /> + + + ); + + const input = screen.getByLabelText( + 'resources.posts.fields.language' + ) as HTMLInputElement; + fireEvent.change(input, { + target: { value: 'New Kid On The Block' }, + }); + fireEvent.click(screen.getByText('ra.action.create_item')); + fireEvent.click(screen.getByText('Get the kid')); + await new Promise(resolve => setTimeout(resolve)); + rerender( + + + } + /> + + + ); + expect( + screen.queryByDisplayValue('New Kid On The Block') + ).not.toBeNull(); + fireEvent.click( + screen.getByLabelText('ra.action.clear_input_value') + ); + fireEvent.blur(input); + fireEvent.focus(input); + expect(screen.queryByText('New Kid On The Block')).not.toBeNull(); + }); }); it('should return null when no choice is selected', async () => { diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx index 2ab26f2c2a5..a7044798416 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx @@ -110,6 +110,40 @@ export const Basic = ({ onSuccess = console.log }) => ( ); +export const ReadOnly = () => ( + + + + +); + +export const Disabled = () => ( + + + + +); + export const Required = () => ( ( { - if (filter) { + const newAuthorName = window.prompt( + 'Enter a new author', + filter + ); + + if (newAuthorName) { const newAuthor = { id: choicesForCreationSupport.length + 1, - name: filter, + name: newAuthorName, }; choicesForCreationSupport.push(newAuthor); return newAuthor; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index f96f5c5be1c..88910a97f75 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -175,6 +175,8 @@ export const AutocompleteInput = < validate, variant, onInputChange, + disabled, + readOnly, ...rest } = props; @@ -218,6 +220,8 @@ export const AutocompleteInput = < resource, source, validate, + disabled, + readOnly, ...rest, }); @@ -505,13 +509,16 @@ If you provided a React element for the optionText prop, you must also provide t // add create option if necessary const { inputValue } = params; - // FIXME pass the allowCreate: true option to useCreateSuggestions instead - if ( - (onCreate || create) && - inputValue !== '' && - !doesQueryMatchSuggestion(filterValue) - ) { - filteredOptions = filteredOptions.concat(getCreateItem(inputValue)); + if (onCreate || create) { + if (inputValue === '') { + // create option with createLabel + filteredOptions = filteredOptions.concat(getCreateItem('')); + } else if (!doesQueryMatchSuggestion(filterValue)) { + filteredOptions = filteredOptions.concat( + // create option with createItemLabel + getCreateItem(inputValue) + ); + } } return filteredOptions; @@ -550,7 +557,6 @@ If you provided a React element for the optionText prop, you must also provide t return ( <> { const mergedTextFieldProps = { + readOnly, ...params.InputProps, ...TextFieldProps?.InputProps, }; @@ -591,6 +599,7 @@ If you provided a React element for the optionText prop, you must also provide t variant={variant} className={AutocompleteInputClasses.textField} {...params} + {...TextFieldProps} InputProps={mergedTextFieldProps} size={size} /> diff --git a/packages/ra-ui-materialui/src/input/BooleanInput.stories.tsx b/packages/ra-ui-materialui/src/input/BooleanInput.stories.tsx index 60bbb8e22f3..8c7f52c0b59 100644 --- a/packages/ra-ui-materialui/src/input/BooleanInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/BooleanInput.stories.tsx @@ -24,6 +24,12 @@ export const Disabled = () => ( ); +export const ReadOnly = () => ( + + + +); + export const CustomIcon = () => ( } /> diff --git a/packages/ra-ui-materialui/src/input/BooleanInput.tsx b/packages/ra-ui-materialui/src/input/BooleanInput.tsx index 2698f736bd3..6b661b78517 100644 --- a/packages/ra-ui-materialui/src/input/BooleanInput.tsx +++ b/packages/ra-ui-materialui/src/input/BooleanInput.tsx @@ -23,6 +23,7 @@ export const BooleanInput = (props: BooleanInputProps) => { onBlur, onChange, onFocus, + readOnly, disabled, parse, resource, @@ -47,6 +48,8 @@ export const BooleanInput = (props: BooleanInputProps) => { onChange, type: 'checkbox', validate, + disabled, + readOnly, ...rest, }); @@ -78,7 +81,8 @@ export const BooleanInput = (props: BooleanInputProps) => { checked={Boolean(field.value)} {...sanitizeInputRestProps(rest)} {...options} - disabled={disabled} + disabled={disabled || readOnly} + readOnly={readOnly} /> } label={ diff --git a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.stories.tsx b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.stories.tsx index f66857c0262..df0972afeea 100644 --- a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.stories.tsx @@ -103,6 +103,24 @@ export const InsideReferenceArrayInput = () => ( ); +export const ReadOnly = () => ( + + + + + + + +); + export const Disabled = () => ( ))} diff --git a/packages/ra-ui-materialui/src/input/CommonInputProps.ts b/packages/ra-ui-materialui/src/input/CommonInputProps.ts index 4cab53bc3c4..1127bc76965 100644 --- a/packages/ra-ui-materialui/src/input/CommonInputProps.ts +++ b/packages/ra-ui-materialui/src/input/CommonInputProps.ts @@ -2,8 +2,10 @@ import { InputProps } from 'ra-core'; export type CommonInputProps = InputProps & { cellClassName?: string; + disabled?: boolean; fullWidth?: boolean; headerCellClassName?: string; margin?: 'none' | 'dense' | 'normal'; + readOnly?: boolean; variant?: 'standard' | 'outlined' | 'filled'; }; diff --git a/packages/ra-ui-materialui/src/input/DatagridInput.tsx b/packages/ra-ui-materialui/src/input/DatagridInput.tsx index 6789534f6fc..078e3c73c10 100644 --- a/packages/ra-ui-materialui/src/input/DatagridInput.tsx +++ b/packages/ra-ui-materialui/src/input/DatagridInput.tsx @@ -157,7 +157,10 @@ export const DatagridInput = (props: DatagridInputProps) => { ); }; -export type DatagridInputProps = Omit & +export type DatagridInputProps = Omit< + CommonInputProps, + 'source' | 'readOnly' | 'disabled' +> & ChoicesProps & Omit & DatagridProps & { diff --git a/packages/ra-ui-materialui/src/input/DateInput.stories.tsx b/packages/ra-ui-materialui/src/input/DateInput.stories.tsx index cec441c2f83..870c67f2a13 100644 --- a/packages/ra-ui-materialui/src/input/DateInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/DateInput.stories.tsx @@ -26,6 +26,14 @@ export const NonFullWidth = () => ( export const Disabled = () => ( + + +); + +export const ReadOnly = () => ( + + + ); diff --git a/packages/ra-ui-materialui/src/input/DateInput.tsx b/packages/ra-ui-materialui/src/input/DateInput.tsx index fc023e434db..1d75c097a3c 100644 --- a/packages/ra-ui-materialui/src/input/DateInput.tsx +++ b/packages/ra-ui-materialui/src/input/DateInput.tsx @@ -44,6 +44,8 @@ export const DateInput = ({ parse, validate, variant, + disabled, + readOnly, ...rest }: DateInputProps) => { const { field, fieldState, id, isRequired } = useInput({ @@ -56,6 +58,8 @@ export const DateInput = ({ resource, source, validate, + disabled, + readOnly, ...rest, }); @@ -72,6 +76,8 @@ export const DateInput = ({ variant={variant} margin={margin} error={invalid} + disabled={disabled || readOnly} + readOnly={readOnly} helperText={ renderHelperText ? ( ( export const Disabled = () => ( + + +); + +export const ReadOnly = () => ( + + + ); diff --git a/packages/ra-ui-materialui/src/input/DateTimeInput.tsx b/packages/ra-ui-materialui/src/input/DateTimeInput.tsx index 8ff0afce1cb..16282ad1f9a 100644 --- a/packages/ra-ui-materialui/src/input/DateTimeInput.tsx +++ b/packages/ra-ui-materialui/src/input/DateTimeInput.tsx @@ -34,6 +34,8 @@ export const DateTimeInput = ({ parse = parseDateTime, validate, variant, + disabled, + readOnly, ...rest }: DateTimeInputProps) => { const { field, fieldState, id, isRequired } = useInput({ @@ -45,6 +47,8 @@ export const DateTimeInput = ({ resource, source, validate, + disabled, + readOnly, ...rest, }); @@ -60,6 +64,8 @@ export const DateTimeInput = ({ variant={variant} margin={margin} error={invalid} + disabled={disabled || readOnly} + readOnly={readOnly} helperText={ renderHelperText ? ( ( export const Disabled = () => ( - + + + + + +); + +export const ReadOnly = () => ( + + diff --git a/packages/ra-ui-materialui/src/input/FileInput.tsx b/packages/ra-ui-materialui/src/input/FileInput.tsx index 20bcaf9eeb2..74d41265f73 100644 --- a/packages/ra-ui-materialui/src/input/FileInput.tsx +++ b/packages/ra-ui-materialui/src/input/FileInput.tsx @@ -21,6 +21,7 @@ import { Labeled } from '../Labeled'; import { FileInputPreview } from './FileInputPreview'; import { sanitizeInputRestProps } from './sanitizeInputRestProps'; import { InputHelperText } from './InputHelperText'; +import { useTheme } from '@mui/material/styles'; import { SxProps } from '@mui/system'; import { SvgIconProps } from '@mui/material'; @@ -47,6 +48,8 @@ export const FileInput = (props: FileInputProps) => { source, validate, validateFileRemoval, + disabled, + readOnly, ...rest } = props; const { onDrop: onDropProp } = options; @@ -90,6 +93,8 @@ export const FileInput = (props: FileInputProps) => { parse: parse || transformFiles, source, validate, + disabled, + readOnly, ...rest, }); const { error, invalid } = fieldState; @@ -145,12 +150,15 @@ export const FileInput = (props: FileInputProps) => { maxSize, minSize, multiple, + disabled: disabled || readOnly, ...options, onDrop, }); const renderHelperText = helperText !== false || invalid; + const theme = useTheme(); + return ( { resource={resource} isRequired={isRequired} color={invalid ? 'error' : undefined} + sx={{ + cursor: disabled || readOnly ? 'default' : 'pointer', + ...rest.sx, + }} {...sanitizeInputRestProps(rest)} > <> @@ -167,6 +179,17 @@ export const FileInput = (props: FileInputProps) => { {...getRootProps({ className: FileInputClasses.dropZone, 'data-testid': 'dropzone', + style: { + color: + disabled || readOnly + ? theme.palette.text.disabled + : inputPropsOptions?.color || + theme.palette.text.primary, + backgroundColor: + disabled || readOnly + ? theme.palette.action.disabledBackground + : inputPropsOptions?.backgroundColor, + }, })} > ( export const Disabled = () => ( - + + + + + +); + +export const ReadOnly = () => ( + + diff --git a/packages/ra-ui-materialui/src/input/ImageInput.tsx b/packages/ra-ui-materialui/src/input/ImageInput.tsx index c5e39d7d8ae..7030271c837 100644 --- a/packages/ra-ui-materialui/src/input/ImageInput.tsx +++ b/packages/ra-ui-materialui/src/input/ImageInput.tsx @@ -24,7 +24,6 @@ const StyledFileInput = styled(FileInput, { background: theme.palette.background.default, borderRadius: theme.shape.borderRadius, fontFamily: theme.typography.fontFamily, - cursor: 'pointer', padding: theme.spacing(1), textAlign: 'center', color: theme.palette.getContrastText(theme.palette.background.default), diff --git a/packages/ra-ui-materialui/src/input/NullableBooleanInput.stories.tsx b/packages/ra-ui-materialui/src/input/NullableBooleanInput.stories.tsx index 1530a27d4ab..92504870f32 100644 --- a/packages/ra-ui-materialui/src/input/NullableBooleanInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/NullableBooleanInput.stories.tsx @@ -18,10 +18,18 @@ export const Basic = () => ( export const Disabled = () => ( + ); +export const ReadOnly = () => ( + + + + +); + const i18nProvider = polyglotI18nProvider(() => englishMessages); const Wrapper = ({ children }) => ( diff --git a/packages/ra-ui-materialui/src/input/NullableBooleanInput.tsx b/packages/ra-ui-materialui/src/input/NullableBooleanInput.tsx index 5cee5c56614..85c0b1548f6 100644 --- a/packages/ra-ui-materialui/src/input/NullableBooleanInput.tsx +++ b/packages/ra-ui-materialui/src/input/NullableBooleanInput.tsx @@ -20,6 +20,8 @@ export const NullableBooleanInput = (props: NullableBooleanInputProps) => { onChange, parse = getBooleanFromString, resource, + disabled, + readOnly, source, validate, variant, @@ -44,6 +46,8 @@ export const NullableBooleanInput = (props: NullableBooleanInputProps) => { resource, source, validate, + disabled, + readOnly, ...rest, }); const renderHelperText = helperText !== false || invalid; @@ -59,6 +63,8 @@ export const NullableBooleanInput = (props: NullableBooleanInputProps) => { className )} select + disabled={disabled || readOnly} + readOnly={readOnly} margin={margin} label={ { }; export type NullableBooleanInputProps = CommonInputProps & - Omit & { + Omit & { nullLabel?: string; falseLabel?: string; trueLabel?: string; diff --git a/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx b/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx index 937d28780c8..72e4d3f9b16 100644 --- a/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx @@ -30,6 +30,40 @@ export const Basic = () => ( ); +export const ReadOnly = () => ( + + + + + + + + + + +); + +export const Disabled = () => ( + + + + + + + + + + +); + export const Float = () => ( { const { @@ -50,6 +52,8 @@ export const NumberInput = ({ resource, source, validate, + disabled, + readOnly, ...rest, }); const { onBlur: onBlurFromField } = field; @@ -134,6 +138,8 @@ export const NumberInput = ({ size="small" variant={variant} error={invalid} + disabled={disabled || readOnly} + readOnly={readOnly} helperText={ renderHelperText ? ( } margin={margin} - inputProps={inputProps} + inputProps={{ ...inputProps, readOnly }} {...sanitizeInputRestProps(rest)} /> ); diff --git a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.stories.tsx b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.stories.tsx index 59ece817855..e34958c19ac 100644 --- a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.stories.tsx @@ -26,6 +26,18 @@ export const Basic = () => ( ); +export const Disabled = () => ( + + + +); + +export const ReadOnly = () => ( + + + +); + export const Row = () => ( { source: sourceProp, translateChoice, validate, + disabled, + readOnly, ...rest } = props; @@ -146,6 +148,8 @@ export const RadioButtonGroupInput = (props: RadioButtonGroupInputProps) => { resource, source, validate, + disabled, + readOnly, ...rest, }); @@ -176,6 +180,8 @@ export const RadioButtonGroupInput = (props: RadioButtonGroupInputProps) => { className={clsx('ra-input', `ra-input-${source}`, className)} margin={margin} error={fetchError || invalid} + disabled={disabled || readOnly} + readOnly={readOnly} {...sanitizeRestProps(rest)} > - // @ts-ignore Promise.resolve({ data: tags, total: tags.length, diff --git a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx index 804b018f663..0394f49622c 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx @@ -66,7 +66,7 @@ import { AutocompleteInput } from './AutocompleteInput'; export const ReferenceInput = (props: ReferenceInputProps) => { const { children = defaultChildren, ...rest } = props; - if (props.validate) { + if (props.validate && process.env.NODE_ENV !== 'production') { throw new Error( ' does not accept a validate prop. Set the validate prop on the child instead.' ); diff --git a/packages/ra-ui-materialui/src/input/ResettableTextField.tsx b/packages/ra-ui-materialui/src/input/ResettableTextField.tsx index db8bb800ab4..53ec23e5a28 100644 --- a/packages/ra-ui-materialui/src/input/ResettableTextField.tsx +++ b/packages/ra-ui-materialui/src/input/ResettableTextField.tsx @@ -22,6 +22,7 @@ export const ResettableTextField = forwardRef( value, resettable, disabled, + readOnly, variant, margin, className, @@ -119,7 +120,7 @@ export const ResettableTextField = forwardRef( title={translate('ra.action.clear_input_value')} onClick={handleClickClearButton} onMouseDown={handleMouseDownClearButton} - disabled={disabled} + disabled={disabled || readOnly} size="large" > { interface Props { clearAlwaysVisible?: boolean; resettable?: boolean; + readOnly?: boolean; } export type ResettableTextFieldProps = Props & diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx index 5038ee88297..f0fb580db80 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx @@ -6,6 +6,8 @@ import { Dialog, DialogActions, DialogContent, + Stack, + Box, TextField, } from '@mui/material'; import fakeRestProvider from 'ra-data-fakerest'; @@ -71,6 +73,148 @@ export const Basic = () => ( ); +export const Disabled = () => ( + + + + + + + + + + + + + + + + + + +); + +export const ReadOnly = () => ( + + + + + + + + + + + + + + + + + + +); + export const DefaultValue = () => ( diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx index 45bea592cd0..653228150fe 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx @@ -115,6 +115,8 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => { translateChoice, validate, variant, + disabled, + readOnly, ...rest } = props; @@ -149,6 +151,8 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => { resource, source, validate, + disabled, + readOnly, ...rest, }); @@ -338,6 +342,8 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => { ))}
)} + disabled={disabled || readOnly} + readOnly={readOnly} data-testid="selectArray" size={size} {...field} diff --git a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx index 15cee12bfa7..65f30581d5b 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx @@ -83,6 +83,37 @@ export const Disabled = () => ( ]} disabled /> + + +); + +export const ReadOnly = () => ( + + + ); diff --git a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx index 0474ff269ec..745fa958b84 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx @@ -38,6 +38,38 @@ export const Resettable = () => ( ); +export const Disabled = () => ( + + + + + + + + + +); + +export const ReadOnly = () => ( + + + + + + + + + +); + export const DefaultValue = () => ( diff --git a/packages/ra-ui-materialui/src/input/TimeInput.stories.tsx b/packages/ra-ui-materialui/src/input/TimeInput.stories.tsx index 9e9cc17ceca..77cca9595d6 100644 --- a/packages/ra-ui-materialui/src/input/TimeInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/TimeInput.stories.tsx @@ -25,6 +25,13 @@ export const NonFullWidth = () => ( export const Disabled = () => ( + + +); +export const ReadOnly = () => ( + + + ); diff --git a/packages/ra-ui-materialui/src/input/TimeInput.tsx b/packages/ra-ui-materialui/src/input/TimeInput.tsx index 2342fd8fa03..9c0765e2dc6 100644 --- a/packages/ra-ui-materialui/src/input/TimeInput.tsx +++ b/packages/ra-ui-materialui/src/input/TimeInput.tsx @@ -54,6 +54,8 @@ export const TimeInput = ({ onChange, source, resource, + disabled, + readOnly, parse = parseTime, validate, variant, @@ -68,6 +70,8 @@ export const TimeInput = ({ resource, source, validate, + readOnly, + disabled, ...rest, }); @@ -85,6 +89,8 @@ export const TimeInput = ({ variant={variant} margin={margin} error={invalid} + disabled={disabled || readOnly} + readOnly={readOnly} helperText={ renderHelperText ? ( ', () => { render(); // Open Posts List - fireEvent.click(await screen.findByText('Posts')); + userEvent.click(await screen.findByText('Posts')); await waitFor(() => { expect(screen.queryAllByRole('checkbox')).toHaveLength(11); }); - fireEvent.click(await screen.findByLabelText('Open')); - fireEvent.click(await screen.findByText('Sint...')); + userEvent.click(await screen.findByLabelText('Open')); + userEvent.click(await screen.findByText('Sint...')); await screen.findByLabelText('Add filter'); + expect(screen.queryAllByText('Close')).toHaveLength(0); await waitFor( () => { expect(screen.getAllByRole('checkbox')).toHaveLength(2); }, { timeout: 10000 } ); - fireEvent.click(screen.getByLabelText('Add filter')); - fireEvent.click(await screen.findByText('Remove all filters')); + userEvent.click(screen.getByLabelText('Add filter')); + userEvent.click(await screen.findByText('Remove all filters')); - await waitFor(() => { - expect(screen.getAllByRole('checkbox')).toHaveLength(11); - }); + await waitFor( + () => { + expect(screen.getAllByRole('checkbox')).toHaveLength(11); + }, + { timeout: 10000 } + ); - fireEvent.click(await screen.findByLabelText('Open')); - fireEvent.click(await screen.findByText('Sint...')); + userEvent.click(await screen.findByLabelText('Open')); + userEvent.click(await screen.findByText('Sint...')); await waitFor( () => { expect(screen.getAllByRole('checkbox')).toHaveLength(2); }, - { timeout: 4000 } + { timeout: 10000 } ); expect(screen.queryByText('Save current query...')).toBeNull(); diff --git a/yarn.lock b/yarn.lock index 11eac332e55..3776596ad5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13096,9 +13096,9 @@ __metadata: linkType: hard "ip@npm:^1.1.5": - version: 1.1.5 - resolution: "ip@npm:1.1.5" - checksum: 877e98d676cd8d0ca01fee8282d11b91fb97be7dd9d0b2d6d98e161db2d4277954f5b55db7cfc8556fe6841cb100d13526a74f50ab0d83d6b130fe8445040175 + version: 1.1.9 + resolution: "ip@npm:1.1.9" + checksum: 5af58bfe2110c9978acfd77a2ffcdf9d33a6ce1c72f49edbaf16958f7a8eb979b5163e43bb18938caf3aaa55cdacde4e470874c58ca3b4b112ea7a30461a0c27 languageName: node linkType: hard