diff --git a/CHANGELOG.md b/CHANGELOG.md index 290cf6f4852..2264ae243af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,37 @@ # Changelog +## v3.14.5 + +* Fix `` and `` are broken on Safari ([6199](https://github.com/marmelab/react-admin/pull/6199)) ([djhi](https://github.com/djhi)) +* Fix `` undo button's color on success type ([6193](https://github.com/marmelab/react-admin/pull/6193)) ([WiXSL](https://github.com/WiXSL)) +* [TypeScript] Publish `data-generator typings` ([6204](https://github.com/marmelab/react-admin/pull/6204)) ([floo51](https://github.com/floo51)) +* [TypeScript] Fix `ra-data-local-storage` types ([6203](https://github.com/marmelab/react-admin/pull/6203)) ([djhi](https://github.com/djhi)) +* [TypeScript] Fix view action component types aren't exported ([6200](https://github.com/marmelab/react-admin/pull/6200)) ([djhi](https://github.com/djhi)) +* [TypeScript] Fix sidebar width type in application theme ([6197](https://github.com/marmelab/react-admin/pull/6197)) ([jtomaszewski](https://github.com/jtomaszewski)) +* [Doc] Add OData data provider ([6206](https://github.com/marmelab/react-admin/pull/6206)) ([jvert](https://github.com/jvert)) +* [Doc] Update tutorial images ([6205](https://github.com/marmelab/react-admin/pull/6205)) ([fzaninotto](https://github.com/fzaninotto)) +* [Doc] Fix custom fields documentation doesn't use `useRecordContext` ([6201](https://github.com/marmelab/react-admin/pull/6201)) ([djhi](https://github.com/djhi)) + +## v3.14.4 + +* Fix `useGetMany` does not respect the `enabled` option ([6188](https://github.com/marmelab/react-admin/pull/6188)) ([djhi](https://github.com/djhi)) +* Fix 'Cannot set property validating of undefined' error when conditionally rendering a form component ([6186](https://github.com/marmelab/react-admin/pull/6186)) ([ThieryMichel](https://github.com/ThieryMichel)) +* Fix `useWarnWhenUsavedChanges` fails on nested fields ([6185](https://github.com/marmelab/react-admin/pull/6185)) ([djhi](https://github.com/djhi)) +* Fix warning when using `` without props ([6165](https://github.com/marmelab/react-admin/pull/6165)) ([fzaninotto](https://github.com/fzaninotto)) +* Fix Menu icon isn't aligned with the sidebar icons ([6161](https://github.com/marmelab/react-admin/pull/6161)) ([JayKaku](https://github.com/JayKaku)) +* Fix missing query string after successful login ([6129](https://github.com/marmelab/react-admin/pull/6129)) ([makbol](https://github.com/makbol)) +* [Doc] Add link to Google Sheet data provider ([6187](https://github.com/marmelab/react-admin/pull/6187)) ([fzaninotto](https://github.com/fzaninotto)) +* [Doc] Fix missing documentation about the ResourceContext ([6183](https://github.com/marmelab/react-admin/pull/6183)) ([fzaninotto](https://github.com/fzaninotto)) +* [Doc] Fix broken link to source in Testing Permissions documentation ([6181](https://github.com/marmelab/react-admin/pull/6181)) ([YashJipkate](https://github.com/YashJipkate)) +* [Doc] Fix typo in `` usage JSDoc ([6169](https://github.com/marmelab/react-admin/pull/6169)) ([WiXSL](https://github.com/WiXSL)) +* [Doc] Fix typo in `withDataProvider` hook example ([6160](https://github.com/marmelab/react-admin/pull/6160)) ([f-jost](https://github.com/f-jost)) +* [Doc] Fix outdated link for Swedish translation ([6156](https://github.com/marmelab/react-admin/pull/6156)) ([kolben](https://github.com/kolben)) + ## v3.14.3 * Fix `` prop doesn't accept value `center` ([6152](https://github.com/marmelab/react-admin/pull/6152)) ([WiXSL](https://github.com/WiXSL)) * Fix runtime warnings when `` displays skeleton while loading ([6146](https://github.com/marmelab/react-admin/pull/6146)) ([fzaninotto](https://github.com/fzaninotto)) +* Fix `useRedirect` does not handle query strings ([6145](https://github.com/marmelab/react-admin/pull/6145)) ([fzaninotto](https://github.com/fzaninotto)) * Fix logout notification may appear more than once ([6144](https://github.com/marmelab/react-admin/pull/6144)) ([fzaninotto](https://github.com/fzaninotto)) * Fix submit errors cannot have translation arguments ([6140](https://github.com/marmelab/react-admin/pull/6140)) ([djhi](https://github.com/djhi)) * Fix `` emits runtime warnings ([6139](https://github.com/marmelab/react-admin/pull/6139)) ([djhi](https://github.com/djhi)) diff --git a/cypress/integration/auth.js b/cypress/integration/auth.js index 8a5454808cb..94c4fbfad53 100644 --- a/cypress/integration/auth.js +++ b/cypress/integration/auth.js @@ -30,4 +30,21 @@ describe('Authentication', () => { ListPage.navigate(); cy.url().then(url => expect(url).to.contain('/#/posts')); }); + + it('should redirect to initial url keeping query string', () => { + let urlBeforeLogout; + + ListPage.navigate(); + ListPage.addCommentableFilter(); + cy.url().then(url => { + urlBeforeLogout = url; + }); + ListPage.setAsNonLogged(); + cy.reload(); + LoginPage.login('login', 'password'); + cy.url().then(urlAfterLogin => { + expect(urlAfterLogin).to.contain(urlBeforeLogout); + }); + ListPage.commentableFilter().should('exist'); + }); }); diff --git a/cypress/support/ListPage.js b/cypress/support/ListPage.js index 01b14cf7eb9..70cd3bc19eb 100644 --- a/cypress/support/ListPage.js +++ b/cypress/support/ListPage.js @@ -4,6 +4,7 @@ export default url => ({ appLoader: '.app-loader', displayedRecords: '.displayed-records', filter: name => `.filter-field[data-source='${name}'] input`, + filterButton: name => `.filter-field[data-source='${name}']`, filterMenuItems: `.new-filter-item`, menuItems: `[role=menuitem]`, filterMenuItem: source => `.new-filter-item[data-key="${source}"]`, @@ -63,6 +64,15 @@ export default url => ({ return cy.get(this.elements.pageNumber(n)).click({ force: true }); }, + addCommentableFilter() { + this.openFilters(); + cy.get(this.elements.filterMenuItem('commentable')).click(); + }, + + commentableFilter() { + return cy.get(this.elements.filterButton('commentable')); + }, + setFilterValue(name, value, clearPreviousValue = true) { cy.get(this.elements.filter(name)); if (clearPreviousValue) { @@ -88,6 +98,12 @@ export default url => ({ cy.get(this.elements.logout).click(); }, + setAsNonLogged() { + cy.window().then(win => { + win.localStorage.setItem('not_authenticated', true); + }); + }, + toggleSelectAll() { cy.get(this.elements.selectAll).click(); }, diff --git a/docs/Actions.md b/docs/Actions.md index 224ea887d24..effe4812e0a 100644 --- a/docs/Actions.md +++ b/docs/Actions.md @@ -867,7 +867,7 @@ const ApproveButton = ({ record }) => { export default ApproveButton; ``` -And here is the `` component using the `withDataProvider` HOC instead of the `useProvider` hook: +And here is the `` component using the `withDataProvider` HOC instead of the `useDataProvider` hook: ```diff import { useState, useEffect } from 'react'; diff --git a/docs/DataProviders.md b/docs/DataProviders.md index 59ad9b707da..eee52c2f745 100644 --- a/docs/DataProviders.md +++ b/docs/DataProviders.md @@ -76,6 +76,7 @@ Developers from the react-admin community have open-sourced Data Providers for m * **[Feathersjs](https://www.feathersjs.com/)**: [josx/ra-data-feathers](https://github.com/josx/ra-data-feathers) * **[Firebase Firestore](https://firebase.google.com/docs/firestore)**: [benwinding/react-admin-firebase](https://github.com/benwinding/react-admin-firebase). * **[Firebase Realtime Database](https://firebase.google.com/docs/database)**: [aymendhaya/ra-data-firebase-client](https://github.com/aymendhaya/ra-data-firebase-client). +* **[Google Sheets](https://www.google.com/sheets/about/)**: [marmelab/ra-data-google-sheets](https://github.com/marmelab/ra-data-google-sheets) * **[GraphQL](https://graphql.org/)**: [marmelab/ra-data-graphql](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-graphql) (uses [Apollo](https://www.apollodata.com/)) * **[HAL](http://stateless.co/hal_specification.html)**: [b-social/ra-data-hal](https://github.com/b-social/ra-data-hal) * **[Hasura](https://github.com/hasura/graphql-engine)**: [hasura/ra-data-hasura](https://github.com/hasura/ra-data-hasura), auto generates valid GraphQL queries based on the properties exposed by the Hasura API. @@ -97,6 +98,7 @@ Developers from the react-admin community have open-sourced Data Providers for m * **[Prisma](https://github.com/weakky/ra-data-prisma)**: [weakky/ra-data-prisma](https://github.com/weakky/ra-data-prisma) * **[Prisma Version 2](https://www.prisma.io/)**: [panter/ra-data-prisma](https://github.com/panter/ra-data-prisma) * **[ProcessMaker3](https://www.processmaker.com/)**: [ckoliber/ra-data-processmaker3](https://github.com/ckoliber/ra-data-processmaker3) +* **[OData](https://www.odata.org/)**: [Groopit/ra-data-odata-server](https://github.com/Groopit/ra-data-odata-server) * **[OpenCRUD](https://www.opencrud.org/)**: [weakky/ra-data-opencrud](https://github.com/Weakky/ra-data-opencrud) * **[REST-HAPI](https://github.com/JKHeadley/rest-hapi)**: [ra-data-rest-hapi](https://github.com/mkg20001/ra-data-rest-hapi) * **[Sails.js](https://sailsjs.com/)**: [mpampin/ra-data-json-sails](https://github.com/mpampin/ra-data-json-sails) diff --git a/docs/Fields.md b/docs/Fields.md index eb8688d218a..191342d98b7 100644 --- a/docs/Fields.md +++ b/docs/Fields.md @@ -746,8 +746,6 @@ TagsField.defaultProps = { ## Reference Fields - - ### `` `` is useful for displaying many-to-one and one-to-one relationships. This component fetches a referenced record (using the `dataProvider.getMany()` method), and passes it to its child. A `` displays nothing on its own, it just fetches the data and expects its child to render it. Usual child components for `` are other `` components. @@ -1364,6 +1362,85 @@ PriceField.defaultProps = { ``` {% endraw %} +## Writing Your Own Field Component + +If you don't find what you need in the list above, you can write your own Field component. It must be a regular React component, accepting a `source` attribute and retrieving the `record` from the `RecordContext` with the `useRecordContext` hook. React-admin will set the `record` in this context based on the API response data at render time. The field component only needs to find the `source` in the `record` and display it. + +For instance, here is an equivalent of react-admin's `` component: + +```jsx +import * as React from "react"; +import PropTypes from 'prop-types'; +import { useRecordContext } from 'react-admin'; + +const TextField = (props) => { + const { source } = props; + const record = useRecordContext(props); + return {record[source]}; +} + +TextField.propTypes = { + label: PropTypes.string, + record: PropTypes.object, + source: PropTypes.string.isRequired, +}; + +export default TextField; +``` + +**Tip**: The `label` attribute isn't used in the `render()` method, but react-admin uses it to display the table header. + +**Tip**: If you want to support deep field sources (e.g. source values like `author.name`), use [lodash/get](https://www.npmjs.com/package/lodash.get) to replace the simple object lookup: + +```jsx +import * as React from "react"; +import PropTypes from 'prop-types'; +import get from 'lodash/get'; +import { useRecordContext } from 'react-admin'; + +const TextField = (props) => { + const { source } = props; + const record = useRecordContext(props); + + return {get(record, source)}; +} +``` + +If you are not looking for reusability, you can create even simpler components, with no attributes. Let's say an API returns user records with `firstName` and `lastName` properties, and that you want to display a full name in a user list. + +```js +{ + id: 123, + firstName: 'John', + lastName: 'Doe' +} +``` + +The component will be: + +```jsx +import * as React from "react"; +import { List, Datagrid, TextField, useRecordContext } from 'react-admin'; + +const FullNameField = () => { + const record = useRecordContext(props); + + return {record.firstName} {record.lastName}; +} + +FullNameField.defaultProps = { label: 'Name' }; + +export const UserList = (props) => ( + + + + + +); +``` + +**Tip**: In such custom fields, the `source` is optional. React-admin uses it to determine which column to use for sorting when the column header is clicked. In case you use the `source` property for additional purposes, the sorting can be overridden by the `sortBy` property on any `Field` component. + ### Adding A Label To Custom Field Components When you use one of the react-admin `Field` components in an `Edit`, `Create` or `Show` view, react-admin includes a label on top of the field value, as in the following example: @@ -1373,11 +1450,14 @@ When you use one of the react-admin `Field` components in an `Edit`, `Create` or For your custom fields, however, the label doesn't appear by default. You need to opt in this feature by setting the `addLabel` prop to `true` in the `defaultProps`. ```diff -const FullNameField = ({ record = {} }) => ( - - {record.firstName} {record.lastName} - -); +const FullNameField = (props) => { + const record = useRecordContext(props); + return ( + + {record.firstName} {record.lastName} + + ); +} FullNameField.defaultProps = { label: 'Name', @@ -1392,10 +1472,10 @@ If you don't use any of these layouts, the `addLabel` trick won't work. You'll h ```jsx import { Labeled } from 'react-admin'; -const MyShowLayout = ({ record }) => ( +const MyShowLayout = () => (
- +
); @@ -1406,7 +1486,7 @@ You can also leverage the default label resolution mechanism by providing the `r ```jsx import { Labeled } from 'react-admin'; -const MyShowLayout = ({ record }) => ( +const MyShowLayout = () => (
@@ -1425,10 +1505,12 @@ For such cases, you can use the custom field approach: use the injected `record` import * as React from "react"; import { EmailField } from 'react-admin'; -const ConditionalEmailField = ({ record, ...rest }) => - record && record.hasEmail - ? +const ConditionalEmailField = (props) => { + const record = useRecordContext(props); + return record && record.hasEmail + ? : null; +} export default ConditionalEmailField; ``` @@ -1443,14 +1525,17 @@ One solution is to add the label manually in the custom component: import * as React from "react"; import { Labeled, EmailField } from 'react-admin'; -const ConditionalEmailField = ({ record, ...rest }) => - record && record.hasEmail +const ConditionalEmailField = (props) => { + const record = useRecordContext(props); + + return record && record.hasEmail ? ( - + ) : null; +} export default ConditionalEmailField; ``` @@ -1525,75 +1610,16 @@ const UserShow = props => ( And now you can use a regular Field component, and the label displays correctly in the Show view. -## Writing Your Own Field Component - -If you don't find what you need in the list above, you can write your own Field component. It must be a regular React component, accepting not only a `source` attribute, but also a `record` attribute. React-admin will inject the `record` based on the API response data at render time. The field component only needs to find the `source` in the `record` and display it. - -For instance, here is an equivalent of react-admin's `` component: - -```jsx -import * as React from "react"; -import PropTypes from 'prop-types'; - -const TextField = ({ source, record = {} }) => {record[source]}; - -TextField.propTypes = { - label: PropTypes.string, - record: PropTypes.object, - source: PropTypes.string.isRequired, -}; - -export default TextField; -``` - -**Tip**: The `label` attribute isn't used in the `render()` method, but react-admin uses it to display the table header. - -**Tip**: If you want to support deep field sources (e.g. source values like `author.name`), use [lodash/get](https://www.npmjs.com/package/lodash.get) to replace the simple object lookup: - -```jsx -import get from 'lodash/get'; -const TextField = ({ source, record = {} }) => {get(record, source)}; -``` - -If you are not looking for reusability, you can create even simpler components, with no attributes. Let's say an API returns user records with `firstName` and `lastName` properties, and that you want to display a full name in a user list. - -```js -{ - id: 123, - firstName: 'John', - lastName: 'Doe' -} -``` - -The component will be: - -```jsx -import * as React from "react"; -import { List, Datagrid, TextField } from 'react-admin'; - -const FullNameField = ({ record = {} }) => {record.firstName} {record.lastName}; -FullNameField.defaultProps = { label: 'Name' }; - -export const UserList = (props) => ( - - - - - -); -``` - -**Tip**: In such custom fields, the `source` is optional. React-admin uses it to determine which column to use for sorting when the column header is clicked. In case you use the `source` property for additional purposes, the sorting can be overridden by the `sortBy` property on any `Field` component. - ### Linking to other records Your custom Field component might need to display a link to another record. React Admin provides a `linkToRecord(basePath, id[, linkType])` method for this purpose. ```js -import { linkToRecord } from 'react-admin'; +import { linkToRecord, useRecordContext } from 'react-admin'; import { Link } from 'react-router-dom'; -const MyCustomField = ({ record: post }) => { +const MyCustomField = () => { + const post = useRecordContext(props); const linkToUser = linkToRecord('/users', post.user_id, 'show'); return {seller.username}; diff --git a/docs/Resource.md b/docs/Resource.md index 418dcea7647..985acf971ca 100644 --- a/docs/Resource.md +++ b/docs/Resource.md @@ -5,33 +5,21 @@ title: "The Resource Component" # The `` component -A `` component maps one API endpoint to a CRUD interface. For instance, the following admin app offers a read-only interface to the resources exposed by the JSONPlaceholder API at [https://jsonplaceholder.typicode.com/posts](https://jsonplaceholder.typicode.com/posts) and [https://jsonplaceholder.typicode.com/users](https://jsonplaceholder.typicode.com/users): +`` components are fundamental building blocks in react-admin apps. They form the skeleton of the application, and of its internal data store. -```jsx -// in src/App.js -import * as React from "react"; -import { Admin, Resource } from 'react-admin'; -import jsonServerProvider from 'ra-data-json-server'; +In react-admin terms, a *resource* is a string that refers to an entity type (like 'products', 'subscribers', or 'tags'). *Records* are objects with an `id` field, and two records of the same *resource* have the same field structure (e.g. all posts records have a title, a publication date, etc). -import { PostList } from './posts'; -import { UserList } from './users'; +A `` component has 3 responsibilities: -const App = () => ( - - - - -); -``` +- It defines the page components to use for interacting with the resource records (to display a list of records, the details of a record, or to create a new one). +- It initializes the internal data store so that react-admin components can see it as a mirror of the API for a given resource. +- It creates a context that lets every descendent component know in which resource they are used (this context is called `ResourceContext`). -`` allows you to define a component for each CRUD operation, using the following prop names: +`` components can only be used as children of [the `` component](./Admin.md). -* `list` (if defined, the resource is displayed on the Menu) -* `create` -* `edit` -* `show` +## Basic Usage -Here is a more complete admin, with components for all the CRUD operations: +For instance, the following admin app offers an interface to the resources exposed by the JSONPlaceholder API ([posts](https://jsonplaceholder.typicode.com/posts), [users](https://jsonplaceholder.typicode.com/users), [comments](https://jsonplaceholder.typicode.com/comments), and [tags](https://jsonplaceholder.typicode.com/tags)): ```jsx import * as React from "react"; @@ -44,32 +32,25 @@ import { CommentList, CommentEdit, CommentCreate, CommentIcon } from './comments const App = () => ( - + {/* complete CRUD pages for posts */} + + {/* read-only user list */} + {/* no show page for the comments resource */} + {/* no standalone page for tags, but the resource is required to display tags in posts */} ); ``` -**Tip**: Under the hood, the `` component uses [react-router](https://reactrouter.com/web/guides/quick-start) to create several routes: - -* `/` maps to the `list` component -* `/create` maps to the `create` component -* `/:id` maps to the `edit` component -* `/:id/show` maps to the `show` component - -**Tip**: You must add a `` when you declare a reference (via ``, ``, ``, `` or ``), because react-admin uses resources to define the data store structure. That's why there is an empty `tag` resource in the example above. +**Tip**: You must add a `` when you declare a reference (via ``, ``, ``, `` or ``), because react-admin uses resources to define the data store structure. That's why there is an empty `tags` resource in the example above. -`` also accepts additional props: - -* [`name`](#name) -* [`icon`](#icon) -* [`options`](#icon) +**Tip**: How does a resource map to an API endpoint? The `` component doesn't know this mapping - it's [the `dataProvider`'s job](./DataProviders.md) to define it. ## `name` -React-admin uses the `name` prop both to determine the API endpoint (passed to the `dataProvider`), and to form the URL for the resource. +`name` is the only required prop for a ``. React-admin uses the `name` prop both to determine the API endpoint (passed to the `dataProvider`), and to form the URL for the resource. ```jsx @@ -84,7 +65,29 @@ The routing will map the component as follows: * `/posts/:id` maps to `PostEdit` * `/posts/:id/show` maps to `PostShow` -**Tip**: If you want to use a special API endpoint (e.g. 'https://jsonplaceholder.typicode.com/my-custom-posts-endpoint') without altering the URL in the react-admin application (so still use `/posts`), write the mapping from the resource `name` (`posts`) to the API endpoint (`my-custom-posts-endpoint`) in your own [`dataProvider`](./Admin.md#dataprovider) +**Tip**: If you want to use a special API endpoint (e.g. 'https://jsonplaceholder.typicode.com/my-custom-posts-endpoint') without altering the URL in the react-admin application (so still use `/posts`), write the mapping from the resource `name` (`posts`) to the API endpoint (`my-custom-posts-endpoint`) in your own [`dataProvider`](./Admin.md#dataprovider). + +## CRUD Props + +`` allows you to define a component for each CRUD operation, using the following prop names: + +* `list` (if defined, the resource is displayed on the Menu) +* `create` +* `edit` +* `show` + +**Tip**: Under the hood, the `` component uses [react-router](https://reactrouter.com/web/guides/quick-start) to create several routes: + +* `/` maps to the `list` component +* `/create` maps to the `create` component +* `/:id` maps to the `edit` component +* `/:id/show` maps to the `show` component + +`` also accepts additional props: + +* [`name`](#name) +* [`icon`](#icon) +* [`options`](#icon) ## `icon` @@ -117,3 +120,42 @@ const App = () => ( ``` {% endraw %} + +## Resource Context + +`` also creates a `RecordContext`, that gives access to the current resource name to all descendents of the main page components (`list`, `create`, `edit`, `show`). + +to read the current resource name, use the `useResourceContext()` hook. + +For instance, the following component displays the name of the current resource: + +```jsx +const ResourceName = () => { + const { resource } = useResourceContext(); + return <>{resource}; +} + +const PostList = (props) => ( + + <> + {/* renders 'posts' */} + + + + + <> + +) +``` + +**Tip**: You can *change* the current resource context, e.g. to use a component designed for a related resource inside another entity. Use the `` component for that: + +```jsx +const MyComponent = () => ( + + {/* renders 'comments' */} + ... + +); +``` + diff --git a/docs/Translation.md b/docs/Translation.md index 102b47977c2..67f4c3972c1 100644 --- a/docs/Translation.md +++ b/docs/Translation.md @@ -195,7 +195,7 @@ You can find translation packages for the following languages: - Russian (`ru`): [klucherev/ra-language-russian](https://github.com/klucherev/ra-language-russian) - Slovak (`sk`): [zavadpe/ra-language-slovak](https://github.com/zavadpe/ra-language-slovak) - Spanish (`es`): [blackboxvision/ra-language-spanish](https://github.com/BlackBoxVision/ra-language-spanish) -- Swedish (`sv`): [jolixab/ra-language-swedish](https://github.com/jolixab/ra-language-swedish) +- Swedish (`sv`): [kolben/ra-language-swedish](https://github.com/kolben/ra-language-swedish) - Turkish (`tr`): [KamilGunduz/ra-language-turkish](https://github.com/KamilGunduz/ra-language-turkish) - Ukrainian (`ua`): [koresar/ra-language-ukrainian](https://github.com/koresar/ra-language-ukrainian) - Vietnamese (`vi`): [hieunguyendut/ra-language-vietnamese](https://github.com/hieunguyendut/ra-language-vietnamese) diff --git a/docs/Tutorial.md b/docs/Tutorial.md index bd1b037768d..fe51be2784e 100644 --- a/docs/Tutorial.md +++ b/docs/Tutorial.md @@ -225,18 +225,23 @@ export const UserList = props => ( ![Url Field](./img/tutorial_url_field.png) -In react-admin, fields are simple React components. At runtime, they receive the `record` fetched from the API (e.g. `{ "id": 2, "name": "Ervin Howell", "website": "anastasia.net", ... }`), and the `source` field they should display (e.g. `website`). +In react-admin, fields are simple React components. At runtime, they grab the `record` fetched from the API (e.g. `{ "id": 2, "name": "Ervin Howell", "website": "anastasia.net", ... }`) with a custom hook, and use the `source` field (e.g. `website`) to get the value they should display (e.g. "anastasia.net"). That means that writing a custom Field component is really straightforward. For instance, here is a simplified version of the `UrlField`: ```jsx // in src/MyUrlField.js import * as React from "react"; +import { useRecordContext } from 'react-admin'; -const MyUrlField = ({ record = {}, source }) => - - {record[source]} - ; +const MyUrlField = ({ source }) => { + const record = useRecordContext(); + return record ? ( + + {record[source]} + + ) : null; +} export default MyUrlField; ``` @@ -274,6 +279,7 @@ The `MyUrlField` component is a perfect opportunity to illustrate how to customi ```jsx // in src/MyUrlField.js import * as React from "react"; +import { useRecordContext } from 'react-admin'; import { makeStyles } from '@material-ui/core/styles'; import LaunchIcon from '@material-ui/icons/Launch'; @@ -283,18 +289,20 @@ const useStyles = makeStyles({ }, icon: { width: '0.5em', + height: '0.5em', paddingLeft: 2, }, }); -const MyUrlField = ({ record = {}, source }) => { +const MyUrlField = ({ source }) => { + const record = useRecordContext(); const classes = useStyles(); - return ( + return record ? ( {record[source]} - ); + ) : null; } export default MyUrlField; diff --git a/docs/UnitTesting.md b/docs/UnitTesting.md index 906bf487784..c1209df0995 100644 --- a/docs/UnitTesting.md +++ b/docs/UnitTesting.md @@ -93,7 +93,7 @@ As explained on the [Auth Provider chapter](./Authentication.md#authorization), In order to avoid regressions and make the design explicit to your co-workers, it's better to unit test which fields are supposed to be displayed or hidden for each permission. -Here is an example with Jest and TestingLibrary, which is testing the [`UserShow` page of the simple example](https://github.com/marmelab/react-admin/blob/master/examples/simple/src/users/UserShow.js). +Here is an example with Jest and TestingLibrary, which is testing the [`UserShow` page of the simple example](https://github.com/marmelab/react-admin/blob/master/examples/simple/src/users/UserShow.tsx). ```jsx // UserShow.spec.js diff --git a/docs/img/tutorial_custom_styles.png b/docs/img/tutorial_custom_styles.png index a1dbbc4e803..cc4863fecdd 100644 Binary files a/docs/img/tutorial_custom_styles.png and b/docs/img/tutorial_custom_styles.png differ diff --git a/docs/img/tutorial_edit_guesser.gif b/docs/img/tutorial_edit_guesser.gif index 8f6d9b6cc07..f7b19e94565 100644 Binary files a/docs/img/tutorial_edit_guesser.gif and b/docs/img/tutorial_edit_guesser.gif differ diff --git a/docs/img/tutorial_guessed_list.png b/docs/img/tutorial_guessed_list.png index 7ae0f537075..af9ffabecec 100644 Binary files a/docs/img/tutorial_guessed_list.png and b/docs/img/tutorial_guessed_list.png differ diff --git a/docs/img/tutorial_guessed_post_list.png b/docs/img/tutorial_guessed_post_list.png index f2051c680be..453e82e0404 100644 Binary files a/docs/img/tutorial_guessed_post_list.png and b/docs/img/tutorial_guessed_post_list.png differ diff --git a/docs/img/tutorial_list_user_name.png b/docs/img/tutorial_list_user_name.png index bb8c381310d..3826b0dff95 100644 Binary files a/docs/img/tutorial_list_user_name.png and b/docs/img/tutorial_list_user_name.png differ diff --git a/docs/img/tutorial_post_list_less_columns.png b/docs/img/tutorial_post_list_less_columns.png index b996096e94c..5b0408d435a 100644 Binary files a/docs/img/tutorial_post_list_less_columns.png and b/docs/img/tutorial_post_list_less_columns.png differ diff --git a/docs/img/tutorial_url_field.png b/docs/img/tutorial_url_field.png index 46f7b04d015..48ff7680301 100644 Binary files a/docs/img/tutorial_url_field.png and b/docs/img/tutorial_url_field.png differ diff --git a/docs/img/tutorial_users_list.png b/docs/img/tutorial_users_list.png index 6c84686f854..6f52b6ac4f9 100644 Binary files a/docs/img/tutorial_users_list.png and b/docs/img/tutorial_users_list.png differ diff --git a/docs/img/tutorial_users_list_selected_columns.png b/docs/img/tutorial_users_list_selected_columns.png index 1eefb698e5d..7bb7363fb84 100644 Binary files a/docs/img/tutorial_users_list_selected_columns.png and b/docs/img/tutorial_users_list_selected_columns.png differ diff --git a/examples/data-generator/package.json b/examples/data-generator/package.json index bee84e75f2e..66ec329cca3 100644 --- a/examples/data-generator/package.json +++ b/examples/data-generator/package.json @@ -1,6 +1,6 @@ { "name": "data-generator-retail", - "version": "3.11.3", + "version": "3.14.5", "homepage": "https://github.com/marmelab/react-admin/tree/master/examples/data-generator", "bugs": "https://github.com/marmelab/react-admin/issues", "license": "MIT", diff --git a/examples/data-generator/tsconfig.json b/examples/data-generator/tsconfig.json index 8be9b276492..04f238f9657 100644 --- a/examples/data-generator/tsconfig.json +++ b/examples/data-generator/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "lib", - "rootDir": "src" + "rootDir": "src", + "declaration": true, + "declarationMap": true, }, "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.spec.js"], "include": ["src"] diff --git a/examples/simple/src/comments/CommentEdit.tsx b/examples/simple/src/comments/CommentEdit.tsx index f480054ede6..7986f9817c0 100644 --- a/examples/simple/src/comments/CommentEdit.tsx +++ b/examples/simple/src/comments/CommentEdit.tsx @@ -75,6 +75,7 @@ const CommentEdit = props => { record={record} save={save} version={version} + warnWhenUnsavedChanges > ( <> - + diff --git a/examples/tutorial/.env b/examples/tutorial/.env new file mode 100644 index 00000000000..6f809cc2540 --- /dev/null +++ b/examples/tutorial/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true diff --git a/examples/tutorial/.gitignore b/examples/tutorial/.gitignore index d30f40ef442..23b2bb76657 100644 --- a/examples/tutorial/.gitignore +++ b/examples/tutorial/.gitignore @@ -15,6 +15,7 @@ .env.development.local .env.test.local .env.production.local +.eslintcache npm-debug.log* yarn-debug.log* diff --git a/lerna.json b/lerna.json index 9ea3543cc87..240e9d625c5 100644 --- a/lerna.json +++ b/lerna.json @@ -4,5 +4,5 @@ "examples/data-generator", "packages/*" ], - "version": "3.14.3" + "version": "3.14.5" } diff --git a/package.json b/package.json index e6865354fd6..080eb268d45 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "lint": "eslint --ext .js,.ts,.tsx \"./packages/**/src/**/*.{js,ts,tsx}\" \"./examples/**/src/**/*.{js,ts,tsx}\" \"./cypress/**/*.{js,ts,tsx}\"", "prettier": "prettier --config ./.prettierrc.js --write --list-different \"packages/*/src/**/*.{js,json,ts,tsx,css,md}\" \"examples/*/src/**/*.{js,ts,json,tsx,css,md}\" \"cypress/**/*.{js,ts,json,tsx,css,md}\"", "run-simple": "cd examples/simple && yarn -s start", - "run-tutorial": "yarn run -s build && cd examples/tutorial && yarn -s start", + "run-tutorial": "cd examples/tutorial && yarn -s start", "run-demo": "cd examples/demo && cross-env REACT_APP_DATA_PROVIDER=rest yarn -s start", "run-graphql-demo": "cd examples/demo && cross-env REACT_APP_DATA_PROVIDER=graphql yarn -s start", "run-demo-watch": "concurrently \"yarn run watch\" \"yarn run run-demo\"", diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 36debc467b8..0bbac672e67 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -1,6 +1,6 @@ { "name": "ra-core", - "version": "3.14.3", + "version": "3.14.5", "description": "Core components of react-admin, a frontend Framework for building admin applications on top of REST services, using ES6, React", "files": [ "*.md", @@ -40,7 +40,7 @@ "final-form": "^4.20.2", "history": "^4.7.2", "ignore-styles": "~5.0.1", - "ra-test": "^3.14.3", + "ra-test": "^3.14.5", "react": "^17.0.0", "react-dom": "^17.0.0", "react-final-form": "^6.5.2", diff --git a/packages/ra-core/src/auth/Authenticated.spec.tsx b/packages/ra-core/src/auth/Authenticated.spec.tsx index 12b4126777f..8e135089ad3 100644 --- a/packages/ra-core/src/auth/Authenticated.spec.tsx +++ b/packages/ra-core/src/auth/Authenticated.spec.tsx @@ -58,7 +58,10 @@ describe('', () => { type: 'RA/CLEAR_STATE', }); expect(history.location.pathname).toEqual('/login'); - expect(history.location.state).toEqual({ nextPathname: '/' }); + expect(history.location.state).toEqual({ + nextPathname: '/', + nextSearch: '', + }); }); }); }); diff --git a/packages/ra-core/src/auth/useAuthenticated.spec.tsx b/packages/ra-core/src/auth/useAuthenticated.spec.tsx index c31f5fc5d06..ab5e70393e0 100644 --- a/packages/ra-core/src/auth/useAuthenticated.spec.tsx +++ b/packages/ra-core/src/auth/useAuthenticated.spec.tsx @@ -100,7 +100,10 @@ describe('useAuthenticated', () => { type: 'RA/CLEAR_STATE', }); expect(history.location.pathname).toEqual('/login'); - expect(history.location.state).toEqual({ nextPathname: '/' }); + expect(history.location.state).toEqual({ + nextPathname: '/', + nextSearch: '', + }); }); }); }); diff --git a/packages/ra-core/src/auth/useLogin.ts b/packages/ra-core/src/auth/useLogin.ts index 3015879b0a5..8d5c120eaed 100644 --- a/packages/ra-core/src/auth/useLogin.ts +++ b/packages/ra-core/src/auth/useLogin.ts @@ -35,6 +35,7 @@ const useLogin = (): Login => { const history = useHistory(); const dispatch = useDispatch(); const nextPathName = locationState && locationState.nextPathname; + const nextSearch = locationState && locationState.nextSearch; const login = useCallback( (params: any = {}, pathName) => @@ -42,11 +43,12 @@ const useLogin = (): Login => { dispatch(resetNotification()); const redirectUrl = pathName ? pathName - : nextPathName || defaultAuthParams.afterLoginUrl; + : nextPathName + nextSearch || + defaultAuthParams.afterLoginUrl; history.push(redirectUrl); return ret; }), - [authProvider, history, nextPathName, dispatch] + [authProvider, history, nextPathName, nextSearch, dispatch] ); const loginWithoutProvider = useCallback( diff --git a/packages/ra-core/src/auth/useLogout.ts b/packages/ra-core/src/auth/useLogout.ts index 2f9854b7313..8185871354c 100644 --- a/packages/ra-core/src/auth/useLogout.ts +++ b/packages/ra-core/src/auth/useLogout.ts @@ -69,6 +69,7 @@ const useLogout = (): Logout => { ) { newLocation.state = { nextPathname: history.location.pathname, + nextSearch: history.location.search, }; } if (redirectToParts[1]) { diff --git a/packages/ra-core/src/dataProvider/useGetMany.spec.tsx b/packages/ra-core/src/dataProvider/useGetMany.spec.tsx index 343c0ebfad1..74b159a3d68 100644 --- a/packages/ra-core/src/dataProvider/useGetMany.spec.tsx +++ b/packages/ra-core/src/dataProvider/useGetMany.spec.tsx @@ -40,6 +40,44 @@ describe('useGetMany', () => { ]); }); + it('should not call the dataProvider with a GET_MANY on mount if enabled is false', async () => { + const dataProvider = { + getMany: jest.fn(() => + Promise.resolve({ data: [{ id: 1, title: 'foo' }] }) + ), + }; + const { dispatch, rerender } = renderWithRedux( + + + + ); + await new Promise(resolve => setTimeout(resolve)); + expect(dispatch).toBeCalledTimes(0); + expect(dataProvider.getMany).toBeCalledTimes(0); + + rerender( + + + + ); + await new Promise(resolve => setTimeout(resolve)); + expect(dispatch).toBeCalledTimes(5); + expect(dispatch.mock.calls[0][0].type).toBe('RA/CRUD_GET_MANY'); + expect(dataProvider.getMany).toBeCalledTimes(1); + expect(dataProvider.getMany.mock.calls[0]).toEqual([ + 'posts', + { ids: [1] }, + ]); + }); + it('should aggregate multiple queries into a single call', async () => { const dataProvider = { getMany: jest.fn(() => diff --git a/packages/ra-core/src/dataProvider/useGetMany.ts b/packages/ra-core/src/dataProvider/useGetMany.ts index 4a2b2e0bc70..3d96ebc8f36 100644 --- a/packages/ra-core/src/dataProvider/useGetMany.ts +++ b/packages/ra-core/src/dataProvider/useGetMany.ts @@ -91,7 +91,7 @@ const DataProviderOptions = { action: CRUD_GET_MANY }; const useGetMany = ( resource: string, ids: Identifier[], - options: UseGetManyOptions = {} + options: UseGetManyOptions = { enabled: true } ): UseGetManyResult => { // we can't use useQueryWithStore here because we're aggregating queries first // therefore part of the useQueryWithStore logic will have to be repeated below @@ -116,6 +116,10 @@ const useGetMany = ( } dataProvider = useDataProvider(); // not the best way to pass the dataProvider to a function outside the hook, but I couldn't find a better one useEffect(() => { + if (options.enabled === false) { + return; + } + if (!queriesToCall[resource]) { queriesToCall[resource] = []; } diff --git a/packages/ra-core/src/form/FormDataConsumer.tsx b/packages/ra-core/src/form/FormDataConsumer.tsx index de0444a4470..41be0b4d636 100644 --- a/packages/ra-core/src/form/FormDataConsumer.tsx +++ b/packages/ra-core/src/form/FormDataConsumer.tsx @@ -32,7 +32,7 @@ interface Props extends ConnectedProps { /** * Get the current (edited) value of the record from the form and pass it - * to child function + * to a child function * * @example * diff --git a/packages/ra-core/src/form/useInput.ts b/packages/ra-core/src/form/useInput.ts index a49c07f8e30..461ca416a7d 100644 --- a/packages/ra-core/src/form/useInput.ts +++ b/packages/ra-core/src/form/useInput.ts @@ -37,6 +37,7 @@ export interface UseInputValue extends FieldRenderProps { const useInput = ({ defaultValue, + initialValue, id, name, source, @@ -67,7 +68,7 @@ const useInput = ({ : validate; const { input, meta } = useFinalFormField(finalName, { - initialValue: defaultValue, + initialValue: initialValue || defaultValue, validate: sanitizedValidate, ...options, }); @@ -79,7 +80,6 @@ const useInput = ({ const handleBlur = useCallback( event => { onBlur(event); - if (typeof customOnBlur === 'function') { customOnBlur(event); } diff --git a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx index c7ba28832d2..f9c0d39ba96 100644 --- a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx +++ b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx @@ -20,6 +20,12 @@ const FormBody = ({ handleSubmit }) => { aria-labelledby="firstname-label" component="input" /> + + @@ -59,45 +65,64 @@ describe('useWarnWhenUnsavedChanges', () => { getByText('Submitted'); }); - it('should not warn when leaving form with submit button', () => { - const { getByLabelText, getByText } = render(); - const input = getByLabelText('First Name') as HTMLInputElement; - input.value = 'John Doe'; - fireEvent.click(getByText('Submit')); - getByText('Submitted'); - }); + test.each([ + ['simple', 'First Name'], + ['nested', 'Author'], + ])( + 'should not warn when leaving form with submit button after updating %s field', + (_, field) => { + const { getByLabelText, getByText } = render(); + fireEvent.change(getByLabelText(field), { + target: { value: 'John Doe' }, + }); + fireEvent.click(getByText('Submit')); + getByText('Submitted'); + } + ); - it('should warn when leaving form with unsaved changes', () => { - // mock click on "cancel" in the confirm dialog - window.confirm = jest.fn().mockReturnValue(false); - const { getByLabelText, getByText, queryByText } = render(); - const input = getByLabelText('First Name') as HTMLInputElement; - fireEvent.change(input, { target: { value: 'John Doe' } }); - fireEvent.click(getByText('Leave')); - expect(window.confirm).toHaveBeenCalledWith( - 'ra.message.unsaved_changes' - ); - // check that we're still in the form and that the unsaved changes are here - expect((getByLabelText('First Name') as HTMLInputElement).value).toBe( - 'John Doe' - ); - expect(queryByText('Somewhere')).toBeNull(); - }); + test.each([ + ['simple', 'First Name'], + ['nested', 'Author'], + ])( + 'should warn when leaving form with unsaved changes after updating %s field', + (_, field) => { + // mock click on "cancel" in the confirm dialog + window.confirm = jest.fn().mockReturnValue(false); + const { getByLabelText, getByText, queryByText } = render(); + const input = getByLabelText(field) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'John Doe' } }); + fireEvent.click(getByText('Leave')); + expect(window.confirm).toHaveBeenCalledWith( + 'ra.message.unsaved_changes' + ); + // check that we're still in the form and that the unsaved changes are here + expect( + (getByLabelText('First Name') as HTMLInputElement).value + ).toBe('John Doe'); + expect(queryByText('Somewhere')).toBeNull(); + } + ); - it('should warn when leaving form with unsaved changes but accept override', () => { - // mock click on "OK" in the confirm dialog - window.confirm = jest.fn().mockReturnValue(true); - const { getByLabelText, getByText, queryByText } = render(); - const input = getByLabelText('First Name') as HTMLInputElement; - fireEvent.change(input, { target: { value: 'John Doe' } }); - fireEvent.click(getByText('Leave')); - expect(window.confirm).toHaveBeenCalledWith( - 'ra.message.unsaved_changes' - ); - // check that we're no longer in the form - expect(queryByText('First Name')).toBeNull(); - getByText('Somewhere'); - }); + test.each([ + ['simple', 'First Name'], + ['nested', 'Author'], + ])( + 'should warn when leaving form with unsaved changes but accept override', + (_, field) => { + // mock click on "OK" in the confirm dialog + window.confirm = jest.fn().mockReturnValue(true); + const { getByLabelText, getByText, queryByText } = render(); + const input = getByLabelText(field) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'John Doe' } }); + fireEvent.click(getByText('Leave')); + expect(window.confirm).toHaveBeenCalledWith( + 'ra.message.unsaved_changes' + ); + // check that we're no longer in the form + expect(queryByText(field)).toBeNull(); + getByText('Somewhere'); + } + ); afterAll(() => delete window.confirm); }); diff --git a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx index 9b96bb160b1..29b41a67bfe 100644 --- a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx +++ b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { useForm } from 'react-final-form'; import { useHistory } from 'react-router-dom'; +import get from 'lodash/get'; import { useTranslate } from '../i18n'; @@ -39,6 +40,7 @@ const useWarnWhenUnsavedChanges = (enable: boolean) => { const unsavedChanges = JSON.parse( window.sessionStorage.getItem('unsavedChanges') ); + if (unsavedChanges) { Object.keys(unsavedChanges).forEach(key => form.change(key, unsavedChanges[key]) @@ -61,7 +63,7 @@ const useWarnWhenUnsavedChanges = (enable: boolean) => { : formState.dirtyFields; const dirtyFieldValues = Object.keys(dirtyFields).reduce( (acc, key) => { - acc[key] = formState.values[key]; + acc[key] = get(formState.values, key); return acc; }, {} diff --git a/packages/ra-core/src/form/validate.spec.ts b/packages/ra-core/src/form/validate.spec.ts index 4a170002afa..a1c69f62b64 100644 --- a/packages/ra-core/src/form/validate.spec.ts +++ b/packages/ra-core/src/form/validate.spec.ts @@ -1,5 +1,3 @@ -import expect from 'expect'; - import { required, minLength, @@ -11,6 +9,7 @@ import { email, choices, composeValidators, + combine2Validators, } from './validate'; describe('Validators', () => { @@ -28,6 +27,134 @@ describe('Validators', () => { ); }; + describe('combine2Validators', () => { + it('should create a new validator that always return the result directly when both validator are synchronous', () => { + const includesFoo = value => + value.match(/foo/) ? null : 'value must include foo'; + const includesBar = value => + value.match(/bar/) ? null : 'value must include bar'; + + const combinedValidator = combine2Validators( + includesFoo, + includesBar + ); + expect(combinedValidator('foobar', null, null)).toBe(null); + expect(combinedValidator('bar', null, null)).toBe( + 'value must include foo' + ); + expect(combinedValidator('foo', null, null)).toBe( + 'value must include bar' + ); + expect(combinedValidator('', null, null)).toBe( + 'value must include foo' + ); + }); + + it('should create a new validator that always return a promise when both validator are asynchronous', async () => { + const includesFoo = value => + Promise.resolve( + value.match(/foo/) ? null : 'value must include foo' + ); + const includesBar = value => + Promise.resolve( + value.match(/bar/) ? null : 'value must include bar' + ); + + const combinedValidator = combine2Validators( + includesFoo, + includesBar + ); + const validPromise = combinedValidator('foobar', null, null); + expect(validPromise.then).toBeDefined(); + expect(await validPromise).toBe(null); + const missingFooPromise = combinedValidator('bar', null, null); + expect(missingFooPromise.then).toBeDefined(); + expect(await missingFooPromise).toBe('value must include foo'); + + const missingBarPromise = combinedValidator('foo', null, null); + expect(missingBarPromise.then).toBeDefined(); + expect(await missingBarPromise).toBe('value must include bar'); + + const invalidPromise = combinedValidator('', null, null); + expect(invalidPromise.then).toBeDefined(); + expect(await invalidPromise).toBe('value must include foo'); + }); + + describe('synchronous validator + asynchronous validator', () => { + const includesFoo = value => + value.match(/foo/) ? null : 'value must include foo'; + const includesBar = value => + Promise.resolve( + value.match(/bar/) ? null : 'value must include bar' + ); + const combinedValidator = combine2Validators( + includesFoo, + includesBar + ); + + it('should return valid result inside a promise when both validators pass', async () => { + const promise = combinedValidator('foobar', null, null); + expect(promise.then).toBeDefined(); + expect(await promise).toBe(null); + }); + + it('should return invalid result directly when both validators fail', () => { + expect(combinedValidator('', null, null)).toBe( + 'value must include foo' + ); + }); + + it('should return invalid result directly when first validator fail', () => { + expect(combinedValidator('bar', null, null)).toBe( + 'value must include foo' + ); + }); + + it('should return invalid result inside a promise when second validator fail', async () => { + const promise = combinedValidator('foo', null, null); + expect(promise.then).toBeDefined(); + expect(await promise).toBe('value must include bar'); + }); + }); + + describe('asynchronous validator + synchronous validator', () => { + const includesFoo = value => + Promise.resolve( + value.match(/foo/) ? null : 'value must include foo' + ); + const includesBar = value => + value.match(/bar/) ? null : 'value must include bar'; + const combinedValidator = combine2Validators( + includesFoo, + includesBar + ); + + it('should return valid result inside a promise when both validators pass', async () => { + const promise = combinedValidator('foobar', null, null); + expect(promise.then).toBeDefined(); + expect(await promise).toBe(null); + }); + + it('should return valid result inside a promise when both validators fail', async () => { + const promise = combinedValidator('', null, null); + expect(promise.then).toBeDefined(); + expect(await promise).toBe('value must include foo'); + }); + + it('should return invalid result in a promise when first validator fail', async () => { + const promise = combinedValidator('bar', null, null); + expect(promise.then).toBeDefined(); + expect(await promise).toBe('value must include foo'); + }); + + it('should return invalid result inside a promise when second validator fail', async () => { + const promise = combinedValidator('foo', null, null); + expect(promise.then).toBeDefined(); + expect(await promise).toBe('value must include bar'); + }); + }); + }); + describe('composeValidators', () => { const asyncSuccessfullValidator = async => new Promise(resolve => resolve(undefined)); diff --git a/packages/ra-core/src/form/validate.ts b/packages/ra-core/src/form/validate.ts index c9d3a68a2e0..38d91b02780 100644 --- a/packages/ra-core/src/form/validate.ts +++ b/packages/ra-core/src/form/validate.ts @@ -76,35 +76,38 @@ const memoize: Memoize = (fn: any) => const isFunction = value => typeof value === 'function'; +export const combine2Validators = ( + validator1: Validator, + validator2: Validator +): Validator => { + return (value, values, meta) => { + const result1 = validator1(value, values, meta); + if (!result1) { + return validator2(value, values, meta); + } + if ( + typeof result1 === 'string' || + isValidationErrorMessageWithArgs(result1) + ) { + return result1; + } + + return result1.then(resolvedResult1 => { + if (!resolvedResult1) { + return validator2(value, values, meta); + } + return resolvedResult1; + }); + }; +}; + // Compose multiple validators into a single one for use with final-form -export const composeValidators = (...validators) => async ( - value, - values, - meta -) => { +export const composeValidators = (...validators) => { const allValidators = (Array.isArray(validators[0]) ? validators[0] : validators ).filter(isFunction) as Validator[]; - - for (const validator of allValidators) { - const errorPromise = validator(value, values, meta); - - if (errorPromise) { - if (typeof errorPromise == 'string') { - return errorPromise; - } - if (isValidationErrorMessageWithArgs(errorPromise)) { - return errorPromise; - } - - const error = await errorPromise; - - if (error) { - return error; - } - } - } + return allValidators.reduce(combine2Validators, () => null); }; // Compose multiple validators into a single one for use with final-form diff --git a/packages/ra-data-json-server/package.json b/packages/ra-data-json-server/package.json index 7cd3b387f83..c8ba4884950 100644 --- a/packages/ra-data-json-server/package.json +++ b/packages/ra-data-json-server/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-json-server", - "version": "3.14.3", + "version": "3.14.5", "description": "JSON Server data provider for react-admin", "main": "lib/index.js", "module": "esm/index.js", @@ -26,7 +26,7 @@ }, "dependencies": { "query-string": "^5.1.1", - "ra-core": "^3.14.3" + "ra-core": "^3.14.5" }, "devDependencies": { "cross-env": "^5.2.0", diff --git a/packages/ra-data-localstorage/package.json b/packages/ra-data-localstorage/package.json index 8f56679602c..15f076a364b 100644 --- a/packages/ra-data-localstorage/package.json +++ b/packages/ra-data-localstorage/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-local-storage", - "version": "3.14.0", + "version": "3.14.5", "description": "Local storage data provider for react-admin", "main": "lib/index.js", "module": "esm/index.js", diff --git a/packages/ra-data-localstorage/src/index.ts b/packages/ra-data-localstorage/src/index.ts index 32a88859ef9..a34799faeaf 100644 --- a/packages/ra-data-localstorage/src/index.ts +++ b/packages/ra-data-localstorage/src/index.ts @@ -29,7 +29,7 @@ import pullAt from 'lodash/pullAt'; * } * }); */ -export default (params: LocalStorageDataProviderParams): DataProvider => { +export default (params?: LocalStorageDataProviderParams): DataProvider => { const { defaultData = {}, localStorageKey = 'ra-data-local-storage', @@ -148,8 +148,8 @@ export default (params: LocalStorageDataProviderParams): DataProvider => { }; export interface LocalStorageDataProviderParams { - defaultData: any; - localStorageKey: string; - loggingEnabled: boolean; - localStorageUpdateDelay: number; + defaultData?: any; + localStorageKey?: string; + loggingEnabled?: boolean; + localStorageUpdateDelay?: number; } diff --git a/packages/ra-i18n-polyglot/package.json b/packages/ra-i18n-polyglot/package.json index e5efc41744e..c941b969472 100644 --- a/packages/ra-i18n-polyglot/package.json +++ b/packages/ra-i18n-polyglot/package.json @@ -1,6 +1,6 @@ { "name": "ra-i18n-polyglot", - "version": "3.14.3", + "version": "3.14.5", "description": "Polyglot i18n provider for react-admin", "main": "lib/index.js", "module": "esm/index.js", @@ -26,7 +26,7 @@ }, "dependencies": { "node-polyglot": "^2.2.2", - "ra-core": "^3.14.3" + "ra-core": "^3.14.5" }, "devDependencies": { "cross-env": "^5.2.0", diff --git a/packages/ra-language-english/package.json b/packages/ra-language-english/package.json index 793b20806eb..2ee578017c6 100644 --- a/packages/ra-language-english/package.json +++ b/packages/ra-language-english/package.json @@ -1,6 +1,6 @@ { "name": "ra-language-english", - "version": "3.14.3", + "version": "3.14.5", "description": "English messages for react-admin, the frontend framework for building admin applications on top of REST/GraphQL services", "repository": { "type": "git", @@ -22,7 +22,7 @@ "watch": "tsc --outDir esm --module es2015 --watch" }, "dependencies": { - "ra-core": "^3.14.3" + "ra-core": "^3.14.5" }, "keywords": [ "react", diff --git a/packages/ra-language-french/package.json b/packages/ra-language-french/package.json index 951d02bcf20..5e38e55085b 100644 --- a/packages/ra-language-french/package.json +++ b/packages/ra-language-french/package.json @@ -1,6 +1,6 @@ { "name": "ra-language-french", - "version": "3.14.3", + "version": "3.14.5", "description": "French messages for react-admin, the frontend framework for building admin applications on top of REST/GraphQL services", "repository": { "type": "git", @@ -16,7 +16,7 @@ "watch": "tsc --outDir esm --module es2015 --watch" }, "dependencies": { - "ra-core": "^3.14.3" + "ra-core": "^3.14.5" }, "keywords": [ "react", diff --git a/packages/ra-test/package.json b/packages/ra-test/package.json index 5f39e8f6209..e328e69cb0d 100644 --- a/packages/ra-test/package.json +++ b/packages/ra-test/package.json @@ -1,6 +1,6 @@ { "name": "ra-test", - "version": "3.14.3", + "version": "3.14.5", "description": "Test utilities for react-admin, a frontend Framework for building admin applications on top of REST services, using ES6, React", "files": [ "*.md", @@ -39,7 +39,7 @@ "final-form": "^4.20.2", "history": "^4.7.2", "ignore-styles": "~5.0.1", - "ra-core": "^3.14.3", + "ra-core": "^3.14.5", "react": "^17.0.0", "react-dom": "^17.0.0", "react-redux": "^7.1.0", diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index 7c9ca6ae498..95697e6f0b0 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -1,6 +1,6 @@ { "name": "ra-ui-materialui", - "version": "3.14.3", + "version": "3.14.5", "description": "UI Components for react-admin with MaterialUI", "files": [ "*.md", @@ -36,7 +36,7 @@ "final-form": "^4.20.2", "final-form-arrays": "^3.0.2", "ignore-styles": "~5.0.1", - "ra-core": "^3.14.3", + "ra-core": "^3.14.5", "react": "^17.0.0", "react-dom": "^17.0.0", "react-final-form": "^6.5.2", diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteButton.tsx index 3dd6cbdba08..f87d72134f9 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteButton.tsx @@ -49,7 +49,7 @@ BulkDeleteButton.propTypes = { basePath: PropTypes.string, label: PropTypes.string, resource: PropTypes.string, - selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired, + selectedIds: PropTypes.arrayOf(PropTypes.any), undoable: PropTypes.bool, icon: PropTypes.element, }; diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx index 6e7dff4b6b5..1b137d36efe 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx @@ -165,7 +165,7 @@ BulkDeleteWithConfirmButton.propTypes = { confirmContent: PropTypes.string, label: PropTypes.string, resource: PropTypes.string, - selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired, + selectedIds: PropTypes.arrayOf(PropTypes.any), icon: PropTypes.element, }; diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx index 7977948b676..3c51b15771f 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx @@ -118,7 +118,7 @@ BulkDeleteWithUndoButton.propTypes = { classes: PropTypes.object, label: PropTypes.string, resource: PropTypes.string, - selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired, + selectedIds: PropTypes.arrayOf(PropTypes.any), icon: PropTypes.element, }; diff --git a/packages/ra-ui-materialui/src/defaultTheme.ts b/packages/ra-ui-materialui/src/defaultTheme.ts index 356ea07aa87..31f46694e0e 100644 --- a/packages/ra-ui-materialui/src/defaultTheme.ts +++ b/packages/ra-ui-materialui/src/defaultTheme.ts @@ -65,8 +65,8 @@ export interface RaThemeOverrides extends Overrides { export interface RaThemeOptions extends ThemeOptions { sidebar?: { - width: number; - closedWidth: number; + width?: number; + closedWidth?: number; }; overrides?: RaThemeOverrides; } diff --git a/packages/ra-ui-materialui/src/detail/CreateActions.tsx b/packages/ra-ui-materialui/src/detail/CreateActions.tsx index 7aa68e08f7d..9642f185edf 100644 --- a/packages/ra-ui-materialui/src/detail/CreateActions.tsx +++ b/packages/ra-ui-materialui/src/detail/CreateActions.tsx @@ -30,7 +30,7 @@ import { useCreateContext, useResourceDefinition } from 'ra-core'; * * ); */ -const CreateActions = ({ className, ...rest }: CreateActionsProps) => { +export const CreateActions = ({ className, ...rest }: CreateActionsProps) => { const { basePath } = useCreateContext(rest); const { hasList } = useResourceDefinition(rest); return ( @@ -48,18 +48,22 @@ const sanitizeRestProps = ({ ...rest }) => rest; -interface CreateActionsProps { +export interface CreateActionsProps { basePath?: string; className?: string; - hasShow?: boolean; + hasCreate?: boolean; + hasEdit?: boolean; hasList?: boolean; + hasShow?: boolean; resource?: string; } CreateActions.propTypes = { basePath: PropTypes.string, className: PropTypes.string, + hasCreate: PropTypes.bool, + hasEdit: PropTypes.bool, + hasShow: PropTypes.bool, hasList: PropTypes.bool, + resource: PropTypes.string, }; - -export default CreateActions; diff --git a/packages/ra-ui-materialui/src/detail/EditActions.tsx b/packages/ra-ui-materialui/src/detail/EditActions.tsx index 9237d06b4b3..814418d99c4 100644 --- a/packages/ra-ui-materialui/src/detail/EditActions.tsx +++ b/packages/ra-ui-materialui/src/detail/EditActions.tsx @@ -30,7 +30,7 @@ import TopToolbar from '../layout/TopToolbar'; * * ); */ -const EditActions = ({ className, ...rest }: EditActionsProps) => { +export const EditActions = ({ className, ...rest }: EditActionsProps) => { const { basePath, record } = useEditContext(rest); const { hasShow } = useResourceDefinition(rest); @@ -54,8 +54,10 @@ export interface EditActionsProps { basePath?: string; className?: string; data?: Record; - hasShow?: boolean; + hasCreate?: boolean; + hasEdit?: boolean; hasList?: boolean; + hasShow?: boolean; resource?: string; } @@ -63,8 +65,9 @@ EditActions.propTypes = { basePath: PropTypes.string, className: PropTypes.string, data: PropTypes.object, + hasCreate: PropTypes.bool, + hasEdit: PropTypes.bool, hasShow: PropTypes.bool, + hasList: PropTypes.bool, resource: PropTypes.string, }; - -export default EditActions; diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index e269445c09b..06456c78786 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -11,7 +11,7 @@ import { useEditContext, } from 'ra-core'; -import DefaultActions from './EditActions'; +import { EditActions as DefaultActions } from './EditActions'; import TitleForRecord from '../layout/TitleForRecord'; import { EditProps } from '../types'; diff --git a/packages/ra-ui-materialui/src/detail/ShowActions.tsx b/packages/ra-ui-materialui/src/detail/ShowActions.tsx index 6ddaf02e75c..928ceb1d630 100644 --- a/packages/ra-ui-materialui/src/detail/ShowActions.tsx +++ b/packages/ra-ui-materialui/src/detail/ShowActions.tsx @@ -39,7 +39,7 @@ const sanitizeRestProps = ({ * * ); */ -const ShowActions = ({ className, ...rest }: ShowActionsProps) => { +export const ShowActions = ({ className, ...rest }: ShowActionsProps) => { const { basePath, record } = useShowContext(rest); const { hasEdit } = useResourceDefinition(rest); return ( @@ -53,7 +53,9 @@ export interface ShowActionsProps { basePath?: string; className?: string; data?: Record; + hasCreate?: boolean; hasEdit?: boolean; + hasShow?: boolean; hasList?: boolean; resource?: string; } @@ -62,9 +64,9 @@ ShowActions.propTypes = { basePath: PropTypes.string, className: PropTypes.string, data: PropTypes.object, + hasCreate: PropTypes.bool, hasEdit: PropTypes.bool, + hasShow: PropTypes.bool, hasList: PropTypes.bool, resource: PropTypes.string, }; - -export default ShowActions; diff --git a/packages/ra-ui-materialui/src/detail/ShowView.tsx b/packages/ra-ui-materialui/src/detail/ShowView.tsx index f21598a8282..8724d87a0ae 100644 --- a/packages/ra-ui-materialui/src/detail/ShowView.tsx +++ b/packages/ra-ui-materialui/src/detail/ShowView.tsx @@ -10,7 +10,7 @@ import { useShowContext, } from 'ra-core'; -import DefaultActions from './ShowActions'; +import { ShowActions as DefaultActions } from './ShowActions'; import TitleForRecord from '../layout/TitleForRecord'; import { ShowProps } from '../types'; diff --git a/packages/ra-ui-materialui/src/detail/index.ts b/packages/ra-ui-materialui/src/detail/index.ts index 78e403da75d..a30266da30d 100644 --- a/packages/ra-ui-materialui/src/detail/index.ts +++ b/packages/ra-ui-materialui/src/detail/index.ts @@ -1,16 +1,16 @@ import { Create } from './Create'; import { CreateView } from './CreateView'; -import CreateActions from './CreateActions'; import { Edit } from './Edit'; import { EditView } from './EditView'; -import EditActions, { EditActionsProps } from './EditActions'; import EditGuesser from './EditGuesser'; import { Show } from './Show'; import { ShowView } from './ShowView'; -import ShowActions, { ShowActionsProps } from './ShowActions'; import ShowGuesser from './ShowGuesser'; import SimpleShowLayout, { SimpleShowLayoutProps } from './SimpleShowLayout'; +export * from './CreateActions'; +export * from './EditActions'; +export * from './ShowActions'; export * from './TabbedShowLayout'; export * from './TabbedShowLayoutTabs'; export * from './Tab'; @@ -18,16 +18,13 @@ export * from './Tab'; export { Create, CreateView, - CreateActions, Edit, EditView, - EditActions, EditGuesser, Show, ShowView, - ShowActions, ShowGuesser, SimpleShowLayout, }; -export type { EditActionsProps, SimpleShowLayoutProps, ShowActionsProps }; +export type { SimpleShowLayoutProps }; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx index c51e1e4abcb..a3f109b0f4f 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx @@ -206,7 +206,8 @@ const AutocompleteArrayInput = (props: AutocompleteArrayInputProps) => { }); // eslint-disable-next-line - const debouncedSetFilter = useCallback(debounce(setFilter || DefaultSetFilter, debounceDelay), + const debouncedSetFilter = useCallback( + debounce(setFilter || DefaultSetFilter, debounceDelay), [setFilter, debounceDelay] ); diff --git a/packages/ra-ui-materialui/src/input/DateInput.tsx b/packages/ra-ui-materialui/src/input/DateInput.tsx index 258b403d941..10a0c0871c7 100644 --- a/packages/ra-ui-materialui/src/input/DateInput.tsx +++ b/packages/ra-ui-materialui/src/input/DateInput.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { FunctionComponent } from 'react'; +import { useEffect } from 'react'; import PropTypes from 'prop-types'; import TextField, { TextFieldProps } from '@material-ui/core/TextField'; import { useInput, FieldTitle, InputProps } from 'ra-core'; @@ -14,7 +14,7 @@ import InputHelperText from './InputHelperText'; * @returns {String} A standardized date (yyyy-MM-dd), to be passed to an */ const convertDateToString = (value: Date) => { - if (!(value instanceof Date) || isNaN(value.getDate())) return; + if (!(value instanceof Date) || isNaN(value.getDate())) return ''; const pad = '00'; const yyyy = value.getFullYear().toString(); const MM = (value.getMonth() + 1).toString(); @@ -44,8 +44,10 @@ const getStringFromDate = (value: string | Date) => { return convertDateToString(new Date(value)); }; -const DateInput: FunctionComponent = ({ +const DateInput = ({ + defaultValue, format = getStringFromDate, + initialValue, label, options, source, @@ -59,14 +61,19 @@ const DateInput: FunctionComponent = ({ validate, variant = 'filled', ...rest -}) => { - const { - id, - input, - isRequired, - meta: { error, submitError, touched }, - } = useInput({ +}: DateInputProps) => { + const sanitizedDefaultValue = defaultValue + ? format(new Date(defaultValue)) + : undefined; + const sanitizedInitialValue = initialValue + ? format(new Date(initialValue)) + : undefined; + + const { id, input, isRequired, meta } = useInput({ + defaultValue: sanitizedDefaultValue, format, + formatOnBlur: true, + initialValue: sanitizedInitialValue, onBlur, onChange, onFocus, @@ -77,10 +84,23 @@ const DateInput: FunctionComponent = ({ ...rest, }); + const { error, submitError, touched } = meta; + + // Workaround for https://github.com/final-form/react-final-form/issues/431 + useEffect(() => { + // Checking for meta.initial allows the format function to work + // on inputs inside an ArrayInput + if (defaultValue || initialValue || meta.initial) { + input.onBlur(); + } + }, [input.onBlur, meta.initial]); // eslint-disable-line + return ( new Date(value); /** * Input component for entering a date and a time with timezone, using the browser locale */ -const DateTimeInput: FunctionComponent = ({ +const DateTimeInput = ({ + defaultValue, format = formatDateTime, + initialValue, label, helperText, margin = 'dense', @@ -80,14 +82,18 @@ const DateTimeInput: FunctionComponent = ({ validate, variant = 'filled', ...rest -}) => { - const { - id, - input, - isRequired, - meta: { error, submitError, touched }, - } = useInput({ +}: DateTimeInputProps) => { + const sanitizedDefaultValue = defaultValue + ? format(new Date(defaultValue)) + : undefined; + const sanitizedInitialValue = initialValue + ? format(new Date(initialValue)) + : undefined; + + const { id, input, isRequired, meta } = useInput({ + defaultValue: sanitizedDefaultValue, format, + initialValue: sanitizedInitialValue, onBlur, onChange, onFocus, @@ -99,10 +105,23 @@ const DateTimeInput: FunctionComponent = ({ ...rest, }); + const { error, submitError, touched } = meta; + + // Workaround for https://github.com/final-form/react-final-form/issues/431 + useEffect(() => { + // Checking for meta.initial allows the format function to work + // on inputs inside an ArrayInput + if (defaultValue || initialValue || meta.initial) { + input.onBlur(); + } + }, [input.onBlur, meta.initial]); // eslint-disable-line + return ( ) => ({ + color: + props.type === 'success' + ? theme.palette.success.contrastText + : theme.palette.primary.light, + }), }), { name: 'RaNotification' } ); diff --git a/packages/react-admin/package.json b/packages/react-admin/package.json index c94839b6873..11f948d7f54 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -1,6 +1,6 @@ { "name": "react-admin", - "version": "3.14.3", + "version": "3.14.5", "description": "A frontend Framework for building admin applications on top of REST services, using ES6, React and Material UI", "files": [ "*.md", @@ -40,10 +40,10 @@ "connected-react-router": "^6.5.2", "final-form": "^4.20.2", "final-form-arrays": "^3.0.2", - "ra-core": "^3.14.3", - "ra-i18n-polyglot": "^3.14.3", - "ra-language-english": "^3.14.3", - "ra-ui-materialui": "^3.14.3", + "ra-core": "^3.14.5", + "ra-i18n-polyglot": "^3.14.5", + "ra-language-english": "^3.14.5", + "ra-ui-materialui": "^3.14.5", "react-final-form": "^6.5.2", "react-final-form-arrays": "^3.1.3", "react-redux": "^7.1.0",