diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb326e65cd..489ef18c7e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## v4.16.2 + +* Fix clearing a nested filter re-renders the previous value when navigating back to the list ([#9491](https://github.com/marmelab/react-admin/pull/9491)) ([slax57](https://github.com/slax57)) +* Fix `ra-data-graphql` uses a Proxy, which prevents adding more methods automatically ([#9487](https://github.com/marmelab/react-admin/pull/9487)) ([fzaninotto](https://github.com/fzaninotto)) +* Fix `useUpdateMany` doesn't accept the `returnPromise` option at call time ([#9486](https://github.com/marmelab/react-admin/pull/9486)) ([fzaninotto](https://github.com/fzaninotto)) +* Fix `` logs a warning ([#9474](https://github.com/marmelab/react-admin/pull/9474)) ([fzaninotto](https://github.com/fzaninotto)) +* [Doc] Update ra-form-layouts dialogs documentation ([#9482](https://github.com/marmelab/react-admin/pull/9482)) ([djhi](https://github.com/djhi)) +* [Doc] Fix snippets fails to render in JS ([#9478](https://github.com/marmelab/react-admin/pull/9478)) ([fzaninotto](https://github.com/fzaninotto)) +* [Doc] Add link to tutorial for headless admin ([#9477](https://github.com/marmelab/react-admin/pull/9477)) ([fzaninotto](https://github.com/fzaninotto)) + +## v4.16.1 + +* Fix `` should display a validation errors right away when form mode is 'onChange' ([#9459](https://github.com/marmelab/react-admin/pull/9459)) ([slax57](https://github.com/slax57)) +* [TypeScript] Fix useRecordContext may return undefined ([#9460](https://github.com/marmelab/react-admin/pull/9460)) ([groomain](https://github.com/groomain)) +* [Doc] Add link to new demo: Note-taking app ([#9465](https://github.com/marmelab/react-admin/pull/9465)) ([fzaninotto](https://github.com/fzaninotto)) +* [Doc] Add headless section in pages components ([#9447](https://github.com/marmelab/react-admin/pull/9447)) ([fzaninotto](https://github.com/fzaninotto)) +* [Doc] Add example showing how to add `` to a custom layout ([#9458](https://github.com/marmelab/react-admin/pull/9458)) ([rossb220](https://github.com/rossb220)) +* [Doc] Update `` doc to use the new package, and document the column order/size persistence ([#9472](https://github.com/marmelab/react-admin/pull/9472)) ([slax57](https://github.com/slax57)) +* [Doc] Update authProvider and dataProvider lists to target the documentation instead of the repository's root ([#9471](https://github.com/marmelab/react-admin/pull/9471)) ([slax57](https://github.com/slax57)) +* [Website] Reorder documentation's Discord and Github icons to match the website order ([#9454](https://github.com/marmelab/react-admin/pull/9454)) ([adguernier](https://github.com/adguernier)) + +## v4.16.0 + +* Add `` props, and allow it to be used without `children` ([#9439](https://github.com/marmelab/react-admin/pull/9439)) ([fzaninotto](https://github.com/fzaninotto)) +* Add `` prop, allowing to trigger actions (like a refresh) on click ([#9420](https://github.com/marmelab/react-admin/pull/9420)) ([david-bezero](https://github.com/david-bezero)) +* Add `` prop to customize the locales button icon ([#9380](https://github.com/marmelab/react-admin/pull/9380)) ([djhi](https://github.com/djhi)) +* Add `
` to allow disabling notifications when the form is invalid ([#9353](https://github.com/marmelab/react-admin/pull/9353)) ([tim-hoffmann](https://github.com/tim-hoffmann)) + + ## v4.15.5 * Add support for `fetchOptions` to `` ([#9436](https://github.com/marmelab/react-admin/pull/9436)) ([smeng9](https://github.com/smeng9)) diff --git a/docs/Admin.md b/docs/Admin.md index 173b26bba7c..03917b22afa 100644 --- a/docs/Admin.md +++ b/docs/Admin.md @@ -648,7 +648,8 @@ You can also disable the `/login` route completely by passing `false` to this pr const authProvider = { // ... async checkAuth() { - if (/* not authenticated */) { + // ... + if (!authenticated) { throw { redirectTo: '/no-access' }; } }, @@ -708,6 +709,7 @@ If you want to override the react-query default query and mutation default optio ```tsx import { Admin } from 'react-admin'; import { QueryClient } from '@tanstack/react-query'; +import { dataProvider } from './dataProvider'; const queryClient = new QueryClient({ defaultOptions: { @@ -722,7 +724,7 @@ const queryClient = new QueryClient({ }); const App = () => ( - + ... ); @@ -915,10 +917,11 @@ But you may want to use another routing strategy, e.g. to allow server-side rend ```tsx import { BrowserRouter } from 'react-router-dom'; import { Admin, Resource } from 'react-admin'; +import { dataProvider } from './dataProvider'; const App = () => ( - + @@ -934,10 +937,11 @@ However, if you serve your admin from a sub path AND use another Router (like [` ```tsx import { Admin, Resource } from 'react-admin'; import { BrowserRouter } from 'react-router-dom'; +import { dataProvider } from './dataProvider'; const App = () => ( - + @@ -974,9 +978,11 @@ React-admin will have to prefix all the internal links with `/admin`. Use the `< ```tsx // in src/StoreAdmin.js import { Admin, Resource } from 'react-admin'; +import { dataProvider } from './dataProvider'; +import posts from './posts'; export const StoreAdmin = () => ( - + ); diff --git a/docs/AdvancedTutorials.md b/docs/AdvancedTutorials.md index 9ed41fa248f..83c142d1dd4 100644 --- a/docs/AdvancedTutorials.md +++ b/docs/AdvancedTutorials.md @@ -5,88 +5,30 @@ title: "Advanced Tutorials" # Advanced Tutorials -## Creating a Record Related to the Current One - -This tutorial explains how to add a button on a show or edit page to create a new resource related to the one displayed. - - - -* [Article](https://marmelab.com/blog/2023/10/12/react-admin-v4-advanced-recipes-creating-a-record-related-to-the-current-one.html) -* [Code](https://github.com/marmelab/react-admin/blob/master/examples/demo/src/products/ProductEdit.tsx) - -## Custom Forms and UI for related records - -This tutorial explains how to have a `create`, `show` or `edit` view of a referenced resource inside a modal or a sliding side panel. - -![Screencast](https://marmelab.com/dd58004986d3bb98a32972ba8fd25fc8/screencast.gif) - -* [Article](https://marmelab.com/blog/2020/04/27/react-admin-tutorials-custom-forms-related-records.html) -* [Codesandbox](https://codesandbox.io/s/react-admin-v3-advanced-recipes-quick-createpreview-voyci) - -## Build a Timeline as a replacement for a List component - -This tutorial shows how to use pure react to implement a custom component replacing react-admin default List. - -![Screencast](https://marmelab.com/d9b4cf0e7faf3ed208c102f8b2334409/storybook_App5.gif) - -* [Article](https://marmelab.com/blog/2019/01/17/react-timeline.html) -* [Repository](https://github.com/marmelab/timeline-react-admin) - -## Creating and Editing a Record From the List Page - -This tutorial shows how to display Creation and Edition forms in a drawer or a dialog from the List page. - -![Screencast](https://marmelab.com/07b25da5494055c4306dd7e7a48fd010/end-result.gif) - -* [Article](https://marmelab.com/blog/2019/02/07/react-admin-advanced-recipes-creating-and-editing-a-record-from-the-list-page.html) -* [Codesandbox](https://codesandbox.io/s/lrm6kl00nl) - -## Add a User Profile Page - -This tutorial explains how to create a profile page based on an `` component, and accessible as a standalone page. - -![Screencast](https://marmelab.com/668056e9d8273ff5ce75dfc641151a90/end_result.gif) - -* [Article](https://marmelab.com/blog/2019/03/07/react-admin-advanced-recipes-user-profile.html) -* [Codesandbox](https://codesandbox.io/s/o1jmj4lwv9) - -## Supplying your own Defaults to React Admin - -This article shows how you can customize many parts of the framework without repeating yourself. - -![Screencast](https://marmelab.com/54d42faced9043f7933df212cbda0f1b/react-admin-edit-defaults.gif) - -* [Article](https://marmelab.com/blog/2019/03/27/supplying-your-own-defaults-to-react-admin.html) -* [Codesandbox](https://codesandbox.io/s/qzxx4mjl59) - -## OpenID Connect Authentication with React Admin - -![OpenID Connect on React Admin with a button "Login With Google"](./img/openid-connect-example.png) - -* [Live Example](https://marmelab.com/ra-example-oauth) -* [Code Repository](https://github.com/marmelab/ra-example-oauth) - -## Changing The Look And Feel Of React-Admin Without JSX - -This article shows how to customize react-admin look and feel using only the Material UI theme. - -![Screencast](https://marmelab.com/097bee867a1d1dc55dec5456732fe94a/screencast.gif) - -* [Article](https://marmelab.com/blog/2020/09/11/react-admin-tutorials-build-your-own-theme.html) -* [Code Repository](https://github.com/Luwangel/react-admin-tutorials-build-your-own-theme) - -## Build A Custom Tags Selector - -This tutorial explains how to create a custom component to select tags for a record, fetching the list of existing tags and allowing to create new tags on the fly. - - - -* [Article](https://marmelab.com/blog/2023/04/26/build-a-custom-tags-selector-with-react-admin.html) -* Test it live in the [CRM Demo](https://marmelab.com/react-admin-crm/#/contacts/1/show) -* CRM Demo [Repository](https://github.com/marmelab/react-admin/blob/master/examples/crm/src/contacts/TagsListEdit.tsx) \ No newline at end of file +If you want to learn the best practices of react-admin development by example, you've come to the right place. This page lists the advanced tutorials we've published on [the react-admin blog](https://marmelab.com/en/blog/#react-admin). + +* 2023-11: [Using React-Admin With DaisyUI, Tailwind CSS, Tanstack Table and React-aria](https://marmelab.com/blog/2023/11/28/using-react-admin-with-your-favorite-ui-library.html) +* 2023-10: [Creating a Record Related to the Current One](https://marmelab.com/blog/2023/10/12/react-admin-v4-advanced-recipes-creating-a-record-related-to-the-current-one.html) +* 2023-09: [Authentication using ActiveDirectory](https://marmelab.com/blog/2023/09/13/active-directory-integration-tutorial.html) +* 2023-08: [Building AI-Augmented Apps](https://marmelab.com/blog/2023/08/09/ai-augmented%20react-apps.html) +* 2023-07: [Building a Kanban Board](https://marmelab.com/blog/2023/07/28/create-a-kanban-board-in-react-admin.html) +* 2023-04: [Building A Custom Tags Selector](https://marmelab.com/blog/2023/04/26/build-a-custom-tags-selector-with-react-admin.html) +* 2023-03: [Creating Custom Form Layouts](https://marmelab.com/blog/2023/03/22/creating-custom-form-layouts-with-react-admin.html) +* 2022-12: [Multi-Tenant Single-Page Apps: Dos and Don'ts](https://marmelab.com/blog/2022/12/14/multitenant-spa.html) +* 2022-11: [Building a B2B app with Strapi and React-Admin](https://marmelab.com/blog/2022/11/28/building-a-crud-app-with-strapi-and-react-admin.html) +* 2022-10: [Writing A Data Provider For Offline-First Applications](https://marmelab.com/blog/2022/10/26/create-an-localforage-dataprovider-in-react-admin.html) +* 2022-04: [Introducing React-admin V4](https://marmelab.com/blog/2022/04/13/react-admin-v4.html) + +Older blog posts contain advice that can be outdated. We keep them here for reference. + +* 2021-12: [Building A Retro React-Admin Theme For Fun And Profit](https://marmelab.com/blog/2021/12/15/retro-admin.html) +* 2021-10: [Using An SQLite Database Live In React-Admin](https://marmelab.com/blog/2021/10/14/using-an-sqlite-database-live-in-react-admin.html) +* 2020-12: [Managing a User Profile](https://marmelab.com/blog/2020/12/14/react-admin-v3-userprofile.html) +* 2020-11: [Changing The Look And Feel Of React-Admin Without JSX](https://marmelab.com/blog/2020/09/11/react-admin-tutorials-build-your-own-theme.html) +* 2020-08: [Handling JWT in Admin Apps the Right Way](https://marmelab.com/blog/2020/07/02/manage-your-jwt-react-admin-authentication-in-memory.html) +* 2020-04: [Custom Forms and UI for related records](https://marmelab.com/blog/2020/04/27/react-admin-tutorials-custom-forms-related-records.html) +* 2019-11: [Introducing React-admin v3](https://marmelab.com/blog/2019/11/20/react-admin-3-0.html) +* 2019-03: [Supplying your own Defaults to React Admin](https://marmelab.com/blog/2019/03/27/supplying-your-own-defaults-to-react-admin.html) +* 2019-03: [Adding a User Profile Page](https://marmelab.com/blog/2019/03/07/react-admin-advanced-recipes-user-profile.html) +* 2019-02: [Creating and Editing a Record From the List Page](https://marmelab.com/blog/2019/02/07/react-admin-advanced-recipes-creating-and-editing-a-record-from-the-list-page.html) +* 2019-01: [Building a Timeline as a replacement for a List component](https://marmelab.com/blog/2019/01/17/react-timeline.html) diff --git a/docs/AuthProviderList.md b/docs/AuthProviderList.md index 97c743c155e..4f496296a05 100644 --- a/docs/AuthProviderList.md +++ b/docs/AuthProviderList.md @@ -7,17 +7,16 @@ title: "Supported Auth Provider Backends" It's very common that your auth logic is so specific that you'll need to write your own `authProvider`. However, the community has built a few open-source Auth Providers that may fit your need: -- **[Auth0](https://auth0.com/)**: [marmelab/ra-auth-auth0](https://github.com/marmelab/ra-auth-auth0) +- **[Auth0](https://auth0.com/)**: [marmelab/ra-auth-auth0](https://github.com/marmelab/ra-auth-auth0/blob/main/packages/ra-auth-auth0/Readme.md) - **[AWS Amplify](https://docs.amplify.aws)**: [MrHertal/react-admin-amplify](https://github.com/MrHertal/react-admin-amplify) - **[AWS Cognito](https://docs.aws.amazon.com/cognito/latest/developerguide/setting-up-the-javascript-sdk.html)**: [marmelab/ra-auth-cognito](https://github.com/marmelab/ra-auth-cognito/blob/main/packages/ra-auth-cognito/Readme.md) -- **[Azure Active Directory (using MSAL)](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-browser)**: [marmelab/ra-auth-msal](https://github.com/marmelab/ra-auth-msal) ([Tutorial](https://marmelab.com/blog/2023/09/13/active-directory-integration-tutorial.html)) +- **[Azure Active Directory (using MSAL)](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-browser)**: [marmelab/ra-auth-msal](https://github.com/marmelab/ra-auth-msal/blob/main/packages/ra-auth-msal/Readme.md) ([Tutorial](https://marmelab.com/blog/2023/09/13/active-directory-integration-tutorial.html)) - **[Casdoor](https://casdoor.com/)**: [NMB-Lab/reactadmin-casdoor-authprovider](https://github.com/NMB-Lab/reactadmin-casdoor-authprovider) -- **[Directus](https://directus.io/)**: [marmelab/ra-directus](https://github.com/marmelab/ra-directus) +- **[Directus](https://directus.io/)**: [marmelab/ra-directus](https://github.com/marmelab/ra-directus/blob/main/packages/ra-directus/Readme.md) - **[Firebase Auth (Google, Facebook, GitHub, etc.)](https://firebase.google.com/docs/auth/web/firebaseui)**: [benwinding/react-admin-firebase](https://github.com/benwinding/react-admin-firebase#auth-provider) - **[Google Identity & Google Workspace](https://developers.google.com/identity/gsi/web/guides/overview)**: [marmelab/ra-auth-google](https://github.com/marmelab/ra-auth-google/blob/main/packages/ra-auth-google/Readme.md) -- **[Keycloak](https://www.keycloak.org/)**: [marmelab/ra-keycloak](https://github.com/marmelab/ra-keycloak) -- **[Postgrest](https://postgrest.org/)**: [raphiniert-com/ra-data-postgrest](https://github.com/raphiniert-com/ra-data-postgrest) -- **[Supabase](https://supabase.io/)**: [marmelab/ra-supabase](https://github.com/marmelab/ra-supabase) +- **[Keycloak](https://www.keycloak.org/)**: [marmelab/ra-keycloak](https://github.com/marmelab/ra-keycloak/blob/main/packages/ra-keycloak/Readme.md) +- **[Supabase](https://supabase.io/)**: [marmelab/ra-supabase](https://github.com/marmelab/ra-supabase/blob/main/packages/ra-supabase/README.md) - **[SurrealDB](https://surrealdb.com/)**: [djedi23/ra-surrealdb](https://github.com/djedi23/ra-surrealdb) Beyond ready-to-use providers, you may find help in these third-party tutorials about integrating more authentication backends: diff --git a/docs/AuthRBAC.md b/docs/AuthRBAC.md index cf82e581839..6f37cd27ead 100644 --- a/docs/AuthRBAC.md +++ b/docs/AuthRBAC.md @@ -7,7 +7,7 @@ title: "RBAC" React-admin Enterprise Edition contains [the ra-rbac module](https://marmelab.com/ra-enterprise/modules/ra-rbac), which adds fine-grained permissions to your admin. This module extends the `authProvider` and adds replacement for many react-admin components that use these permissions. - ); ``` -``` You must add a `name` prop to the `` so you can reference it in the permissions. Then, to allow users to access a particular ``, update the permissions definition as follows: `{ action: 'write', resource: '{RESOURCE}.tab.{NAME}' }`, where `RESOURCE` is the resource name, and `NAME` the name you provided to the ``. diff --git a/docs/Configurable.md b/docs/Configurable.md index 37cc53e2ede..3d35b20743c 100644 --- a/docs/Configurable.md +++ b/docs/Configurable.md @@ -214,3 +214,53 @@ const MyAppBar = () => ( ); ``` + +## `` + +The `` is already included in the layouts provided by react-admin. If you are using a custom layout, you need to add the `` component to your layout. + +{% raw %} +```jsx +// in src/MyLayout.js +import * as React from 'react'; +import { Box } from '@mui/material'; +import { AppBar, Menu, Sidebar, Inspector } from 'react-admin'; + +const MyLayout = ({ children, dashboard }) => ( + + + + + + + + + {children} + + + + + +); + +export default MyLayout; +``` +{% endraw %} \ No newline at end of file diff --git a/docs/Create.md b/docs/Create.md index d0f3f3568b8..ab2ca50eb69 100644 --- a/docs/Create.md +++ b/docs/Create.md @@ -50,6 +50,8 @@ const App = () => ( export default App; ``` +## Props + You can customize the `` component using the following props: * [`actions`](#actions): override the actions toolbar with a custom component @@ -599,3 +601,90 @@ export default OrderEdit; ``` **Tip:** If you'd like to avoid creating an intermediate component like ``, or are using an ``, you can use the [``](./Inputs.md#linking-two-inputs) component as an alternative. + +## Controlled Mode + +`` deduces the resource and the initial form values from the URL. This is fine for a creation page, but if you need to let users create records from another page, you probably want to define this parameter yourself. + +In that case, use the [`resource`](#resource) and [`record`](#record) props to set the creation parameters regardless of the URL. + +```jsx +import { Create, SimpleForm, TextInput, SelectInput } from "react-admin"; + +export const BookCreate = () => ( + + + + + + + +); +``` + +**Tip**: You probably also want to customize [the `redirect` prop](#redirect) if you embed an `` component in another page. + +## Headless Version + +Besides preparing a save handler, `` renders the default creation page layout (title, actions, a Material UI ``) and its children. If you need a custom creation layout, you may prefer [the `` component](./CreateBase.md), which only renders its children in a [`CreateContext`](./useCreateContext.md). + +```jsx +import { CreateBase, SelectInput, SimpleForm, TextInput, Title } from "react-admin"; +import { Card, CardContent, Container } from "@mui/material"; + +export const BookCreate = () => ( + + + + <Card> + <CardContent> + <SimpleForm> + <TextInput source="title" /> + <TextInput source="author" /> + <SelectInput source="availability" choices={[ + { id: "in_stock", name: "In stock" }, + { id: "out_of_stock", name: "Out of stock" }, + { id: "out_of_print", name: "Out of print" }, + ]} /> + </SimpleForm> + </CardContent> + </Card> + </Container> + </CreateBase> +); +``` + +In the previous example, `<SimpleForm>` grabs the save handler from the `CreateContext`. + +If you don't need the `CreateContext`, you can use [the `useCreateController` hook](./useCreateController.md), which does the same data fetching as `<CreateBase>` but lets you render the content. + +```jsx +import { useCreateController, SelectInput, SimpleForm, TextInput, Title } from "react-admin"; +import { Card, CardContent, Container } from "@mui/material"; + +export const BookCreate = () => { + const { save } = useCreateController(); + return ( + <Container> + <Title title="Create book" /> + <Card> + <CardContent> + <SimpleForm onSubmit={values => save(values)}> + <TextInput source="title" /> + <TextInput source="author" /> + <SelectInput source="availability" choices={[ + { id: "in_stock", name: "In stock" }, + { id: "out_of_stock", name: "Out of stock" }, + { id: "out_of_print", name: "Out of print" }, + ]} /> + </SimpleForm> + </CardContent> + </Card> + </Container> + ); +}; +``` \ No newline at end of file diff --git a/docs/CreateBase.md b/docs/CreateBase.md index 73e9d1ce7f5..c5c40d01bdf 100644 --- a/docs/CreateBase.md +++ b/docs/CreateBase.md @@ -5,9 +5,11 @@ title: "The CreateBase Component" # `<CreateBase>` -The `<CreateBase>` component is a headless version of `<Create>`: it prepares a form submit handler, and renders its children. +`<CreateBase>` is a headless variant of [`<Create>`](./Create.md). It prepares a form submit handler, and renders its children in a [`CreateContext`](./useCreateContext.md). Use it to build a custom creation page layout. -It does that by calling `useCreateController`, and by putting the result in an `CreateContext`. +Contrary to [`<Create>`](./Create.md), it does not render the page layout, so no title, no actions, and no `<Card>`. + +`<CreateBase>` relies on the [`useCreateController`](./useCreateController.md) hook. ## Usage diff --git a/docs/CreateDialog.md b/docs/CreateDialog.md index eb92cc880e6..1355c8b9eaa 100644 --- a/docs/CreateDialog.md +++ b/docs/CreateDialog.md @@ -15,7 +15,18 @@ This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" s ## Usage -Add the `<CreateDialog>` component as a sibling to a `<List>` component. + +First, install the `@react-admin/ra-form-layout` package: + +```sh +npm install --save @react-admin/ra-form-layout +# or +yarn add @react-admin/ra-form-layout +``` + +**Tip**: [`ra-form-layout`](https://marmelab.com/ra-enterprise/modules/ra-form-layout#createdialog-editdialog--showdialog) is hosted in a private npm registry. You need to subscribe to one of the [Enterprise Edition](https://marmelab.com/ra-enterprise/) plans to access this package. + +Then, add the `<CreateDialog>` component as a sibling to a `<List>` component. ```jsx import { @@ -57,21 +68,177 @@ In the related `<Resource>`, you don't need to declare a `create` component as t **Note**: You can't use the `<CreateDialog>` and have a standard `<Edit>` specified on your `<Resource>`, because the `<Routes>` declarations would conflict. If you need this, use the [`<CreateInDialogButton>`](./CreateInDialogButton.md) instead. -`<CreateDialog>` accepts the same props as the [`<Create>`](./Create.md) component, and the same type of children (e.g. a [`<SimpleForm>`](./SimpleForm.md) element). - -* `children`: the components that renders the form -* `className`: passed to the root component -* [`component`](./Create.md#component): override the root component -* [`disableAuthentication`](./Create.md#disableauthentication): disable the authentication check -* [`mutationOptions`](./Create.md#mutationoptions): options for the `dataProvider.create()` call -* [`record`](./Create.md#record): initialize the form with a record -* [`redirect`](./Create.md#redirect): change the redirect location after successful creation -* [`resource`](./Create.md#resource): override the name of the resource to create -* [`sx`](./Create.md#sx-css-api): Override the styles -* [`title`](./Create.md#title): override the page title -* [`transform`](./Create.md#transform): transform the form data before calling `dataProvider.create()` - -Check [the `ra-form-layout` documentation](https://marmelab.com/ra-enterprise/modules/ra-form-layout#createdialog-editdialog--showdialog) for more details. + +## Props + +`<CreateDialog>` accepts the following props: + +| Prop | Required | Type | Default | Description | +| -------------- | -------- | ----------------- | ------- | ----------- | +| `children` | Required | `ReactNode` | | The content of the dialog. | +| `fullWidth` | Optional | `boolean` | `false` | If `true`, the dialog stretches to the full width of the screen. | +| `maxWidth` | Optional | `string` | `sm` | The max width of the dialog. | +| `mutation Options` | Optional | `object` | | The options to pass to the `useMutation` hook. | +| `resource` | Optional | `string` | | The resource name, e.g. `posts` +| `sx` | Optional | `object` | | Override the styles applied to the dialog component. | +| `transform` | Optional | `function` | | Transform the form data before calling `dataProvider.create()`. | + +## `children` + +`<CreateDialog>` doesn't render any field by default - it delegates this to its children, usually a Form component. + +React-admin provides several built-in form layout components: + +- [`SimpleForm`](./SimpleForm.md) for a single-column layout +- [`TabbedForm`](./TabbedForm.md) for a tabbed layout +- [`AccordionForm`](./AccordionForm.md) for long forms with collapsible sections +- [`LongForm`](./LongForm.md) for long forms with a navigation sidebar +- [`WizardForm`](./WizardForm.md) for multi-step forms +- and [`Form`](./Form.md), a headless component to use as a base for your custom layouts + +To use an alternative form layout, switch the `<CreateDialog>` child component: + +```diff +const MyCreateDialog = () => ( + <CreateDialog fullWidth maxWidth="md"> +- <SimpleForm> ++ <TabbedForm> ++ <TabbedForm.Tab label="Identity"> + <TextInput source="first_name" fullWidth /> + <TextInput source="last_name" fullWidth /> ++ </TabbedForm.Tab> ++ <TabbedForm.Tab label="Informations"> + <DateInput source="dob" label="born" fullWidth /> + <SelectInput source="sex" choices={sexChoices} fullWidth /> ++ </TabbedForm.Tab> +- </SimpleForm> ++ </TabbedForm> + </CreateDialog> +); +``` + +## `fullWidth` + +By default, `<CreateDialog>` renders a [Material UI `<Dialog>`](https://mui.com/material-ui/react-dialog/#full-screen-dialogs) component that takes the width of its content. + +You can make the dialog full width by setting the `fullWidth` prop to `true`: + +```jsx +const MyCreateDialog = () => ( + <CreateDialog fullWidth> + ... + </CreateDialog> +); +``` + +In addition, you can set a dialog maximum width by using the `maxWidth` enumerable in combination with the `fullWidth` boolean. When the `fullWidth` prop is true, the dialog will adapt based on the `maxWidth` value. + +```jsx +const MyCreateDialog = () => ( + <CreateDialog fullWidth maxWidth="sm"> + ... + </CreateDialog> +); +``` + +## `maxWidth` + +The `maxWidth` prop allows you to set the max width of the dialog. It can be one of the following values: `xs`, `sm`, `md`, `lg`, `xl`, `false`. The default is `sm`. + +For example, you can use that prop to make the dialog full width: + +```jsx +const MyCreateDialog = () => ( + <CreateDialog fullWidth maxWidth={false}> + ... + </CreateDialog> +); +``` + +## `mutationOptions` + +The `mutationOptions` prop allows you to pass options to the `useMutation` hook. + +This can be useful e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) to the `dataProvider.create()` call. + +{% raw %} +```jsx +const MyCreateDialog = () => ( + <CreateDialog mutationOptions={{ meta: { fetch: 'author' } }}> + ... + </CreateDialog> +); +``` +{% endraw %} + +## `resource` + +The `resource` prop allows you to pass the resource name to the `<CreateDialog>` component. If not provided, it will be deduced from the resource context. + +This is useful to link to a related record. For instance, the following dialog lets you create the author of a book: + +```jsx +const EditAuthorDialog = () => { + const book = useRecordContext(); + return ( + <CreateDialog resource="authors"> + ... + </CreateDialog> + ); +}; +``` + +## `sx` + +Customize the styles applied to the Material UI `<Dialog>` component: + +{% raw %} +```jsx +const MyCreateDialog = () => ( + <CreateDialog sx={{ backgroundColor: 'paper' }}> + ... + </CreateDialog> +); +``` +{% endraw %} + +## `transform` + +To transform a record after the user has submitted the form but before the record is passed to `dataProvider.create()`, use the `transform` prop. It expects a function taking a record as argument, and returning a modified record. For instance, to add a computed field upon edition: + +```jsx +export const UseCreate = () => { + const transform = data => ({ + ...data, + fullName: `${data.firstName} ${data.lastName}` + }); + return ( + <CreateDialog transform={transform}> + ... + </CreateDialog> + ); +} +``` + +The `transform` function can also return a `Promise`, which allows you to do all sorts of asynchronous calls (e.g. to the `dataProvider`) during the transformation. + +**Tip**: If you want to have different transformations based on the button clicked by the user (e.g. if the creation form displays two submit buttons, one to "save", and another to "save and notify other admins"), you can set the `transform` prop on [the `<SaveButton>` component](./SaveButton.md), too. + +**Tip**: The `transform` function also gets the `previousData` in its second argument: + +```jsx +export const UseCreate = () => { + const transform = (data, { previousData }) => ({ + ...data, + avoidChangeField: previousData.avoidChangeField + }); + return ( + <CreateDialog transform={transform}> + ... + </CreateDialog> + ); +} +``` ## Usage Without Routing diff --git a/docs/CreateInDialogButton.md b/docs/CreateInDialogButton.md index b5dd02e3320..cd1581568c4 100644 --- a/docs/CreateInDialogButton.md +++ b/docs/CreateInDialogButton.md @@ -7,19 +7,29 @@ title: "CreateInDialogButton" This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> component offers a way to open a `<Create>` view inside a dialog, hence allowing to create a new record without leaving the current view. -It can be useful in case you want the ability to create a record linked by a reference to the currently edited record, or if you have a nested `<Datagrid>` inside a `<Show>` or an `<Edit>` view. - <video controls autoplay playsinline muted loop> <source src="https://marmelab.com/ra-enterprise/modules/assets/ra-form-layout/latest/CreateInDialogButton.webm" type="video/webm" /> <source src="https://marmelab.com/ra-enterprise/modules/assets/ra-form-layout/latest/CreateInDialogButton.mp4" type="video/mp4" /> Your browser does not support the video tag. </video> +It can be useful in case you want the ability to create a record linked by a reference to the currently edited record, or if you have a nested `<Datagrid>` inside a `<Show>` or an `<Edit>` view. + Note that this component doesn't use routing, so it doesn't change the URL. It's therefore not possible to bookmark the creation dialog, or to link to it from another page. If you need that functionality, use [`<CreateDialog>`](./CreateDialog.md) instead. ## Usage -Put `<CreateInDialogButton>` wherever you would put a `<CreateButton>`, and use the same children as you would for a `<Create>` component (e.g. a `<SimpleForm>`): +First, install the `@react-admin/ra-form-layout` package: + +```sh +npm install --save @react-admin/ra-form-layout +# or +yarn add @react-admin/ra-form-layout +``` + +**Tip**: [`ra-form-layout`](https://marmelab.com/ra-enterprise/modules/ra-form-layout#createindialogbutton-editindialogbutton-and-EditInDialogButton) is hosted in a private npm registry. You need to subscribe to one of the [Enterprise Edition](https://marmelab.com/ra-enterprise/) plans to access this package. + +Then, put `<CreateInDialogButton>` wherever you would put a `<CreateButton>`, and use the same children as you would for a `<Create>` component (e.g. a `<SimpleForm>`): {% raw %} ```jsx @@ -63,15 +73,195 @@ const CompanyShow = () => ( In the above example, `<CreateInDialogButton>` is used to create a new employee for the current company. [The `<WithRecord>` component](./WithRecord.md) helps to set the new employee company id by default. +## Props + `<CreateInDialogButton>` accepts the following props: -* `inline`: set to true to display only a Material UI `<IconButton>` instead of the full `<Button>`. The label will still be available as a `<Tooltip>` though. -* `icon`: allows to override the default icon. -* `label`: allows to override the default button label. I18N is supported. -* `ButtonProps`: object containing props to pass to Material UI's `<Button>`. -* remaining props will be passed to the [`<CreateDialog>`](./CreateDialog.md) dialog component. +| Prop | Required | Type | Default | Description | +| -------------- | -------- | ----------------- | ------- | ----------- | +| `children` | Required | `ReactNode` | | The content of the dialog. | +| `ButtonProps` | Optional | `object` | | Object containing props to pass to Material UI's `<Button>`. | +| `fullWidth` | Optional | `boolean` | `false` | If `true`, the dialog stretches to the full width of the screen. | +| `icon` | Optional | `ReactElement` | | Allows to override the default icon. | +| `inline` | Optional | `boolean` | | Set to true to display only a Material UI `<IconButton>` instead of the full `<Button>`. | +| `label` | Optional | `string` | | Allows to override the default button label. I18N is supported. | +| `maxWidth` | Optional | `string` | `sm` | The max width of the dialog. | +| `mutation Options` | Optional | `object` | | The options to pass to the `useMutation` hook. | +| `resource` | Optional | `string` | | The resource name, e.g. `posts` +| `sx` | Optional | `object` | | Override the styles applied to the dialog component. | + + +## `children` + +`<CreateInDialogButton>` doesn't render any field by default - it delegates this to its children, usually a Form component. + +React-admin provides several built-in form layout components: + +- [`SimpleForm`](./SimpleForm.md) for a single-column layout +- [`TabbedForm`](./TabbedForm.md) for a tabbed layout +- [`AccordionForm`](./AccordionForm.md) for long forms with collapsible sections +- [`LongForm`](./LongForm.md) for long forms with a navigation sidebar +- [`WizardForm`](./WizardForm.md) for multi-step forms +- and [`Form`](./Form.md), a headless component to use as a base for your custom layouts + +To use an alternative form layout, switch the `<CreateInDialogButton>` child component: + +```diff +const CreateButton = () => ( + <CreateInDialogButton fullWidth maxWidth="md"> +- <SimpleForm> ++ <TabbedForm> ++ <TabbedForm.Tab label="Identity"> + <TextInput source="first_name" fullWidth /> + <TextInput source="last_name" fullWidth /> ++ </TabbedForm.Tab> ++ <TabbedForm.Tab label="Informations"> + <DateInput source="dob" label="born" fullWidth /> + <SelectInput source="sex" choices={sexChoices} fullWidth /> ++ </TabbedForm.Tab> +- </SimpleForm> ++ </TabbedForm> + </CreateInDialogButton> +); +``` + +## `ButtonProps` + +The `ButtonProps` prop allows you to pass props to the MUI `<Button>` component. For instance, to change the color and size of the button: + +{% raw %} +```jsx +const CreateButton = () => ( + <CreateInDialogButton ButtonProps={{ color: 'primary', fullWidth: true }}> + <SimpleForm> + ... + </SimpleForm> + </CreateInDialogButton> +); +``` +{% endraw %} + +## `fullWidth` + +By default, `<CreateInDialogButton>` renders a [Material UI `<Dialog>`](https://mui.com/material-ui/react-dialog/#full-screen-dialogs) component that takes the width of its content. + +You can make the dialog full width by setting the `fullWidth` prop to `true`: + +```jsx +const CreateButton = () => ( + <CreateInDialogButton fullWidth> + ... + </CreateInDialogButton> +); +``` + +In addition, you can set a dialog maximum width by using the `maxWidth` enumerable in combination with the `fullWidth` boolean. When the `fullWidth` prop is true, the dialog will adapt based on the `maxWidth` value. + +```jsx +const CreateButton = () => ( + <CreateInDialogButton fullWidth maxWidth="sm"> + ... + </CreateInDialogButton> +); +``` + +## `icon` + +The `icon` prop allows you to pass an icon to the button. It can be a MUI icon component, or a custom icon component. + +```jsx +import { Create } from '@mui/icons-material'; + +const CreateButton = () => ( + <CreateInDialogButton icon={<Create />}> + ... + </CreateInDialogButton> +); +``` -Check out [the `ra-form-layout` documentation](https://marmelab.com/ra-enterprise/modules/ra-form-layout#createindialogbutton-editindialogbutton-and-showindialogbutton) for more details. +## `inline` + +By default, `<CreateInDialogButton>` renders a `<Button>` component. If you want to display only an `<IconButton>`, set the `inline` prop to `true`: + +```jsx +const CreateButton = () => ( + <CreateInDialogButton inline> + ... + </CreateInDialogButton> +); +``` + +## `label` + +The `label` prop allows you to pass a custom label to the button, instead of the default ("Create"). It can be a string, or a React element. + +```jsx +const CreateButton = () => ( + <CreateInDialogButton label="Edit details"> + ... + </CreateInDialogButton> +); +``` + +## `maxWidth` + +The `maxWidth` prop allows you to set the max width of the dialog. It can be one of the following values: `xs`, `sm`, `md`, `lg`, `xl`, `false`. The default is `sm`. + +For example, you can use that prop to make the dialog full width: + +```jsx +const CreateButton = () => ( + <CreateInDialogButton fullWidth maxWidth={false}> + ... + </CreateInDialogButton> +); +``` + +## `mutationOptions` + +The `mutationOptions` prop allows you to pass options to the `useMutation` hook. + +This can be useful e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) to the `dataProvider.create()` call. + +{% raw %} +```jsx +const CreateButton = () => ( + <CreateInDialogButton mutationOptions={{ meta: { fetch: 'author' } }}> + ... + </CreateInDialogButton> +); +``` +{% endraw %} + +## `resource` + +The `resource` prop allows you to pass the resource name to the `<CreateInDialogButton>` component. If not provided, it will be deduced from the resource context. + +This is useful to link to a related record. For instance, the following button lets you create the author of a book: + +```jsx +const CreateAuthorButton = () => { + return ( + <CreateInDialogButton resource="authors"> + ... + </CreateInDialogButton> + ); +}; +``` + +## `sx` + +Customize the styles applied to the Material UI `<Dialog>` component: + +{% raw %} +```jsx +const CreateButton = () => ( + <CreateInDialogButton sx={{ backgroundColor: 'paper' }}> + ... + </CreateInDialogButton> +); +``` +{% endraw %} ## Combining With `<EditInDialogButton>` diff --git a/docs/DataProviderList.md b/docs/DataProviderList.md index 9a29e62b2e0..1305b6c3446 100644 --- a/docs/DataProviderList.md +++ b/docs/DataProviderList.md @@ -14,7 +14,7 @@ If you can't find a Data Provider for your backend below, no worries! [Writing a * **[Blitz-js](https://blitzjs.com/docs)**: [theapexlab/ra-data-blitz](https://github.com/theapexlab/ra-data-blitz) * **[Configurable Identity Property REST Client](https://github.com/zachrybaker/ra-data-rest-client)**: [zachrybaker/ra-data-rest-client](https://github.com/zachrybaker/ra-data-rest-client) * **[coreBOS](https://corebos.com/)**: [React-Admin coreBOS Integration](https://github.com/coreBOS/reactadminportal) -* **[Directus](https://directus.io/)**: [marmelab/ra-directus](https://github.com/marmelab/ra-directus) +* **[Directus](https://directus.io/)**: [marmelab/ra-directus](https://github.com/marmelab/ra-directus/blob/main/packages/ra-directus/Readme.md) * **[Django Rest Framework](https://www.django-rest-framework.org/)**: [bmihelac/ra-data-django-rest-framework](https://github.com/bmihelac/ra-data-django-rest-framework) * **[Eve](https://docs.python-eve.org/en/stable/)**: [smeng9/ra-data-eve](https://github.com/smeng9/ra-data-eve) * **[Express & Mongoose](https://github.com/NathanAdhitya/express-mongoose-ra-json-server)**: [NathanAdhitya/express-mongoose-ra-json-server](https://github.com/NathanAdhitya/express-mongoose-ra-json-server) @@ -59,7 +59,7 @@ If you can't find a Data Provider for your backend below, no worries! [Writing a * **[Spring Boot](https://spring.io/projects/spring-boot)**: [vishpat/ra-data-springboot-rest](https://github.com/vishpat/ra-data-springboot-rest) * **[Strapi v3/v4](https://strapi.io/)**: [nazirov91/ra-strapi-rest](https://github.com/nazirov91/ra-strapi-rest) * **[Strapi v4](https://strapi.io/)**: [garridorafa/ra-strapi-v4-rest](https://github.com/garridorafa/ra-strapi-v4-rest) -* **[Supabase](https://supabase.io/)**: [marmelab/ra-supabase](https://github.com/marmelab/ra-supabase) +* **[Supabase](https://supabase.io/)**: [marmelab/ra-supabase](https://github.com/marmelab/ra-supabase/blob/main/packages/ra-supabase/README.md) - **[SurrealDB](https://surrealdb.com/)**: [djedi23/ra-surrealdb](https://github.com/djedi23/ra-surrealdb) * **[TreeQL / PHP-CRUD-API](https://treeql.org/)**: [nkappler/ra-data-treeql](https://github.com/nkappler/ra-data-treeql) * **[WooCommerce REST API](https://woocommerce.github.io/woocommerce-rest-api-docs)**: [zackha/ra-data-woocommerce](https://github.com/zackha/ra-data-woocommerce) diff --git a/docs/DataProviderWriting.md b/docs/DataProviderWriting.md index 173b53ef380..e21dd13a5b7 100644 --- a/docs/DataProviderWriting.md +++ b/docs/DataProviderWriting.md @@ -305,7 +305,7 @@ interface DeleteParams { interface DeleteResult { data: Record; } -function delete(resource: string, params: DeleteParams): Promise<DeleteResult> +function _delete(resource: string, params: DeleteParams): Promise<DeleteResult> ``` **Example** diff --git a/docs/Datagrid.md b/docs/Datagrid.md index 2544862c69d..29e608e79c7 100644 --- a/docs/Datagrid.md +++ b/docs/Datagrid.md @@ -5,7 +5,7 @@ title: "The Datagrid Component" # `<Datagrid>` -The `<Datagrid>` component renders a list of records as a table. It supports sorting, row selection for bulk actions, and an expand panel. It is usually used as a descendant of the [`<List>`](List.md#list) and [`<ReferenceManyField>`](./ReferenceManyField.md) components. Outside these components, it must be used inside a `ListContext`. +The `<Datagrid>` component renders a list of records as a table. It supports sorting, row selection for bulk actions, and an expand panel. It is usually used as a descendant of the [`<List>`](./List.md#list) and [`<ReferenceManyField>`](./ReferenceManyField.md) components. Outside these components, it must be used inside a `ListContext`. <video controls autoplay playsinline muted loop> <source src="./img/Datagrid.mp4" type="video/mp4"/> diff --git a/docs/DatagridAG.md b/docs/DatagridAG.md index f65623e75fa..1a45e3d9552 100644 --- a/docs/DatagridAG.md +++ b/docs/DatagridAG.md @@ -44,15 +44,15 @@ Additionally, `<DatagridAG>` is compatible with the [Enterprise version of ag-gr ## Usage -First, install the `@react-admin/ra-editable-datagrid` package: +First, install the `@react-admin/ra-datagrid-ag` package: ```sh -npm install --save @react-admin/ra-editable-datagrid +npm install --save @react-admin/ra-datagrid-ag # or -yarn add @react-admin/ra-editable-datagrid +yarn add @react-admin/ra-datagrid-ag ``` -**Tip**: `ra-editable-datagrid` is hosted in a private npm registry. You need to subscribe to one of the [Enterprise Edition](https://marmelab.com/ra-enterprise/) plans to access this package. +**Tip**: `ra-datagrid-ag` is hosted in a private npm registry. You need to subscribe to one of the [Enterprise Edition](https://marmelab.com/ra-enterprise/) plans to access this package. Then, use `<DatagridAG>` as a child of a react-admin `<List>`, `<ReferenceManyField>`, or any other component that creates a `ListContext`. @@ -61,7 +61,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -88,18 +88,21 @@ Here are the important things to note: `<DatagridAG>` doesn't currently support the [server-side row model](https://www.ag-grid.com/react-data-grid/row-models/), so you have to load all data client-side. The client-side performance isn't affected by a large number of records, as ag-grid uses [DOM virtualization](https://www.ag-grid.com/react-data-grid/dom-virtualisation/). `<DatagridAG>` has been tested with 10,000 records without any performance issue. +> **Note:** To mitigate an [issue](https://github.com/ag-grid/ag-grid/issues/7241) preventing tree shaking with some bundlers, `<DatagridAG>` uses React's [lazy loading](https://react.dev/reference/react/lazy#suspense-for-code-splitting) by default. This feature relies on [dynamic `import()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import), which might require support from your bundler or framework. + ## Props -| Prop | Required | Type | Default | Description | -| --- | --- | --- | --- | --- | -| `columnDefs` | Required | Array | n/a | The columns definitions | -| `bulkActionButtons` | Optional | Element | `<BulkDelete Button>` | The component used to render the bulk action buttons | -| `cellRenderer` | Optional | String, Function or Element | | Allows to use a custom component to render the cell content | -| `defaultColDef` | Optional | Object | | The default column definition (applied to all columns) | -| `mutationOptions` | Optional | Object | | The mutation options | -| `theme` | Optional | String | `'ag-theme-alpine'` | The name of the ag-grid theme | -| `pagination` | Optional | Boolean | `true` | Enable or disable pagination | -| `sx` | Optional | Object | | The sx prop passed down to the wrapping `<div>` element | +| Prop | Required | Type | Default | Description | +| ------------------- | -------- | --------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------- | +| `columnDefs` | Required | Array | n/a | The columns definitions | +| `bulkActionButtons` | Optional | Element | `<BulkDelete Button>` | The component used to render the bulk action buttons | +| `cellRenderer` | Optional | String, Function or Element | | Allows to use a custom component to render the cell content | +| `defaultColDef` | Optional | Object | | The default column definition (applied to all columns) | +| `mutationOptions` | Optional | Object | | The mutation options | +| `preferenceKey` | Optional | String or `false` | `${resource}.ag-grid.params` | The key used to persist columns order and sizing in the Store. `false` disables persistence. | +| `sx` | Optional | Object | | The sx prop passed down to the wrapping `<div>` element | +| `theme` | Optional | String | `'ag-theme-alpine'` | The name of the ag-grid theme | +| `pagination` | Optional | Boolean | `true` | Enable or disable pagination | `<DatagridAG>` also accepts the same props as [`<AgGridReact>`](https://www.ag-grid.com/react-data-grid/grid-options/) with the exception of `rowData`, since the data is fetched from the List context. @@ -141,7 +144,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; const truncate = (str: string, n: number) => { return str.length > n ? str.slice(0, n - 1) + '...' : str; @@ -190,7 +193,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -220,7 +223,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { EmailField, List, ReferenceField, TextField } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const CommentList = () => { const columnDefs = [ @@ -266,7 +269,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List, BulkExportButton, BulkDeleteButton } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; const PostBulkActionButtons = () => ( <> @@ -312,7 +315,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -336,6 +339,49 @@ export const PostList = () => { {% endraw %} +This also allows to display a notification after the mutation succeeds. + +{% raw %} + +```tsx +import 'ag-grid-community/styles/ag-grid.css'; +import 'ag-grid-community/styles/ag-theme-alpine.css'; +import React from 'react'; +import { List, useNotify } from 'react-admin'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; + +export const PostList = () => { + const columnDefs = [ + { field: 'title' }, + { field: 'published_at' }, + { field: 'body' }, + ]; + const notify = useNotify(); + const onSuccess = React.useCallback(() => { + notify('ra.notification.updated', { + type: 'info', + messageArgs: { + smart_count: 1, + }, + undoable: true, + }); + }, [notify]); + return ( + <List perPage={10000} pagination={false}> + <DatagridAG + columnDefs={columnDefs} + mutationOptions={{ + mutationMode: 'undoable', + onSuccess, + }} + /> + </List> + ); +}; +``` + +{% endraw %} + ## `theme` You can use a different theme for the grid by passing a `theme` prop. You can for instance use one of the [themes provided by ag-grid](https://www.ag-grid.com/react-data-grid/themes/), like `ag-theme-balham` or `ag-theme-alpine-dark`: @@ -345,7 +391,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -376,7 +422,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; const CarList = () => { const columnDefs = [ @@ -405,6 +451,28 @@ const CarList = () => { Your browser does not support the video tag. </video> +### `preferenceKey` + +`<DatagridAG>` will store the order and size of columns in the [Store](./Store.md), under the key `${resource}.ag-grid.params`. + +If you wish to change the key used to store the columns order and size, you can pass a `preferenceKey` prop to `<DatagridAG>`. + +```tsx +<List perPage={10000} pagination={false}> + <DatagridAG columnDefs={columnDefs} preferenceKey="my-post-list" /> +</List> +``` + +If, instead, you want to disable the persistence of the columns order and size, you can pass `false` to the `preferenceKey` prop: + +```tsx +<List perPage={10000} pagination={false}> + <DatagridAG columnDefs={columnDefs} preferenceKey={false} /> +</List> +``` + +**Note:** Saving the columns size in the Store is disabled when using the [`flex` config](https://www.ag-grid.com/react-data-grid/column-sizing/#column-flex). + ## `sx` You can also use [the `sx` prop](./SX.md) to customize the grid's style: @@ -416,7 +484,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -448,7 +516,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -481,7 +549,7 @@ import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { AgGridReact } from 'ag-grid-react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -518,7 +586,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -547,7 +615,7 @@ import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { AgGridReact } from 'ag-grid-react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -598,7 +666,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -630,7 +698,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List, BulkExportButton, BulkDeleteButton } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; const PostBulkActionButtons = () => ( <> @@ -680,7 +748,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; const CarList = () => { const columnDefs = [ @@ -712,7 +780,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React, { useMemo } from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; import 'ag-grid-enterprise'; const CarList = () => { @@ -754,7 +822,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -779,7 +847,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -802,7 +870,7 @@ import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-alpine.css'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; export const PostList = () => { const columnDefs = [ @@ -839,7 +907,7 @@ import { AgGridReact } from 'ag-grid-react'; import 'ag-grid-enterprise'; import React from 'react'; import { List } from 'react-admin'; -import { DatagridAG } from '@react-admin/ra-editable-datagrid'; +import { DatagridAG } from '@react-admin/ra-datagrid-ag'; const OlympicWinnersList = () => { const columnDefs = [ diff --git a/docs/Demos.md b/docs/Demos.md index ba3619bd643..920e01d3fdf 100644 --- a/docs/Demos.md +++ b/docs/Demos.md @@ -7,8 +7,6 @@ title: "React-admin demos" If you want to see what react-admin is capable of, or if you want to learn from apps built by seasoned react-admin developers, check out these demos. -## Overview - <style> .demos-list { display: grid; @@ -115,7 +113,7 @@ If you want to see what react-admin is capable of, or if you want to learn from <div class="demos-list"> <div class="card"> - <img src="./img/demo-ecommerce-oss.png" alt="ecommerce-oss"> + <a href="#e-commerce" class="no-decoration"><img src="./img/demo-ecommerce-oss.png" alt="ecommerce-oss"></a> <div class="content-card"> <a href="#e-commerce" class="no-decoration"> <p class="title-card"><b>E-commerce</b></p> @@ -130,7 +128,7 @@ If you want to see what react-admin is capable of, or if you want to learn from </div> </div> <div class="card"> - <img src="./img/demo-ecommerce-ee.png" alt="ecommerce-ee"> + <a href="#e-commerce-enterprise" class="no-decoration"><img src="./img/demo-ecommerce-ee.png" alt="ecommerce-ee"></a> <div class="content-card"> <a href="#e-commerce-enterprise" class="no-decoration"> <p class="title-card"><b>E-commerce Enterprise</b></p> @@ -145,11 +143,11 @@ If you want to see what react-admin is capable of, or if you want to learn from </div> </div> <div class="card"> - <img src="./img/demo-CRM.png" alt="CRM"> + <a href="#crm" class="no-decoration"><img src="./img/demo-CRM.png" alt="CRM"></a> <div class="content-card"> <a href="#crm" class="no-decoration"> <p class="title-card"><b>CRM</b></p> - <p class="description-card">A complete CRM app allowing to manage contacts, companies, deals, notes, tasks, and tags. Built by the core team.</p> + <p class="description-card">A complete CRM app with an address book and a Kanban board for deals. Built by the core team.</p> </a> </div> <div class="card-footer"> @@ -160,11 +158,11 @@ If you want to see what react-admin is capable of, or if you want to learn from </div> </div> <div class="card"> - <img src="./img/demo-help-desk.png" alt="help-desk"> + <a href="#help-desk" class="no-decoration"><img src="./img/demo-help-desk.png" alt="help-desk"></a> <div class="content-card"> <a href="#help-desk" class="no-decoration"> <p class="title-card"><b>Help Desk</b></p> - <p class="description-card">A simple help desk app allowing to manage tickets, users, and tags. Built by the core team.</p> + <p class="description-card">A ticketing app with realtime collaboration and site-wide search. Built by the core team.</p> </a> </div> <div class="card-footer"> @@ -175,26 +173,26 @@ If you want to see what react-admin is capable of, or if you want to learn from </div> </div> <div class="card"> - <img src="./img/navidrome.png" alt="Music Player"> + <a href="#note-taking-app" class="no-decoration"><img src="./img/writers-delight.png" alt="Music Player"></a> <div class="content-card"> - <a href="#music-player" class="no-decoration"> - <p class="title-card"><b>Music Player</b></p> - <p class="description-card">Navidrome is a Spotify clone allowing to manage songs, artists, playlists, and favorites.</p> + <a href="#note-taking-app" class="no-decoration"> + <p class="title-card"><b>Note-taking app</b></p> + <p class="description-card">Writer's Delight lets you write notes, essays, and stories with an AI assistant. Built by the core team.</p> </a> </div> <div class="card-footer"> <div class="links-container"> - <p><b><a href="https://demo.navidrome.org/app/" class="demo link">Demo</a></b></p> - <p><b><a href="https://github.com/navidrome/navidrome/" class="source-code link">Source code</a></b></p> + <p><b><a href="https://marmelab.com/writers-delight/" class="demo link">Demo</a></b></p> + <p><b><a href="https://github.com/marmelab/writers-delight/" class="source-code link">Source code</a></b></p> </div> </div> </div> <div class="card"> - <img src="./img/blog_demo.png" alt="Blog"> + <a href="#blog" class="no-decoration"><img src="./img/blog_demo.png" alt="Blog"></a> <div class="content-card"> <a href="#blog" class="no-decoration"> <p class="title-card"><b>Blog</b></p> - <p class="description-card">A simple application with posts, comments and users that we use for our e2e tests. Not designed to have a good UX, but to use most of the react-admin features. Built by the core team.</p> + <p class="description-card">A simple application with posts, comments and users that we use for our e2e tests. Designed to use most of the react-admin features. Built by the core team.</p> </a> </div> <div class="card-footer"> @@ -204,6 +202,21 @@ If you want to see what react-admin is capable of, or if you want to learn from </div> </div> </div> + <div class="card"> + <a href="#music-player" class="no-decoration"><img src="./img/navidrome.png" alt="Music Player"></a> + <div class="content-card"> + <a href="#music-player" class="no-decoration"> + <p class="title-card"><b>Music Player</b></p> + <p class="description-card">Navidrome is a Spotify clone allowing to manage songs, artists, playlists, and favorites.</p> + </a> + </div> + <div class="card-footer"> + <div class="links-container"> + <p><b><a href="https://demo.navidrome.org/app/" class="demo link">Demo</a></b></p> + <p><b><a href="https://github.com/navidrome/navidrome/" class="source-code link">Source code</a></b></p> + </div> + </div> + </div> </div> ## E-commerce @@ -268,12 +281,12 @@ A complete CRM app allowing to manage contacts, companies, deals, notes, tasks, The source shows how to implement the following features: - [Horizontal navigation](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/Layout.tsx) +- [Trello-like Kanban board for the deals pipeline](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/deals/DealListContent.tsx) - [Custom d3.js / Nivo Chart in the dashboard](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/dashboard/DealsChart.tsx) - [Add or remove tags to a contact](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/contacts/TagsListEdit.tsx) - [Use dataProvider hooks to update notes](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/notes/Note.tsx) - [Custom grid layout for companies](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/companies/GridList.tsx) - [Filter by "my favorites" in the company list](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/deals/OnlyMineInput.tsx) -- [Trello-like board for the deals pipeline](https://github.com/marmelab/react-admin/blob/7c60db09aea34a90607a4e7560e9e4b51bd7b9a3/examples/crm/src/deals/DealListContent.tsx) ## Help Desk @@ -300,14 +313,28 @@ The source shows how to implement the following features: * [Mark as read on visit](https://github.com/marmelab/react-admin-helpdesk/blob/6208ab49597544f0e8d7e238c5c676f73f30c114/src/tickets/TicketShow.tsx#L18) * [List with live updates](https://github.com/marmelab/react-admin-helpdesk/blob/6208ab49597544f0e8d7e238c5c676f73f30c114/src/tickets/useGetTicketReadsForRecord.ts) -## Music Player +## Note-taking App -Navidrome is a Spotify clone allowing to manage songs, artists, playlists, and favorites. +Writer's Delight lets you write notes, essays, and stories with an AI assistant. Built by the core team. -![Navidrome](./img/navidrome.png) +[<img src="https://github.com/marmelab/writers-delight/raw/main/public/writers-delight.png" class="no-shadow" alt="Writer's Delight" />](https://marmelab.com/writers-delight/) -* Demo: [https://demo.navidrome.org/app/](https://demo.navidrome.org/app/) -* Source code: [https://github.com/navidrome/navidrome/](https://github.com/navidrome/navidrome/) +* Demo: [https://marmelab.com/writers-delight/](https://marmelab.com/writers-delight/) +* Source code: [https://github.com/marmelab/writers-delight/](https://github.com/marmelab/writers-delight/) + +The source shows how to implement the following features: + +* [Predictive Text Input](https://github.com/marmelab/writers-delight/blob/main/src/compositions/CompositionEdit.tsx#L34) +* [AutoSave](https://github.com/marmelab/writers-delight/blob/main/src/compositions/CompositionEdit.tsx#L30) +* [Infinite List](https://github.com/marmelab/writers-delight/blob/main/src/compositions/CompositionList.tsx#L56) +* [Edit with List sidebar](https://github.com/marmelab/writers-delight/blob/main/src/compositions/CompositionList.tsx#L43) +* [Offline-first data provider](https://github.com/marmelab/writers-delight/blob/main/src/dataProvider.ts#L26) +* [Custom Layout](https://github.com/marmelab/writers-delight/blob/main/src/Layout.tsx) +* [Custom theme](https://github.com/marmelab/writers-delight/blob/main/src/App.tsx#L8-L12) +* [Splash Screen](https://github.com/marmelab/writers-delight/blob/main/src/SplashScreen.tsx) +* [Headless Delete](https://github.com/marmelab/writers-delight/blob/main/src/compositions/MoreActionsButton.tsx#L24) +* [useCreate](https://github.com/marmelab/writers-delight/blob/main/src/compositions/CreateCompositionButton.tsx#L6) +* [useStore](https://github.com/marmelab/writers-delight/blob/main/src/compositions/AISwitch.tsx#L23) ## Blog @@ -318,6 +345,15 @@ A simple application with posts, comments and users that we use for our e2e test * Demo: [https://stackblitz.com/github/marmelab/react-admin/tree/master/examples/simple](https://stackblitz.com/github/marmelab/react-admin/tree/master/examples/simple) * Source code: [https://github.com/marmelab/react-admin/tree/master/examples/simple](https://github.com/marmelab/react-admin/tree/master/examples/simple) +## Music Player + +Navidrome is a Spotify clone allowing to manage songs, artists, playlists, and favorites. + +![Navidrome](./img/navidrome.png) + +* Demo: [https://demo.navidrome.org/app/](https://demo.navidrome.org/app/) +* Source code: [https://github.com/navidrome/navidrome/](https://github.com/navidrome/navidrome/) + ## Broadcom Layer 7 API Hub A framework built on top of react-admin for building developer portals. diff --git a/docs/Edit.md b/docs/Edit.md index efae0b70f62..d16c8a90860 100644 --- a/docs/Edit.md +++ b/docs/Edit.md @@ -58,6 +58,8 @@ const App = () => ( export default App; ``` +## Props + You can customize the `<Edit>` component using the following props: * [`actions`](#actions): override the actions toolbar with a custom component @@ -770,3 +772,90 @@ export const PostEdit = () => ( ``` **Tips:** If you want users to be warned if they haven't pressed the Save button when they browse to another record, you can follow the tutorial [Navigating Through Records In`<Edit>` Views](./PrevNextButtons.md#navigating-through-records-in-edit-views-after-submit). + +## Controlled Mode + +`<Edit>` deduces the resource and the record id from the URL. This is fine for an edition page, but if you need to let users edit records from another page, you probably want to define the edit parameters yourself. + +In that case, use the [`resource`](#resource) and [`id`](#id) props to set the edit parameters regardless of the URL. + +```jsx +import { Edit, SimpleForm, TextInput, SelectInput } from "react-admin"; + +export const BookEdit = ({ id }) => ( + <Edit resource="books" id={id} redirect={false}> + <SimpleForm> + <TextInput source="title" /> + <TextInput source="author" /> + <SelectInput source="availability" choices={[ + { id: "in_stock", name: "In stock" }, + { id: "out_of_stock", name: "Out of stock" }, + { id: "out_of_print", name: "Out of print" }, + ]} /> + </SimpleForm> + </Edit> +); +``` + +**Tip**: You probably also want to customize [the `redirect` prop](#redirect) if you embed an `<Edit>` component in another page. + +## Headless Version + +Besides fetching a record and preparing a save handler, `<Edit>` renders the default edition page layout (title, actions, a Material UI `<Card>`) and its children. If you need a custom edition layout, you may prefer [the `<EditBase>` component](./EditBase.md), which only renders its children in an [`EditContext`](./useEditContext.md). + +```jsx +import { EditBase, SelectInput, SimpleForm, TextInput, Title } from "react-admin"; +import { Card, CardContent, Container } from "@mui/material"; + +export const BookEdit = () => ( + <EditBase> + <Container> + <Title title="Book Edition" /> + <Card> + <CardContent> + <SimpleForm> + <TextInput source="title" /> + <TextInput source="author" /> + <SelectInput source="availability" choices={[ + { id: "in_stock", name: "In stock" }, + { id: "out_of_stock", name: "Out of stock" }, + { id: "out_of_print", name: "Out of print" }, + ]} /> + </SimpleForm> + </CardContent> + </Card> + </Container> + </EditBase> +); +``` + +In the previous example, `<SimpleForm>` grabs the record and the save handler from the `EditContext`. + +If you don't need the `EditContext`, you can use [the `useEditController` hook](./useEditController.md), which does the same data fetching as `<EditBase>` but lets you render the content. + +```tsx +import { useEditController, SelectInput, SimpleForm, TextInput, Title } from "react-admin"; +import { Card, CardContent, Container } from "@mui/material"; + +export const BookEdit = () => { + const { record, save } = useEditController(); + return ( + <Container> + <Title title={`Edit book ${record?.title}`} /> + <Card> + <CardContent> + <SimpleForm record={record} onSubmit={values => save(values)}> + <TextInput source="title" /> + <TextInput source="author" /> + <SelectInput source="availability" choices={[ + { id: "in_stock", name: "In stock" }, + { id: "out_of_stock", name: "Out of stock" }, + { id: "out_of_print", name: "Out of print" }, + ]} /> + </SimpleForm> + </CardContent> + </Card> + </Container> + ); +}; +``` \ No newline at end of file diff --git a/docs/EditBase.md b/docs/EditBase.md index 33c39947ce5..e6965400ff8 100644 --- a/docs/EditBase.md +++ b/docs/EditBase.md @@ -5,39 +5,44 @@ title: "The EditBase Component" # `<EditBase>` -The `<EditBase>` component is a headless version of [`<Edit>`](./Edit.md): it fetches a record based on the URL, prepares a form submit handler, and renders its children. +`<EditBase>` is a headless variant of [`<Edit>`](./Edit.md): it fetches a record based on the URL, prepares a form submit handler, and renders its children inside an [`EditContext`](./useEditContext.md). Use it to build a custom edition page layout. -It does that by calling [`useEditController`](./useEditController.md), and by putting the result in an `EditContext`. +Contrary to [`<Edit>`](./Edit.md), it does not render the page layout, so no title, no actions, and no `<Card>`. + +`<EditBase>` relies on the [`useEditController`](./useEditController.md) hook. ## Usage Use `<EditBase>` to create a custom Edition view, with exactly the content you add as child and nothing else (no title, Card, or list of actions as in the `<Edit>` component). ```jsx -import * as React from "react"; -import { EditBase, SimpleForm, TextInput, SelectInput } from "react-admin"; -import { Card } from "@mui/material"; +import { EditBase, SelectInput, SimpleForm, TextInput, Title } from "react-admin"; +import { Card, CardContent, Container } from "@mui/material"; export const BookEdit = () => ( <EditBase> - <div> + <Container> <Title title="Book Edition" /> <Card> - <SimpleForm> - <TextInput source="title" /> - <TextInput source="author" /> - <SelectInput source="availability" choices={[ - { id: "in_stock", name: "In stock" }, - { id: "out_of_stock", name: "Out of stock" }, - { id: "out_of_print", name: "Out of print" }, - ]} /> - </SimpleForm> + <CardContent> + <SimpleForm> + <TextInput source="title" /> + <TextInput source="author" /> + <SelectInput source="availability" choices={[ + { id: "in_stock", name: "In stock" }, + { id: "out_of_stock", name: "Out of stock" }, + { id: "out_of_print", name: "Out of print" }, + ]} /> + </SimpleForm> + </CardContent> </Card> - </div> + </Container> </EditBase> ); ``` +## Props + You can customize the `<EditBase>` component using the following props, documented in the `<Edit>` component: * `children`: the components that renders the form diff --git a/docs/EditDialog.md b/docs/EditDialog.md index 4d58dee2c13..d2c8941429c 100644 --- a/docs/EditDialog.md +++ b/docs/EditDialog.md @@ -15,7 +15,17 @@ This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" s ## Usage -Add the `<EditDialog>` component as a sibling to a `<List>` component. +First, install the `@react-admin/ra-form-layout` package: + +```sh +npm install --save @react-admin/ra-form-layout +# or +yarn add @react-admin/ra-form-layout +``` + +**Tip**: [`ra-form-layout`](https://marmelab.com/ra-enterprise/modules/ra-form-layout#createdialog-editdialog--showdialog) is hosted in a private npm registry. You need to subscribe to one of the [Enterprise Edition](https://marmelab.com/ra-enterprise/) plans to access this package. + +Then, add the `<EditDialog>` component as a sibling to a `<List>` component. ```jsx import { @@ -54,23 +64,211 @@ In the related `<Resource>`, you don't need to declare an `edit` component as th <Resource name="customers" list={CustomerList} /> ``` -`<EditDialog>` accepts the same props as the [`<Edit>`](./Edit.md) component, and the same type of children (e.g. a [`<SimpleForm>`](./SimpleForm.md) element). - -* `children`: the components that renders the form -* `className`: passed to the root component -* [`component`](./Edit.md#component): override the root component -* [`disableAuthentication`](./Edit.md#disableauthentication): disable the authentication check -* [`id`](./Edit.md#id): the id of the record to edit -* [`mutationMode`](./Edit.md#mutationmode): switch to optimistic or pessimistic mutations (undoable by default) -* [`mutationOptions`](./Edit.md#mutationoptions): options for the `dataProvider.update()` call -* [`queryOptions`](./Edit.md#queryoptions): options for the `dataProvider.getOne()` call -* [`redirect`](./Edit.md#redirect): change the redirect location after successful creation -* [`resource`](./Edit.md#resource): override the name of the resource to create -* [`sx`](./Edit.md#sx-css-api): Override the styles -* [`title`](./Edit.md#title): override the page title -* [`transform`](./Edit.md#transform): transform the form data before calling `dataProvider.update()` - -Check [the `ra-form-layout` documentation](https://marmelab.com/ra-enterprise/modules/ra-form-layout#createdialog-editdialog--showdialog) for more details. +## Props + +`<EditDialog>` accepts the following props: + +| Prop | Required | Type | Default | Description | +| -------------- | -------- | ----------------- | ------- | ----------- | +| `children` | Required | `ReactNode` | | The content of the dialog. | +| `fullWidth` | Optional | `boolean` | `false` | If `true`, the dialog stretches to the full width of the screen. | +| `id` | Optional | `string | number` | | The record id. If not provided, it will be deduced from the record context. | +| `maxWidth` | Optional | `string` | `sm` | The max width of the dialog. | +| `mutation Options` | Optional | `object` | | The options to pass to the `useMutation` hook. | +| `queryOptions` | Optional | `object` | | The options to pass to the `useQuery` hook. +| `resource` | Optional | `string` | | The resource name, e.g. `posts` +| `sx` | Optional | `object` | | Override the styles applied to the dialog component. | +| `transform` | Optional | `function` | | Transform the form data before calling `dataProvider.update()`. | + +## `children` + +`<EditDialog>` doesn't render any field by default - it delegates this to its children, usually a Form component. + +React-admin provides several built-in form layout components: + +- [`SimpleForm`](./SimpleForm.md) for a single-column layout +- [`TabbedForm`](./TabbedForm.md) for a tabbed layout +- [`AccordionForm`](./AccordionForm.md) for long forms with collapsible sections +- [`LongForm`](./LongForm.md) for long forms with a navigation sidebar +- [`WizardForm`](./WizardForm.md) for multi-step forms +- and [`Form`](./Form.md), a headless component to use as a base for your custom layouts + +To use an alternative form layout, switch the `<EditDialog>` child component: + +```diff +const MyEditDialog = () => ( + <EditDialog fullWidth maxWidth="md"> +- <SimpleForm> ++ <TabbedForm> ++ <TabbedForm.Tab label="Identity"> + <TextInput source="first_name" fullWidth /> + <TextInput source="last_name" fullWidth /> ++ </TabbedForm.Tab> ++ <TabbedForm.Tab label="Informations"> + <DateInput source="dob" label="born" fullWidth /> + <SelectInput source="sex" choices={sexChoices} fullWidth /> ++ </TabbedForm.Tab> +- </SimpleForm> ++ </TabbedForm> + </EditDialog> +); +``` + +## `fullWidth` + +By default, `<EditDialog>` renders a [Material UI `<Dialog>`](https://mui.com/material-ui/react-dialog/#full-screen-dialogs) component that takes the width of its content. + +You can make the dialog full width by setting the `fullWidth` prop to `true`: + +```jsx +const MyEditDialog = () => ( + <EditDialog fullWidth> + ... + </EditDialog> +); +``` + +In addition, you can set a dialog maximum width by using the `maxWidth` enumerable in combination with the `fullWidth` boolean. When the `fullWidth` prop is true, the dialog will adapt based on the `maxWidth` value. + +```jsx +const MyEditDialog = () => ( + <EditDialog fullWidth maxWidth="sm"> + ... + </EditDialog> +); +``` + +## `id` + +The `id` prop allows you to pass the record id to the `<EditDialog>` component. If not provided, it will be deduced from the record context. + +This is useful to link to a related record. For instance, the following dialog lets you show the author of a book: + +```jsx +const EditAuthorDialog = () => { + const book = useRecordContext(); + return ( + <EditDialog resource="authors" id={book.author_id}> + ... + </EditDialog> + ); +}; +``` + +## `maxWidth` + +The `maxWidth` prop allows you to set the max width of the dialog. It can be one of the following values: `xs`, `sm`, `md`, `lg`, `xl`, `false`. The default is `sm`. + +For example, you can use that prop to make the dialog full width: + +```jsx +const MyEditDialog = () => ( + <EditDialog fullWidth maxWidth={false}> + ... + </EditDialog> +); +``` + +## `mutationOptions` + +The `mutationOptions` prop allows you to pass options to the `useMutation` hook. + +This can be useful e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) to the `dataProvider.update()` call. + +{% raw %} +```jsx +const MyEditDialog = () => ( + <EditDialog mutationOptions={{ meta: { fetch: 'author' } }}> + ... + </EditDialog> +); +``` +{% endraw %} + +## `queryOptions` + +The `queryOptions` prop allows you to pass options to the `useQuery` hook. + +This can be useful e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) to the `dataProvider.getOne()` call. + +{% raw %} +```jsx +const MyEditDialog = () => ( + <EditDialog queryOptions={{ meta: { fetch: 'author' } }}> + ... + </EditDialog> +); +``` +{% endraw %} + +## `resource` + +The `resource` prop allows you to pass the resource name to the `<EditDialog>` component. If not provided, it will be deduced from the resource context. + +This is useful to link to a related record. For instance, the following dialog lets you show the author of a book: + +```jsx +const EditAuthorDialog = () => { + const book = useRecordContext(); + return ( + <EditDialog resource="authors" id={book.author_id}> + ... + </EditDialog> + ); +}; +``` + +## `sx` + +Customize the styles applied to the Material UI `<Dialog>` component: + +{% raw %} +```jsx +const MyEditDialog = () => ( + <EditDialog sx={{ backgroundColor: 'paper' }}> + ... + </EditDialog> +); +``` +{% endraw %} + +## `transform` + +To transform a record after the user has submitted the form but before the record is passed to `dataProvider.update()`, use the `transform` prop. It expects a function taking a record as argument, and returning a modified record. For instance, to add a computed field upon edition: + +```jsx +export const UserEdit = () => { + const transform = data => ({ + ...data, + fullName: `${data.firstName} ${data.lastName}` + }); + return ( + <EditDialog transform={transform}> + ... + </EditDialog> + ); +} +``` + +The `transform` function can also return a `Promise`, which allows you to do all sorts of asynchronous calls (e.g. to the `dataProvider`) during the transformation. + +**Tip**: If you want to have different transformations based on the button clicked by the user (e.g. if the creation form displays two submit buttons, one to "save", and another to "save and notify other admins"), you can set the `transform` prop on [the `<SaveButton>` component](./SaveButton.md), too. + +**Tip**: The `transform` function also gets the `previousData` in its second argument: + +```jsx +export const UserEdit = () => { + const transform = (data, { previousData }) => ({ + ...data, + avoidChangeField: previousData.avoidChangeField + }); + return ( + <EditDialog transform={transform}> + ... + </EditDialog> + ); +} +``` ## Usage Without Routing diff --git a/docs/EditInDialogButton.md b/docs/EditInDialogButton.md index ee88f67ed6e..1f2c3505bf1 100644 --- a/docs/EditInDialogButton.md +++ b/docs/EditInDialogButton.md @@ -74,7 +74,6 @@ const CompanyShow = () => ( | -------------- | -------- | ----------------- | ------- | ----------- | | `children` | Required | `ReactNode` | | The content of the dialog. | | `ButtonProps` | Optional | `object` | | Object containing props to pass to Material UI's `<Button>`. | -| `empty WhileLoading` | Optional | `boolean` | | Set to `true` to return `null` while the list is loading. | | `fullWidth` | Optional | `boolean` | `false` | If `true`, the dialog stretches to the full width of the screen. | | `icon` | Optional | `ReactElement` | | Allows to override the default icon. | | `id` | Optional | `string | number` | | The record id. If not provided, it will be deduced from the record context. | @@ -97,7 +96,6 @@ React-admin provides several built-in form layout components: - [`AccordionForm`](./AccordionForm.md) for long forms with collapsible sections - [`LongForm`](./LongForm.md) for long forms with a navigation sidebar - [`WizardForm`](./WizardForm.md) for multi-step forms -- [`EditDialog`](./EditDialog.md) for sub-forms in a modal dialog - and [`Form`](./Form.md), a headless component to use as a base for your custom layouts To use an alternative form layout, switch the `<EditInDialogButton>` child component: @@ -137,20 +135,6 @@ const EditButton = () => ( ``` {% endraw %} -## `emptyWhileLoading` - -By default, `<EditInDialogButton>` renders its child component even before the `dataProvider.getOne()` call returns. If you use `<SimpleForm>` or `<TabbedForm>`, this isn't a problem as these components only render when the record has been fetched. But if you use a custom child component that expects the record context to be defined, your component will throw an error. - -To avoid this, set the `emptyWhileLoading` prop to `true`: - -```jsx -const EditButton = () => ( - <EditInDialogButton emptyWhileLoading> - ... - </EditInDialogButton> -); -``` - ## `fullWidth` By default, `<EditInDialogButton>` renders a [Material UI `<Dialog>`](https://mui.com/material-ui/react-dialog/#full-screen-dialogs) component that takes the width of its content. @@ -246,7 +230,7 @@ const EditButton = () => ( ## `mutationOptions` -The `queryOptions` prop allows you to pass options to the `useMutation` hook. +The `mutationOptions` prop allows you to pass options to the `useMutation` hook. This can be useful e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) to the `dataProvider.update()` call. diff --git a/docs/EditableDatagrid.md b/docs/EditableDatagrid.md index 7eb3f223821..c993ff6b1c4 100644 --- a/docs/EditableDatagrid.md +++ b/docs/EditableDatagrid.md @@ -5,7 +5,9 @@ title: "The EditableDatagrid Component" # `<EditableDatagrid>` -This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> component offers an "edit-in-place" experience in a `<Datagrid>`. +The default react-admin user experience consists of three pages: List, Edit, and Create. However, in some cases, users may prefer to do all CRUD tasks in one page. + +`<EditableDatagrid>` is an [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" src="./img/premium.svg" /> component that offers an "edit-in-place" experience, allowing users to edit, create, and delete records in place inside a `<Datagrid>`. <video controls autoplay playsinline muted loop> <source src="https://marmelab.com/ra-enterprise/modules/assets/ra-editable-datagrid-overview.webm" type="video/webm" /> @@ -13,6 +15,12 @@ This [Enterprise Edition](https://marmelab.com/ra-enterprise)<img class="icon" s Your browser does not support the video tag. </video> +With `<EditableDatagrid>`, when users click on a row in the datagrid, the row content is replaced by the edition form. They can also create new records by clicking on the Create button, which inserts an empty editable row as the first line of the list. Finally, they can delete a record by clicking on the Delete button on each row. + +You can test it live in [the Enterprise Edition Storybook](https://react-admin.github.io/ra-enterprise/?path=/story/ra-editable-datagrid-editabledatagrid--undoable) and [the e-commerce demo](https://marmelab.com/ra-enterprise-demo/#/tours/ra-editable-datagrid). + +`<EditableDatagrid>` allows you to use any [Input component](./Inputs.md) to edit the record - including Reference Inputs for foreign keys. + ## Usage First, install the `@react-admin/ra-editable-datagrid` package: @@ -23,45 +31,114 @@ npm install --save @react-admin/ra-editable-datagrid yarn add @react-admin/ra-editable-datagrid ``` -**Tip**: `ra-editable-datagrid` is hosted in a private npm registry. You need to subscribe to one of the [Enterprise Edition](https://marmelab.com/ra-enterprise/) plans to access this package. +**Tip**: `ra-editable-datagrid` is part of the [React-Admin Enterprise Edition](https://marmelab.com/ra-enterprise/), and hosted in a private npm registry. You need to subscribe to one of the Enterprise Edition plans to access this registry. -Then, use `<EditableDatagrid>` as a child of a react-admin `<List>`, `<ReferenceManyField>`, or any other component that creates a `ListContext`. +Then, replace `<Datagrid>` with `<EditableDatagrid>` in a react-admin `<List>`, `<ReferenceManyField>`, or any other component that creates a `ListContext`. In addition, pass a form component to be displayed when the user switches to edit or create mode. -```jsx -import { - List, - TextField, - TextInput, - DateField, - DateInput, - SelectField, - SelectInput, - required, -} from 'react-admin'; +```tsx +import { List, TextField, TextInput, DateField, DateInput, SelectField, SelectInput, required } from 'react-admin'; import { EditableDatagrid, RowForm } from '@react-admin/ra-editable-datagrid'; -const professionChoices = [ +export const ArtistList = () => ( + <List hasCreate empty={false}> + <EditableDatagrid + createForm={<ArtistForm />} + editForm={<ArtistForm />} + > + <TextField source="id" /> + <TextField source="firstName" /> + <TextField source="lastName" /> + <DateField source="dob" label="born" /> + <SelectField source="profession" choices={professionChoices} /> + </EditableDatagrid> + </List> +); + +const ArtistForm = () => ( + <RowForm> + <TextField source="id" /> + <TextInput source="firstName" validate={required()} /> + <TextInput source="lastName" validate={required()} /> + <DateInput source="dob" label="born" validate={required()} /> + <SelectInput source="profession" choices={professions} /> + </RowForm> +); + +const professions = [ { id: 'actor', name: 'Actor' }, { id: 'singer', name: 'Singer' }, { id: 'other', name: 'Other' }, ]; +``` -const ArtistList = () => ( - <List hasCreate empty={false}> +**Tip**: No need to include an `<EditButton>` in the datagrid children, as `<EditableDatagrid>` automatically adds a column with edit/delete buttons. + +## Props + +As `<EditableDatagrid>` is a drop-in replacement for `<Datagrid>`, it accepts all [the `<Datagrid>` props](./Datagrid.md#props), plus a few extra props: + +| Prop | Required | Type | Default | Description | +| -------------- | -------- | ------------ | ---------- | ------------------------------------------------------------------------- | +| `editForm` | Required | ReactElement | - | The component to display instead of a row when the users edit a record. | +| `actions` | Optional | ReactElement | - | The component used to customize the actions buttons. | +| `createForm` | Optional | ReactElement | - | The component to display as the first row when the user creates a record. | +| `mutationMode` | Optional | `string` | `undoable` | Mutation mode (`'undoable'`, `'pessimistic'` or `'optimistic'`). | +| `noDelete` | Optional | boolean | - | Disable the inline Delete button. | + +## `actions` + +By default, the `<EditableDatagrid>` will show both edit and delete buttons when users hover a row. If you want to either customize the button's behavior or provide more actions, you can leverage the `actions` prop. It accepts a React element. + +For instance, here's how to customize the delete button so that it asks users for a confirmation but still allows them to undo the deletion: + +```tsx +import React from 'react'; +import { List, TextField } from 'react-admin'; +import { + DeleteWithConfirmIconButton, + EditableDatagrid, + EditRowButton, +} from '@react-admin/ra-editable-datagrid'; +import { ArtistForm } from './ArtistForm'; + +export const ArtistList = () => ( + <List> <EditableDatagrid + actions={<RowAction />} + // The mutation mode is still applied to updates mutationMode="undoable" - createForm={<ArtistForm />} editForm={<ArtistForm />} > <TextField source="id" /> - <TextField source="firstname" /> + <TextField source="firstName" /> <TextField source="name" /> - <DateField source="dob" label="born" /> - <SelectField - source="prof" - label="Profession" - choices={professionChoices} - /> + </EditableDatagrid> + </List> +); + +const RowAction = () => ( + <> + <EditRowButton /> + <DeleteWithConfirmIconButton mutationMode="undoable" /> + </> +); +``` + +## `createForm` + +The component displayed as the first row when a user clicks on the Create button. It's usually a form built with [`<RowForm>`](#rowform), with the same number of children as the `<EditableDatagrid>` has children. + +```tsx +export const ArtistList = () => ( + <List hasCreate empty={false}> + <EditableDatagrid + editForm={<ArtistForm />} + createForm={<ArtistForm />} + > + <TextField source="id" /> + <TextField source="firstName" /> + <TextField source="lastName" /> + {/*...*/} </EditableDatagrid> </List> ); @@ -69,9 +146,365 @@ const ArtistList = () => ( const ArtistForm = () => ( <RowForm> <TextField source="id" /> - <TextInput source="firstname" validate={required()} /> + <TextInput source="firstName" /> + <TextInput source="lastName" /> + {/*...*/} + </RowForm> +); +``` + +**Tip**: It's a good idea to reuse the same form component for `createForm` and `editForm`, as in the example above. + +Since the creation form is embedded in the List view, you shouldn't set the `<Resource create>` prop. But react-admin's `<List>` only displays a Create button if the current `Resource` has a `create` page. That's why you must force the [`<List hasCreate>`](./List.md#hascreate) prop to `true`, as in the example above, to have the Create button show up with `<EditableDatagrid>`. + +Also, when the list is empty, the `<List>` component normally doesn't render its children (it renders an `empty` component instead). To bypass this system and see the empty editable datagrid with a create button instead, you need to force the `<List empty={false}>` prop, as in the example above. + +`<EditableDatagrid>` renders the `createForm` elements in a `<table>`, so the create form element should render a `<tr>`. We advise you to use the [`<RowForm>`](#rowform) component, which renders a `<tr>` by default. But you can also use your own component to render the creation form ([see `<RowForm>` below](#rowform)). + +**Tip**: The `createForm` component must render as many columns as there are children in the `<EditableDatagrid>`. That's why in the example above, the `<ArtistForm>` component renders a `<TextInput>` for each `<TextField>` (except for the read-only `id` field, for which it renders a `<TextField>`). + +## `editForm` + +The component displayed when a user clicks on a row to edit it. It's usually a form built with [`<RowForm>`](#rowform), with the same number of children as the `<EditableDatagrid>` has children. + +```tsx +export const ArtistList = () => ( + <List> + <EditableDatagrid editForm={<ArtistForm />}> + <TextField source="id" /> + <TextField source="firstName" /> + <TextField source="lastName" /> + {/*...*/} + </EditableDatagrid> + </List> +); + +const ArtistForm = () => ( + <RowForm> + <TextField source="id" /> + <TextInput source="firstName" /> + <TextInput source="lastName" /> + {/*...*/} + </RowForm> +); +``` + +**Tip**: No need to include a `<SaveButton>` in the form, as `<RowForm>` automatically adds a column with save/cancel buttons. + +**Tip**: If one column isn't editable, use a `<Field>` component instead of an `<Input>` component (like the `<TextField>` in the `<RowForm>` above). + +The `<EditableDatagrid>` component renders the `editForm` elements in a `<table>`, so these elements should render a `<tr>`. We advise you to use [the `<RowForm>` component](#rowform) for `editForm`, which renders a `<tr>` by default. But you can also use your own component to render the row form. + +## `mutationMode` + +Use the `mutationMode` prop to specify the [mutation mode](./Edit.html#mutationmode) for the edit and delete actions. By default, the `<EditableDatagrid>` uses the `undoable` mutation mode. You can change it to `optimistic` or `pessimistic` if you prefer. + +```jsx +<EditableDatagrid mutationMode="pessimistic"> + {/*...*/} +</EditableDatagrid> +``` + +## `noDelete` + +You can disable the delete button by setting the `noDelete` prop to `true`: + +```jsx +<EditableDatagrid noDelete> + {/*...*/} +</EditableDatagrid> +``` + +## `<RowForm>` + +`<RowForm>` renders a form in a table row, with one table cell per child. It is designed to be used as [`editForm`](#editform) and [`createForm`](#createform) element. + +```tsx +import { List, TextField, TextInput } from 'react-admin'; +import { EditableDatagrid, RowForm } from '@react-admin/ra-editable-datagrid'; + +export const ArtistList = () => ( + <List hasCreate empty={false}> + <EditableDatagrid + createForm={<ArtistForm />} + editForm={<ArtistForm />} + > + <TextField source="id" /> + <TextField source="firstName" /> + <TextField source="lastName" /> + </EditableDatagrid> + </List> +); + +const ArtistForm = () => ( + <RowForm> + <TextField source="id" /> + <TextInput source="firstName" /> + <TextInput source="lastName" /> + </RowForm> +); +``` + +`<RowForm>` and `<EditableDatagrid>` should have the same number of children, and these children should concern the same `source`. + +**Tip**: No need to include a `<SaveButton>` in the form, as `<RowForm>` automatically adds a column with save/cancel buttons. + +If you want to avoid the edition of a column, use a `<Field>` component instead of an `<Input>` component (like the `<TextField>` in the example above). + +`<RowForm>` accepts the following props: + +| Prop | Required | Type | Default | Description | +| ----------------- | -------- | ------------ | ------- | ------------------------------------------------------------------------- | +| `mutationOptions` | Optional | `Object` | - | An object that can contain `onSuccess` and `onError` functions to be executed after the row has been saved and after the row has failed to be saved respectively. | +| `submitOnEnter` | Optional | `boolean` | `true` | Whether the form can be submitted by pressing the Enter key. | +| `transform` | Optional | `function` | - | A function to transform the row before it is saved. | + +Any additional props passed to `<RowForm>` are passed down to the underlying react-admin [`<Form>`](./Form.md) component. That means that you can pass e.g. `defaultValues`, or `validate` props. + +{% raw %} +```tsx +import { RowForm } from '@react-admin/ra-editable-datagrid'; + +const ArtistForm = () => ( + <RowForm defaultValues={{ firstName: 'John', name: 'Doe' }}> + <TextField source="id" disabled /> <TextInput source="name" validate={required()} /> - <DateInput source="dob" label="born" validate={required()} /> + </RowForm> +); +``` +{% endraw %} + +## `useEditableDatagridContext` + +For advanced use cases, you can use the `useEditableDatagridContext` hook to manage the visibility of the creation form. It returns the following callbacks: + +- `openStandaloneCreateForm`: A function to open the create form. +- `closeStandaloneCreateForm`: A function to close the create form. + +For instance, the following example displays a custom message when the list is empty, and a button to open the create form: + +```tsx +import React from 'react'; +import { Typography, Box } from '@mui/material'; +import { CreateButton, List } from 'react-admin'; +import { + EditableDatagrid, + useEditableDatagridContext, +} from '@react-admin/ra-editable-datagrid'; + +const MyCreateButton = () => { + const { openStandaloneCreateForm } = useEditableDatagridContext(); + const handleClick = () => { + openStandaloneCreateForm(); + }; + return ( + <Box> + <Typography>No books yet</Typography> + <Typography>Do you want to add one?</Typography> + <CreateButton onClick={handleClick} label="Create the first book" /> + </Box> + ); +}; + +export const BookList = () => ( + <List hasCreate empty={false}> + <EditableDatagrid empty={<MyCreateButton />}> + {/*...*/} + </EditableDatagrid> + </List> +); +``` + +Feel free to visit the [dedicated stories](https://react-admin.github.io/ra-enterprise/?path=/story/ra-editable-datagrid-empty--custom-empty-standalone) to see more examples. + +## Using Inside a `<ReferenceManyField>` + +You can use `<EditableDatagrid>` inside a `<ReferenceManyField>`. The only difference with its usage in a `<List>` is that you have to initialize the foreign key in the creation form using the `defaultValues` prop: + +{% raw %} +```tsx +import { + DateField, + DateInput, + Edit, + NumberField, + NumberInput, + ReferenceManyField, + required, + SimpleForm, + TextField, + TextInput, +} from 'react-admin'; +import { useFormContext } from 'react-hook-form'; +import { EditableDatagrid, RowForm } from '@react-admin/ra-editable-datagrid'; + +const OrderEdit = () => ( + <Edit> + <SimpleForm> + <ReferenceManyField + fullWidth + label="Products" + reference="products" + target="order_id" + > + <EditableDatagrid + mutationMode="undoable" + createForm={<ProductForm />} + editForm={<ProductForm />} + rowClick="edit" + > + <TextField source="id" /> + <TextField source="name" /> + <NumberField source="price" /> + <DateField source="available_since" /> + </EditableDatagrid> + </ReferenceManyField> + <DateInput source="purchase_date" /> + </SimpleForm> + </Edit> +); + +const ProductForm = () => { + const { getValues } = useFormContext(); + return ( + <RowForm defaultValues={{ order_id: getValues('id') }}> + <TextInput source="id" disabled /> + <TextInput source="name" validate={required()} /> + <NumberInput source="price" validate={required()} /> + <DateInput source="available_since" validate={required()} /> + </RowForm> + ); +}; +``` +{% endraw %} + +In these examples, the same form component is used in `createForm` and `editForm`, but you can pass different forms (e.g. if some fields can be set at creation but not changed afterward). + +**Tip**: To edit a one-to-many relationship, you can also use [the `<ReferenceManyInput>` component](./ReferenceManyInput.md). + +## Providing Custom Side Effects + +You can provide your own side effects in response to successful or failed save and delete actions, by passing functions to the `onSuccess` or `onError` inside the `mutationOptions` prop: + +{% raw %} +```tsx +import { RowForm, useRowContext } from '@react-admin/ra-editable-datagrid'; + +const ArtistEditionForm = () => { + const notify = useNotify(); + const { close } = useRowContext(); + + const handleSuccess = response => { + notify( + `Artist ${response.name} ${response.firstName} has been updated` + ); + close(); + }; + + return ( + <RowForm mutationOptions={{ onSuccess: handleSuccess }}> + {/*...*/} + </RowForm> + ); +}; + +const ArtistCreationForm = () => { + const notify = useNotify(); + const { close } = useRowContext(); + + const handleSuccess = response => { + notify(`Artist ${response.name} ${response.firstName} has been added`); + close(); + }; + + return ( + <RowForm mutationOptions={{ onSuccess: handleSuccess }}> + {/*...*/} + </RowForm> + ); +}; +``` +{% endraw %} + +Note that we provide an additional side effects hook: `useRowContext` allows you to close the form. + +**Tip**: If you use `useNotify` inside an `onSuccess` side effect for an Edit form in addition to the `<EditableDatagrid mutationMode="undoable">` prop, you will need to set the notification as undoable for the changes to take effects. Also, note that, on undoable forms, the `onSuccess` side effect will be called immediately, without any `response` argument. + +```ts +const handleSuccess = () => { + notify('Artist has been updated', { type: 'info', undoable: true }); + close(); +}; +``` + +Besides, the `<RowForm>` also accepts a function for its `transform` prop allowing you to alter the data before sending it to the dataProvider: + +```tsx +import { TextInput, DateInput, SelectInput } from 'react-admin'; +import { RowForm } from '@react-admin/ra-editable-datagrid'; + +const ArtistCreateForm = () => { + const handleTransform = data => { + return { + ...data, + fullName: `${data.firstName} ${data.name}`, + }; + }; + + return ( + <RowForm transform={handleTransform}> + <TextInput source="firstName" validate={required()} /> + <TextInput source="name" validate={required()} /> + <DateInput source="dob" label="born" validate={required()} /> + <SelectInput + source="prof" + label="Profession" + choices={professionChoices} + /> + </RowForm> + ); +}; +``` + +## Adding A `meta` Prop To All Mutations + +Just like with `<Datagrid>`, if you'd like to add a `meta` prop to all the dataProvider calls, you will need to provide custom `mutationOptions` at all the places where mutations occur: + +- the `createForm` +- the `editForm` +- the `<DeleteRowButton>` + +Here is a complete example: + +{% raw %} +```tsx +import { + TextInput, + DateInput, + SelectInput, + TextField, + DateField, + SelectField, + required, + List, +} from 'react-admin'; +import { + EditableDatagrid, + RowForm, + RowFormProps, + EditRowButton, + DeleteRowButton, +} from '@react-admin/ra-editable-datagrid'; + +const ArtistForm = ({ meta }) => ( + <RowForm + defaultValues={{ firstName: 'John', name: 'Doe' }} + mutationOptions={{ meta }} + > + <TextField source="id" /> + <TextInput source="firstName" validate={required()} /> + <TextInput source="name" validate={required()} /> + <DateInput source="dob" label="Born" validate={required()} /> <SelectInput source="prof" label="Profession" @@ -79,9 +512,147 @@ const ArtistForm = () => ( /> </RowForm> ); + +const ArtistListWithMeta = () => { + const meta = { foo: 'bar' }; + return ( + <List hasCreate sort={{ field: 'id', order: 'DESC' }} empty={false}> + <EditableDatagrid + createForm={<ArtistForm meta={meta} />} + editForm={<ArtistForm meta={meta} />} + rowClick="edit" + actions={ + <> + <EditRowButton /> + <DeleteRowButton mutationOptions={{ meta }} /> + </> + } + > + <TextField source="id" /> + <TextField source="firstName" /> + <TextField source="name" /> + <DateField source="dob" label="Born" /> + <SelectField + source="prof" + label="Profession" + choices={professionChoices} + /> + </EditableDatagrid> + </List> + ); +}; ``` +{% endraw %} -`<EditableDatagrid>` is a drop-in replacement for `<Datagrid>`. It expects 2 additional props: `createForm` and `editForm`, the components to be displayed when a user creates or edits a row. The `<RowForm>` component allows to create such forms using react-admin Input components. +## Configurable +You can let end users customize what fields are displayed in the `<EditableDatagrid>` by using the `<EditableDatagridConfigurable>` component instead, together with the `<RowFormConfigurable>` component. + +<video controls autoplay playsinline muted loop> + <source src="https://marmelab.com/ra-enterprise/modules/assets/ra-editable-datagrid-configurable.mp4" type="video/mp4"/> + <source src="https://marmelab.com/ra-enterprise/modules/assets/ra-editable-datagrid-configurable.webm" type="video/webm"/> + Your browser does not support the video tag. +</video> + +```diff +import { List, TextField } from 'react-admin'; +import { +- EditableDatagrid, ++ EditableDatagridConfigurable, +- RowForm, ++ RowFormConfigurable, +} from '@react-admin/ra-editable-datagrid'; + +const ArtistForm = ({ meta }) => ( +- <RowForm> ++ <RowFormConfigurable> + <TextField source="id" /> + <TextInput source="firstName" validate={required()} /> + <TextInput source="name" validate={required()} /> + <DateInput source="dob" label="Born" validate={required()} /> + <SelectInput + source="prof" + label="Profession" + choices={professionChoices} + /> +- </RowForm> ++ </RowFormConfigurable> +); + +const ArtistList = () => ( + <List hasCreate empty={false}> +- <EditableDatagrid ++ <EditableDatagridConfigurable + mutationMode="undoable" + createForm={<ArtistForm />} + editForm={<ArtistForm />} + > + <TextField source="id" /> + <TextField source="firstName" /> + <TextField source="name" /> + <DateField source="dob" label="born" /> + <SelectField + source="prof" + label="Profession" + choices={professionChoices} + /> +- </EditableDatagrid> ++ </EditableDatagridConfigurable> + </List> +); +``` + +When users enter the configuration mode and select the `<EditableDatagrid>`, they can show/hide datagrid columns. They can also use the [`<SelectColumnsButton>`](./SelectColumnsButton.md) + +By default, `<EditableDatagridConfigurable>` renders all child fields. But you can also omit some of them by passing an `omit` prop containing an array of field sources: + +```tsx +// by default, hide the id and author columns +// users can choose to show them in configuration mode +const PostList = () => ( + <List> + <EditableDatagridConfigurable omit={['id', 'author']}> + <TextField source="id" /> + <TextField source="title" /> + <TextField source="author" /> + <TextField source="year" /> + </EditableDatagridConfigurable> + </List> +); +``` + +If you render more than one `<EditableDatagridConfigurable>` on the same page, you must pass a unique `preferenceKey` prop to each one: + +```tsx +const PostList = () => ( + <List> + <EditableDatagridConfigurable preferenceKey="posts.datagrid"> + <TextField source="id" /> + <TextField source="title" /> + <TextField source="author" /> + <TextField source="year" /> + </EditableDatagridConfigurable> + </List> +); +``` + +The inspector uses the field `source` (or `label` when it's a string) to display the column name. If you use non-field children (e.g. action buttons), then it's your responsibility to wrap them in a component with a `label` prop, that will be used by the inspector: + +```tsx +const FieldWrapper = ({ children, label }) => children; +const PostList = () => ( + <List> + <EditableDatagridConfigurable> + <TextField source="id" /> + <TextField source="title" /> + <TextField source="author" /> + <TextField source="year" /> + <FieldWrapper label="Actions"> + <EditButton /> + </FieldWrapper> + </EditableDatagridConfigurable> + </List> +); +``` -Check [the `ra-editable-datagrid` documentation](https://marmelab.com/ra-enterprise/modules/ra-editable-datagrid) for more details. +`<EditableDatagridConfigurable>` accepts the same props as `<EditableDatagrid>`. diff --git a/docs/Features.md b/docs/Features.md index ccbc2651aba..ebb0f9c49bb 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -271,6 +271,98 @@ We have made many improvements to this default layout based on user feedback. In And for mobile users, react-admin renders a different layout with larger margins and less information density (see [Responsive](#responsive)). +## Headless Core + +React-admin components use Material UI components by default, which lets you scaffold a page in no time. As material UI supports [theming](#theming), you can easily customize the look and feel of your app. But in some cases, this is not enough, and you need to use another UI library. + +You can change the UI library you use with react-admin to use [Ant Design](https://ant.design/), [Daisy UI](https://daisyui.com/), [Chakra UI](https://chakra-ui.com/), or even you own custom UI library. The **headless logic** behind react-admin components is agnostic of the UI library, and is exposed via `...Base` components and controller hooks. + +For instance, here a List view built with [Ant Design](https://ant.design/): + +![List view built with Ant Design](./img/list_ant_design.png) + +It leverages the `useListController` hook: + +{% raw %} +```jsx +import { useListController } from 'react-admin'; +import { Card, Table, Button } from 'antd'; +import { + CheckCircleOutlined, + PlusOutlined, + EditOutlined, +} from '@ant-design/icons'; +import { Link } from 'react-router-dom'; + +const PostList = () => { + const { data, page, total, setPage, isPending } = useListController({ + sort: { field: 'published_at', order: 'DESC' }, + perPage: 10, + }); + const handleTableChange = (pagination) => { + setPage(pagination.current); + }; + return ( + <> + <div style={{ margin: 10, textAlign: 'right' }}> + <Link to="/posts/create"> + <Button icon={<PlusOutlined />}>Create</Button> + </Link> + </div> + <Card bodyStyle={{ padding: '0' }} loading={isPending}> + <Table + size="small" + dataSource={data} + columns={columns} + pagination={{ current: page, pageSize: 10, total }} + onChange={handleTableChange} + /> + </Card> + </> + ); +}; + +const columns = [ + { title: 'Id', dataIndex: 'id', key: 'id' }, + { title: 'Title', dataIndex: 'title', key: 'title' }, + { + title: 'Publication date', + dataIndex: 'published_at', + key: 'pub_at', + render: (value) => new Date(value).toLocaleDateString(), + }, + { + title: 'Commentable', + dataIndex: 'commentable', + key: 'commentable', + render: (value) => (value ? <CheckCircleOutlined /> : null), + }, + { + title: 'Actions', + render: (_, record) => ( + <Link to={`/posts/${record.id}`}> + <Button icon={<EditOutlined />}>Edit</Button> + </Link> + ), + }, +]; + +export default PostList; +``` +{% endraw %} + +Check the following hooks to learn more about headless controllers: + +- [`useListController`](./useListController.md) +- [`useEditController`](./useEditController.md) +- [`useCreateController`](./useCreateController.md) +- [`useShowController`](./useShowController.md) + +And check these examples for admin panels built with react-admin but without Material UI: + +- [DaisyUI, Tailwind CSS, Tanstack Table and React-Aria](https://marmelab.com/blog/2023/11/28/using-react-admin-with-your-favorite-ui-library.html) +- [shadcn/ui, Tailwind CSS and Radix UI](https://github.com/marmelab/ra-shadcn-demo) + ## Guessers & Scaffolding When mapping a new API route to a CRUD view, adding fields one by one can be tedious. React-admin provides a set of guessers that can automatically **generate a complete CRUD UI based on an API response**. diff --git a/docs/Form.md b/docs/Form.md index 2626ed3d140..a81a50fb2b4 100644 --- a/docs/Form.md +++ b/docs/Form.md @@ -5,10 +5,12 @@ title: "Form" # `<Form>` -The `<Form>` component creates a `<form>` to edit a record, and renders its children. It is a headless component used internally by `<SimpleForm>`, `<TabbedForm>`, and other form components. +`<Form>` is a headless component that creates a `<form>` to edit a record, and renders its children. Use it to build a custom form layout, or to use another UI kit than Material UI. `<Form>` reads the `record` from the `RecordContext`, uses it to initialize the defaultValues of a react-hook-form via `useForm`, turns the `validate` function info a react-hook-form compatible form validator, notifies the user when the input validation fails, and creates a form context via `<FormProvider>`. +`<Form>` is used internally by `<SimpleForm>`, `<TabbedForm>`, and other form components. + ## Usage Use `<Form>` to build completely custom form layouts. Don't forget to include a submit button (or react-admin's [`<SaveButton>`](./SaveButton.md)) to actually save the record. diff --git a/docs/InfiniteList.md b/docs/InfiniteList.md index f76e24955d7..e6518aa5e18 100644 --- a/docs/InfiniteList.md +++ b/docs/InfiniteList.md @@ -161,4 +161,111 @@ export const BookList = () => ( </InfiniteList> ); ``` -{% endraw %} \ No newline at end of file +{% endraw %} + +## Controlled Mode + +`<InfiniteList>` deduces the resource and the list parameters from the URL. This is fine for a page showing a single list of records, but if you need to display more than one list in a page, you probably want to define the list parameters yourself. + +In that case, use the [`resource`](#resource), [`sort`](#sort), and [`filter`](#filter-permanent-filter) props to set the list parameters. + +{% raw %} +```jsx +import { InfiniteList, InfinitePagination, SimpleList } from 'react-admin'; +import { Container, Typography } from '@mui/material'; + +const Dashboard = () => ( + <Container> + <Typography>Latest posts</Typography> + <InfiniteList + resource="posts" + sort={{ field: 'published_at', order: 'DESC' }} + filter={{ is_published: true }} + disableSyncWithLocation + > + <SimpleList + primaryText={record => record.title} + secondaryText={record => `${record.views} views`} + /> + <InfinitePagination /> + </InfiniteList> + <Typography>Latest comments</Typography> + <InfiniteList + resource="comments" + sort={{ field: 'published_at', order: 'DESC' }} + perPage={10} + disableSyncWithLocation + > + <SimpleList + primaryText={record => record.author.name} + secondaryText={record => record.body} + tertiaryText={record => new Date(record.published_at).toLocaleDateString()} + /> + <InfinitePagination /> + </InfiniteList> + </Container> +) +``` +{% endraw %} + +## Headless Version + +Besides fetching a list of records from the data provider, `<InfiniteList>` renders the default list page layout (title, buttons, filters, a Material-UI `<Card>`, infinite pagination) and its children. If you need a custom list layout, you may prefer the `<InfiniteListBase>` component, which only renders its children in a [`ListContext`](./useListContext.md). + +```jsx +import { InfiniteListBase, InfinitePagination, WithListContext } from 'react-admin'; +import { Card, CardContent, Container, Stack, Typography } from '@mui/material'; + +const ProductList = () => ( + <InfiniteListBase> + <Container> + <Typography variant="h4">All products</Typography> + <WithListContext render={({ isPending, data }) => ( + !isPending && ( + <Stack spacing={1}> + {data.map(product => ( + <Card key={product.id}> + <CardContent> + <Typography>{product.name}</Typography> + </CardContent> + </Card> + ))} + </Stack> + ) + )} /> + <InfinitePagination /> + </Container> + </InfiniteListBase> +); +``` + +The previous example leverages [`<WithListContext>`](./WithListContext.md) to grab the data that `<ListBase>` stores in the `ListContext`. + +If you don't need the `ListContext`, you can use the `useInfiniteListController` hook, which does the same data fetching as `<InfiniteListBase>` but lets you render the content. + +```jsx +import { useInfiniteListController } from 'react-admin'; +import { Card, CardContent, Container, Stack, Typography } from '@mui/material'; + +const ProductList = () => { + const { isPending, data } = useInfiniteListController(); + return ( + <Container> + <Typography variant="h4">All products</Typography> + {!isPending && ( + <Stack spacing={1}> + {data.map(product => ( + <Card key={product.id}> + <CardContent> + <Typography>{product.name}</Typography> + </CardContent> + </Card> + ))} + </Stack> + )} + </Container> + ); +}; +``` + +`useInfiniteListController` returns callbacks to sort, filter, and paginate the list, so you can build a complete List page. \ No newline at end of file diff --git a/docs/List.md b/docs/List.md index a6a09f48414..2e09d9bdb24 100644 --- a/docs/List.md +++ b/docs/List.md @@ -1081,3 +1081,158 @@ const ProductList = () => ( </List> ) ``` + +## Controlled Mode + +`<List>` deduces the resource and the list parameters from the URL. This is fine for a page showing a single list of records, but if you need to display more than one list in a page, you probably want to define the list parameters yourself. + +In that case, use the [`resource`](#resource), [`sort`](#sort), [`filter`](#filter-permanent-filter), and [`perPage`](#perpage) props to set the list parameters. + +{% raw %} +```jsx +import { List, SimpleList } from 'react-admin'; +import { Container, Typography } from '@mui/material'; + +const Dashboard = () => ( + <Container> + <Typography>Latest posts</Typography> + <List + resource="posts" + sort={{ field: 'published_at', order: 'DESC' }} + filter={{ is_published: true }} + perPage={10} + > + <SimpleList + primaryText={record => record.title} + secondaryText={record => `${record.views} views`} + /> + </List> + <Typography>Latest comments</Typography> + <List + resource="comments" + sort={{ field: 'published_at', order: 'DESC' }} + perPage={10} + > + <SimpleList + primaryText={record => record.author.name} + secondaryText={record => record.body} + tertiaryText={record => new Date(record.published_at).toLocaleDateString()} + /> + </List> + </Container> +) +``` +{% endraw %} + +**Note**: If you need to set the list parameters to render a list of records *related to another record*, there are better components than `<List>` for that. Check out the following components, specialized in fetching and displaying a list of related records: + +- [`<ReferenceArrayField>`](./ReferenceArrayField.md), +- [`<ReferenceManyField>`](./ReferenceManyField.md), +- [`<ReferenceManyToManyField>`](./ReferenceManyToManyField.md). + +If the `<List>` children allow to *modify* the list state (i.e. if they let users change the sort order, the filters, the selection, or the pagination), then you should also use the [`disableSyncWithLocation`](#disablesyncwithlocation) prop to prevent react-admin from changing the URL. This is the case e.g. if you use a `<Datagrid>`, which lets users sort the list by clicking on column headers. + +{% raw %} +```jsx +import { List, Datagrid, TextField, NumberField, DateField } from 'react-admin'; +import { Container, Typography } from '@mui/material'; + +const Dashboard = () => ( + <Container> + <Typography>Latest posts</Typography> + <List + resource="posts" + sort={{ field: 'published_at', order: 'DESC' }} + filter={{ is_published: true }} + perPage={10} + disableSyncWithLocation + > + <Datagrid bulkActionButtons={false}> + <TextField source="title" /> + <NumberField source="views" /> + </Datagrid> + </List> + <Typography>Latest comments</Typography> + <List + resource="comments" + sort={{ field: 'published_at', order: 'DESC' }} + perPage={10} + disableSyncWithLocation + > + <Datagrid bulkActionButtons={false}> + <TextField source="author.name" /> + <TextField source="body" /> + <DateField source="published_at" /> + </Datagrid> + </List> + </Container> +) +``` +{% endraw %} + +**Note**: If you render more than one `<Datagrid>` for the same resource in the same page, they will share the selection state (i.e. the checked checkboxes). This is a design choice because if row selection is not tied to a resource, then when a user deletes a record it may remain selected without any ability to unselect it. You can get rid of the checkboxes by setting `<Datagrid bulkActionButtons={false}>`. + +## Headless Version + +Besides fetching a list of records from the data provider, `<List>` renders the default list page layout (title, buttons, filters, a Material-UI `<Card>`, pagination) and its children. If you need a custom list layout, you may prefer [the `<ListBase>` component](./ListBase.md), which only renders its children in a [`ListContext`](./useListContext.md). + +```jsx +import { ListBase, WithListContext } from 'react-admin'; +import { Card, CardContent, Container, Stack, Typography } from '@mui/material'; + +const ProductList = () => ( + <ListBase> + <Container> + <Typography variant="h4">All products</Typography> + <WithListContext render={({ isPending, data }) => ( + !isPending && ( + <Stack spacing={1}> + {data.map(product => ( + <Card key={product.id}> + <CardContent> + <Typography>{product.name}</Typography> + </CardContent> + </Card> + ))} + </Stack> + ) + )} /> + <WithListContext render={({ isPending, total }) => ( + !isPending && <Typography>{total} results</Typography> + )} /> + </Container> + </ListBase> +); +``` + +The previous example leverages [`<WithListContext>`](./WithListContext.md) to grab the data that `<ListBase>` stores in the `ListContext`. + +If you don't need the `ListContext`, you can use [the `useListController` hook](./useListController.md), which does the same data fetching as `<ListBase>` but lets you render the content. + +```jsx +import { useListController } from 'react-admin'; +import { Card, CardContent, Container, Stack, Typography } from '@mui/material'; + +const ProductList = () => { + const { isPending, data, total } = useListController(); + return ( + <Container> + <Typography variant="h4">All products</Typography> + {!isPending && ( + <Stack spacing={1}> + {data.map(product => ( + <Card key={product.id}> + <CardContent> + <Typography>{product.name}</Typography> + </CardContent> + </Card> + ))} + </Stack> + )} + {!isPending && <Typography>{total} results</Typography>} + </Container> + ); +}; +``` + +`useListController` returns callbacks to sort, filter, and paginate the list, so you can build a complete List page. Check [the `useListController`hook documentation](./useListController.md) for details. \ No newline at end of file diff --git a/docs/ListBase.md b/docs/ListBase.md index c8cfea8972e..4440b791a27 100644 --- a/docs/ListBase.md +++ b/docs/ListBase.md @@ -5,9 +5,11 @@ title: "The ListBase Component" # `<ListBase>` -`<ListBase>` is a headless variant of `<List>`, as it does not render anything. `<ListBase>` calls [`useListController`](./useListController.md), puts the result in a `ListContext`, then renders its child. +`<ListBase>` is a headless variant of [`<List>`](./List.md). It fetches a list of records from the data provider, puts it in a [`ListContext`](./useListContext.md), and renders its children. Use it to build a custom list layout. -If you want to display a record list in an entirely custom layout, i.e. use only the data fetching part of `<List>` and not the view layout, use `<ListBase>`. +Contrary to [`<List>`](./List.md), it does not render the page layout, so no title, no actions, no `<Card>`, and no pagination. + +`<ListBase>` relies on the [`useListController`](./useListController.md) hook. ## Usage diff --git a/docs/ListTutorial.md b/docs/ListTutorial.md index 3af9fd6ac6b..fba5aeb21b2 100644 --- a/docs/ListTutorial.md +++ b/docs/ListTutorial.md @@ -479,9 +479,10 @@ For these cases, react-admin provides a `<ListGuesser>` component that will gues ```tsx import { Admin, Resource, ListGuesser } from 'react-admin'; +import { dataProvider } from './dataProvider'; const App = () => ( - <Admin dataProvider={...}> + <Admin dataProvider={dataProvider}> <Resource name="posts" list={ListGuesser} /> </Admin> ); diff --git a/docs/NextJs.md b/docs/NextJs.md index 55b10172287..27164a992e1 100644 --- a/docs/NextJs.md +++ b/docs/NextJs.md @@ -31,7 +31,7 @@ This creates a project with the following folder structure: | Pages Router | App Router | |-------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------| -| ![ Next Admin folder structure with Pages Router ]( ./img/next-admin-with-page-router-folder-structure.png ) | ![ Next Admin folder structure with App Router ]( ./img/next-admin-with-app-router-folder-structure.png ) | +| ![Next Admin folder structure with Pages Router](./img/next-admin-with-page-router-folder-structure.png) | ![Next Admin folder structure with App Router](./img/next-admin-with-app-router-folder-structure.png) | ## Adding React-Admin Dependencies diff --git a/docs/PrevNextButtons.md b/docs/PrevNextButtons.md index 294180dbf95..9204948937c 100644 --- a/docs/PrevNextButtons.md +++ b/docs/PrevNextButtons.md @@ -369,8 +369,8 @@ Let's say users want to edit customer records and to navigate between records in - when they navigate to another record, the form is not saved. Thanks to React-admin components, you can solve these issues by using -- [`redirect` prop from `<Edit>`](Edit.md#redirect) with which you can specify the redirect to apply. Here we will choose to stay on the page rather than being redirected to the list view. -- [`warnWhenUnsavedChanges` from `Form`](Form.md#warnwhenunsavedchanges) that will trigger an alert if the user tries to change page while the record has not been saved. +- [`redirect` prop from `<Edit>`](./Edit.md#redirect) with which you can specify the redirect to apply. Here we will choose to stay on the page rather than being redirected to the list view. +- [`warnWhenUnsavedChanges` from `Form`](./Form.md#warnwhenunsavedchanges) that will trigger an alert if the user tries to change page while the record has not been saved. {% raw %} ```tsx diff --git a/docs/Readme.md b/docs/Readme.md index 3b383792cd1..537eb22921b 100644 --- a/docs/Readme.md +++ b/docs/Readme.md @@ -135,15 +135,21 @@ Read more about the [Architecture choices](./Architecture.md). The [React-Admin Enterprise Edition](https://marmelab.com/ra-enterprise) <img class="icon" src="./img/premium.svg" /> offers additional features and services for react-admin: - Save weeks of development thanks to the **Private Modules**, valid on an unlimited number of domains and projects. - - `ra-preferences`: Persist user preferences (language, theme, filters, datagrid columns, sidebar position, etc.) in local storage. - - `ra-navigation`: Multi-level menu and breadcrumb, with the ability to define a custom path for your resources. - - `ra-realtime`: Display live notifications, auto-update content on the screen, lock content when editing, with adapters for real-time backends. + - `ra-ai`: Components powered by Artificial Intelligence (AI) to boost user productivity. Suggest completion for user inputs, fix and improve large chunks of text in React-Admin forms. + - `ra-audit-log`: Track all changes made to your data, and display them in a dedicated view. + - `ra-calendar`: Display and manipulate events, drag and resize appointments, and browse a calendar in react-admin apps. + - `ra-datagrid-ag`: Integration with the [ag-Grid](https://www.ag-grid.com/) data grid, for better performance and advanced features (row grouping, aggregation, tree data, pivoting, column resizing, and much more). - `ra-editable-datagrid`: Edit data directly in the list view, for better productivity. Excel-like editing experience. - `ra-form-layout`: New form layouts for complex data entry tasks (accordion, wizard, etc.) + - `ra-json-schema-form`: Generate react-admin apps from a JSON Schema. + - `ra-markdown`: Read Markdown data, and edit it using a WYSIWYG editor in your admin + - `ra-navigation`: Alternative layouts and menus, breadcrumb, and hooks for applications with a deep navigation tree. + - `ra-rbac`: Role-based access control for fine-grained permissions. + - `ra-realtime`: Display live notifications, auto-update content on the screen, lock content when editing, with adapters for real-time backends. - `ra-relationships`: Visualize and edit complex relationships, including many-to-many relationships. - - `ra-tree`: Edit and visualize tree structures. Reorganize by drag and drop. Adapts to any data structure on the backend (parent_id, children, nested sets, etc.). + - `ra-search`: Plug your search engine and let users search across all resources via a smart Omnibox. - `ra-tour`: Guided tours for react-admin applications. Step-by-step instructions, Material UI skin. - - `ra-markdown`: Read Markdown data, and edit it using a WYSIWYG editor in your admin + - `ra-tree`: Edit and visualize tree structures. Reorganize by drag and drop. Adapts to any data structure on the backend (parent_id, children, nested sets, etc.). - Get **Support** from experienced react and react-admin developers, who will help you find the right information and troubleshoot your bugs. - Get a **50% Discount on Professional Services** in case you need coaching, audit, or custom development by our experts. - Get access to exclusive **Learning Material**, including a Storybook full of examples, and a dedicated demo app. diff --git a/docs/Search.md b/docs/Search.md index 9efd5bd352c..a3653655e9d 100644 --- a/docs/Search.md +++ b/docs/Search.md @@ -169,6 +169,8 @@ The `<Search>` component accepts the following props: | `options` | Optional | `Object` | - | An object containing options to apply to the search. | | `wait` | Optional | `number` | 500 | The delay of debounce for the search to launch after typing in ms. | +Additional props are passed down to the Material UI [`<TextField>`](https://mui.com/material-ui/react-text-field/) component. + ## `children` The `<Search>` children allow you to customize the way results are displayed. The child component can grab the search result using the `useSearchResult` hook. diff --git a/docs/Show.md b/docs/Show.md index e7cd3100f0d..3473a434705 100644 --- a/docs/Show.md +++ b/docs/Show.md @@ -604,31 +604,87 @@ export const PostShow = () => ( **Tips:** If you want the `<PrevNextButtons>` to link to the `<Show>` view, you have to set the `linkType` to `show`. See [the `<PrevNextButtons linkType>` prop](./PrevNextButtons.md#linktype). -## Headless Version +## Controlled Mode -The root component of `<Show>` is a Material UI `<Card>`. Besides, `<Show>` renders an action toolbar, and sets the page title. This may be useless if you have a completely custom layout. +`<show>` deduces the resource and the record id from the URL. This is fine for a detail page, but if you need to embed the details of a record in another page, you probably want to define these parameters yourself. -In that case, opt for [the `<ShowBase>` component](./ShowBase.md), a headless version of `<Show>`. +In that case, use the [`resource`](#resource) and [`id`](#id) props to set the show parameters regardless of the URL. ```jsx -// in src/posts.jsx -import { ShowBase } from 'react-admin'; +import { Show, SelectField, SimpleShowLayout, TextField } from "react-admin"; -export const PostShow = () => ( +export const BookShow = ({ id }) => ( + <Show resource="books" id={id}> + <SimpleShowLayout> + <TextField source="title" /> + <TextField source="author" /> + <SelectField source="availability" choices={[ + { id: "in_stock", name: "In stock" }, + { id: "out_of_stock", name: "Out of stock" }, + { id: "out_of_print", name: "Out of print" }, + ]} /> + </SimpleShowLayout> + </Show> +); +``` + +## Headless Version + +Besides fetching a record, `<Show>` renders the default detail page layout (title, actions, a Material UI `<Card>`) and its children. If you need a custom detail layout, you may prefer [the `<ShowBase>` component](./ShowBase.md), which only renders its children in a [`ShowContext`](./useShowContext.md). + +```jsx +import { ShowBase, SelectField, SimpleShowLayout, TextField, Title } from "react-admin"; +import { Card, CardContent, Container } from "@mui/material"; + +export const BookShow = () => ( <ShowBase> - <Grid container> - <Grid item xs={8}> - <SimpleShowLayout> - ... - </SimpleShowLayout> - </Grid> - <Grid item xs={4}> - Show instructions... - </Grid> - </Grid> - <div> - Post related links... - </div> + <Container> + <Title title="Book Detail" /> + <Card> + <CardContent> + <SimpleShowLayout> + <TextField source="title" /> + <TextField source="author" /> + <SelectField source="availability" choices={[ + { id: "in_stock", name: "In stock" }, + { id: "out_of_stock", name: "Out of stock" }, + { id: "out_of_print", name: "Out of print" }, + ]} /> + </SimpleShowLayout> + </CardContent> + </Card> + </Container> </ShowBase> ); ``` + +In the previous example, `<SimpleShowLayout>` grabs the record from the `ShowContext`. + +If you don't need the `ShowContext`, you can use [the `useShowController` hook](./useShowController.md), which does the same data fetching as `<ShowBase>` but lets you render the content. + +```tsx +import { useShowController, SelectField, SimpleShowLayout, TextField, Title } from "react-admin"; +import { Card, CardContent, Container } from "@mui/material"; + +export const BookShow = () => { + const { record } = useShowController(); + return ( + <Container> + <Title title={`Edit book ${record?.title}`} /> + <Card> + <CardContent> + <SimpleShowLayout record={record}> + <TextField source="title" /> + <TextField source="author" /> + <SelectField source="availability" choices={[ + { id: "in_stock", name: "In stock" }, + { id: "out_of_stock", name: "Out of stock" }, + { id: "out_of_print", name: "Out of print" }, + ]} /> + </SimpleShowLayout> + </CardContent> + </Card> + </Container> + ); +}; +``` diff --git a/docs/ShowBase.md b/docs/ShowBase.md index 4e129d87c3d..fc269411c55 100644 --- a/docs/ShowBase.md +++ b/docs/ShowBase.md @@ -5,15 +5,12 @@ title: "The ShowBase Component" # `<ShowBase>` -`<ShowBase>` is a headless component that lets you build custom Show pages. It handles the logic of the Show page: - -- it calls `useShowController` to fetch the record from the data provider via `dataProvider.getOne()`, -- it computes the default page title -- it creates a `ShowContext` and a `RecordContext`, -- it renders its child component +`<ShowBase>` is a headless variant of [`<Show>`](./Show.md). It fetches the record from the data provider via `dataProvider.getOne()`, puts it in a [`ShowContext`](./useShowContext.md), and renders its child. Use it to build a custom show page layout. Contrary to [`<Show>`](./Show.md), it does not render the page layout, so no title, no actions, and no `<Card>`. +`<ShowBase>` relies on the [`useShowController`](./useShowController.md) hook. + ## Usage Use `<ShowBase>` instead of `<Show>` when you want a completely custom page layout, without the default actions and title. diff --git a/docs/SimpleForm.md b/docs/SimpleForm.md index 603ce3953c6..d08bd279e2f 100644 --- a/docs/SimpleForm.md +++ b/docs/SimpleForm.md @@ -738,3 +738,35 @@ export default OrderEdit; ``` **Tip:** If you'd like to avoid creating an intermediate component like `<CityInput>`, or are using an `<ArrayInput>`, you can use the [`<FormDataConsumer>`](./Inputs.md#linking-two-inputs) component as an alternative. + +## Headless Version + +`<SimpleForm>` renders its children in a Material UI `<Stack>`, and renders a toolbar with a `<SaveButton>`. If you want to build a custom form layout, you can use [the `<Form>` component](./Form.md) instead. + +```jsx +import { Create, Form, TextInput, RichTextInput, SaveButton } from 'react-admin'; +import { Grid } from '@mui/material'; + +export const PostCreate = () => ( + <Create> + <Form> + <Grid container> + <Grid item xs={6}> + <TextInput source="title" fullWidth /> + </Grid> + <Grid item xs={6}> + <TextInput source="author" fullWidth /> + </Grid> + <Grid item xs={12}> + <RichTextInput source="body" fullWidth /> + </Grid> + <Grid item xs={12}> + <SaveButton /> + </Grid> + </Grid> + </Form> + </Create> +); +``` + +React-admin forms leverage react-hook-form's [`useForm` hook](https://react-hook-form.com/docs/useform). \ No newline at end of file diff --git a/docs/Tutorial.md b/docs/Tutorial.md index e0f9e14c75c..622b331a2a3 100644 --- a/docs/Tutorial.md +++ b/docs/Tutorial.md @@ -1047,7 +1047,7 @@ export const dataProvider: DataProvider = { return httpClient(url).then(({ headers, json }) => ({ data: json, - total: parseInt((headers.get('content-range') || "0").split('/').pop() || 0, 10), + total: parseInt((headers.get('content-range') || "0").split('/').pop() || '0', 10), })); }, @@ -1079,7 +1079,7 @@ export const dataProvider: DataProvider = { return httpClient(url).then(({ headers, json }) => ({ data: json, - total: parseInt((headers.get('content-range') || "0").split('/').pop() || 0, 10), + total: parseInt((headers.get('content-range') || "0").split('/').pop() || '0', 10), })); }, @@ -1104,7 +1104,7 @@ export const dataProvider: DataProvider = { method: 'POST', body: JSON.stringify(params.data), }).then(({ json }) => ({ - data: { ...params.data, id: json.id }, + data: { ...params.data, id: json.id } as any, })), delete: (resource, params) => diff --git a/docs/Validation.md b/docs/Validation.md index 6c03c0114a7..f51b2fc39b7 100644 --- a/docs/Validation.md +++ b/docs/Validation.md @@ -235,7 +235,7 @@ export default { } ``` -See the [Translation documentation](TranslationTranslating.md#translating-form-validation-errors) for details. +See the [Translation documentation](./TranslationTranslating.md#translating-form-validation-errors) for details. **Tip**: Make sure to define validation functions or array of functions in a variable outside your component, instead of defining them directly in JSX. This can result in a new function or array at every render, and trigger infinite rerender. diff --git a/docs/WrapperField.md b/docs/WrapperField.md index a6a66aa3376..f63d6e1e2fd 100644 --- a/docs/WrapperField.md +++ b/docs/WrapperField.md @@ -5,7 +5,7 @@ title: "The WrapperField Component" # `<WrapperField>` -This component simply renders its children. Why would you want to use such a dumb component? To combine several fields in a single cell (in a `<Datagrid>`) or in a single row (in a `<SimpleShowLayout>`). +This component simply renders its children. Why would you want to use such a dumb component? To combine several fields in a single cell (in a `<Datagrid>`), in a single row (in a `<SimpleShowLayout>`) or in a group of inputs (in a `<SimpleFormConfigurable>`) . `<WrapperField>` allows to define the `label` and sort field for a combination of fields: @@ -25,4 +25,24 @@ const BookList = () => ( ); ``` +```jsx +import { Edit, WrapperField, TextInput, SimpleFormConfigurable } from 'react-admin'; +import { Stack } from '@mui/material'; + +const PostEdit = () => ( + <Edit> + <SimpleFormConfigurable> + <TextInput source="title"/> + <WrapperField source="author"> + <Stack> + <TextInput source="author_first_name"/> + <TextInput source="author_last_name"/> + </Stack> + </WrapperField> + </SimpleFormConfigurable> + </Edit> +); +``` + + **Tip**: If you just want to combine two fields in a string, check [the `<FunctionField>` component](./FunctionField.md) instead. \ No newline at end of file diff --git a/docs/_includes/nav.html b/docs/_includes/nav.html index 76ac755e46a..78cc25412af 100644 --- a/docs/_includes/nav.html +++ b/docs/_includes/nav.html @@ -27,11 +27,12 @@ > </li> <li> - <a href="https://discord.gg/GeZF9sqh3N" title="Discord"> + <a + href="https://github.com/marmelab/react-admin" + title="GitHub" + > <svg - height="25px" - width="25px" - viewBox="0 -28.5 256 256" + viewBox="0 0 256 250" preserveAspectRatio="xMidYMid" style=" height: 20px; @@ -40,18 +41,17 @@ " > <path - d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" + d="M128.001 0C57.317 0 0 57.307 0 128.001c0 56.554 36.676 104.535 87.535 121.46 6.397 1.185 8.746-2.777 8.746-6.158 0-3.052-.12-13.135-.174-23.83-35.61 7.742-43.124-15.103-43.124-15.103-5.823-14.795-14.213-18.73-14.213-18.73-11.613-7.944.876-7.78.876-7.78 12.853.902 19.621 13.19 19.621 13.19 11.417 19.568 29.945 13.911 37.249 10.64 1.149-8.272 4.466-13.92 8.127-17.116-28.431-3.236-58.318-14.212-58.318-63.258 0-13.975 5-25.394 13.188-34.358-1.329-3.224-5.71-16.242 1.24-33.874 0 0 10.749-3.44 35.21 13.121 10.21-2.836 21.16-4.258 32.038-4.307 10.878.049 21.837 1.47 32.066 4.307 24.431-16.56 35.165-13.12 35.165-13.12 6.967 17.63 2.584 30.65 1.255 33.873 8.207 8.964 13.173 20.383 13.173 34.358 0 49.163-29.944 59.988-58.447 63.157 4.591 3.972 8.682 11.762 8.682 23.704 0 17.126-.148 30.91-.148 35.126 0 3.407 2.304 7.398 8.792 6.14C219.37 232.5 256 184.537 256 128.002 256 57.307 198.691 0 128.001 0zm-80.06 182.34c-.282.636-1.283.827-2.194.39-.929-.417-1.45-1.284-1.15-1.922.276-.655 1.279-.838 2.205-.399.93.418 1.46 1.293 1.139 1.931zm6.296 5.618c-.61.566-1.804.303-2.614-.591-.837-.892-.994-2.086-.375-2.66.63-.566 1.787-.301 2.626.591.838.903 1 2.088.363 2.66zm4.32 7.188c-.785.545-2.067.034-2.86-1.104-.784-1.138-.784-2.503.017-3.05.795-.547 2.058-.055 2.861 1.075.782 1.157.782 2.522-.019 3.08zm7.304 8.325c-.701.774-2.196.566-3.29-.49-1.119-1.032-1.43-2.496-.726-3.27.71-.776 2.213-.558 3.315.49 1.11 1.03 1.45 2.505.701 3.27zm9.442 2.81c-.31 1.003-1.75 1.459-3.199 1.033-1.448-.439-2.395-1.613-2.103-2.626.301-1.01 1.747-1.484 3.207-1.028 1.446.436 2.396 1.602 2.095 2.622zm10.744 1.193c.036 1.055-1.193 1.93-2.715 1.95-1.53.034-2.769-.82-2.786-1.86 0-1.065 1.202-1.932 2.733-1.958 1.522-.03 2.768.818 2.768 1.868zm10.555-.405c.182 1.03-.875 2.088-2.387 2.37-1.485.271-2.861-.365-3.05-1.386-.184-1.056.893-2.114 2.376-2.387 1.514-.263 2.868.356 3.061 1.403z" ></path> </svg> </a> </li> <li> - <a - href="https://github.com/marmelab/react-admin" - title="GitHub" - > + <a href="https://discord.gg/GeZF9sqh3N" title="Discord"> <svg - viewBox="0 0 256 250" + height="25px" + width="25px" + viewBox="0 -28.5 256 256" preserveAspectRatio="xMidYMid" style=" height: 20px; @@ -61,7 +61,7 @@ " > <path - d="M128.001 0C57.317 0 0 57.307 0 128.001c0 56.554 36.676 104.535 87.535 121.46 6.397 1.185 8.746-2.777 8.746-6.158 0-3.052-.12-13.135-.174-23.83-35.61 7.742-43.124-15.103-43.124-15.103-5.823-14.795-14.213-18.73-14.213-18.73-11.613-7.944.876-7.78.876-7.78 12.853.902 19.621 13.19 19.621 13.19 11.417 19.568 29.945 13.911 37.249 10.64 1.149-8.272 4.466-13.92 8.127-17.116-28.431-3.236-58.318-14.212-58.318-63.258 0-13.975 5-25.394 13.188-34.358-1.329-3.224-5.71-16.242 1.24-33.874 0 0 10.749-3.44 35.21 13.121 10.21-2.836 21.16-4.258 32.038-4.307 10.878.049 21.837 1.47 32.066 4.307 24.431-16.56 35.165-13.12 35.165-13.12 6.967 17.63 2.584 30.65 1.255 33.873 8.207 8.964 13.173 20.383 13.173 34.358 0 49.163-29.944 59.988-58.447 63.157 4.591 3.972 8.682 11.762 8.682 23.704 0 17.126-.148 30.91-.148 35.126 0 3.407 2.304 7.398 8.792 6.14C219.37 232.5 256 184.537 256 128.002 256 57.307 198.691 0 128.001 0zm-80.06 182.34c-.282.636-1.283.827-2.194.39-.929-.417-1.45-1.284-1.15-1.922.276-.655 1.279-.838 2.205-.399.93.418 1.46 1.293 1.139 1.931zm6.296 5.618c-.61.566-1.804.303-2.614-.591-.837-.892-.994-2.086-.375-2.66.63-.566 1.787-.301 2.626.591.838.903 1 2.088.363 2.66zm4.32 7.188c-.785.545-2.067.034-2.86-1.104-.784-1.138-.784-2.503.017-3.05.795-.547 2.058-.055 2.861 1.075.782 1.157.782 2.522-.019 3.08zm7.304 8.325c-.701.774-2.196.566-3.29-.49-1.119-1.032-1.43-2.496-.726-3.27.71-.776 2.213-.558 3.315.49 1.11 1.03 1.45 2.505.701 3.27zm9.442 2.81c-.31 1.003-1.75 1.459-3.199 1.033-1.448-.439-2.395-1.613-2.103-2.626.301-1.01 1.747-1.484 3.207-1.028 1.446.436 2.396 1.602 2.095 2.622zm10.744 1.193c.036 1.055-1.193 1.93-2.715 1.95-1.53.034-2.769-.82-2.786-1.86 0-1.065 1.202-1.932 2.733-1.958 1.522-.03 2.768.818 2.768 1.868zm10.555-.405c.182 1.03-.875 2.088-2.387 2.37-1.485.271-2.861-.365-3.05-1.386-.184-1.056.893-2.114 2.376-2.387 1.514-.263 2.868.356 3.061 1.403z" + d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" ></path> </svg> </a> diff --git a/docs/img/list_ant_design.png b/docs/img/list_ant_design.png new file mode 100644 index 00000000000..68123387224 Binary files /dev/null and b/docs/img/list_ant_design.png differ diff --git a/docs/img/writers-delight.png b/docs/img/writers-delight.png new file mode 100644 index 00000000000..0f2aafb066c Binary files /dev/null and b/docs/img/writers-delight.png differ diff --git a/docs/useCreateController.md b/docs/useCreateController.md index eaca03499ce..5ac114b80b7 100644 --- a/docs/useCreateController.md +++ b/docs/useCreateController.md @@ -5,44 +5,47 @@ title: "The useCreateController hook" # `useCreateController` -The `useCreateController` hook contains the logic of [the `<Create>` component](./Create.md): it prepares a form submit handler, and returns the data and callbacks necessary to render a Creation view. +`useCreateController` contains the headless logic of the [`<Create>`](./Create.md) component. It's useful to create a custom creation view. It's also the base hook when building a custom view with another UI kit than Material UI. -React-admin calls `useCreateController` internally, when you use the `<Create>`, or `<CreateBase>` component. +`useCreateController` reads the resource name from the resource context and browser location, computes the form default values, prepares a form submit handler based on `dataProvider.create()`, computes the default page title, and returns them. Its return value matches the [`CreateContext`](./useCreateContext.md) shape. + +`useCreateController` is used internally by [`<Create>`](./Create.md) and [`<CreateBase>`](./CreateBase.md). If your Create view uses react-admin components like [`<SimpleForm>`](./SimpleForm.md), prefer [`<CreateBase>`](./CreateBase.md) to `useCreateController` as it takes care of creating a `<CreateContext>`. ## Usage Use `useCreateController` to create a custom creation view, with exactly the content you need. -{% raw %} -```jsx -import * as React from "react"; -import { useCreateController, CreateContextProvider, SimpleForm, TextInput, SelectInput } from "react-admin"; -import { Card } from "@mui/material"; +```tsx +import { useCreateController, SelectInput, SimpleForm, TextInput, Title } from "react-admin"; +import { Card, CardContent, Container } from "@mui/material"; export const BookCreate = () => { - const { save } = useCreateController({ resource: 'books' }); - return ( - <div> - <Title title="Book Creation" /> - <Card> - <SimpleForm onSubmit={save} record={{}} > - <TextInput source="title" /> - <TextInput source="author" /> - <SelectInput source="availability" choices={[ - { id: "in_stock", name: "In stock" }, - { id: "out_of_stock", name: "Out of stock" }, - { id: "out_of_print", name: "Out of print" }, - ]} /> - </SimpleForm> - </Card> - </div> - ); + const { save } = useCreateController(); + return ( + <Container> + <Title title="Create book" /> + <Card> + <CardContent> + <SimpleForm onSubmit={save}> + <TextInput source="title" /> + <TextInput source="author" /> + <SelectInput source="availability" choices={[ + { id: "in_stock", name: "In stock" }, + { id: "out_of_stock", name: "Out of stock" }, + { id: "out_of_print", name: "Out of print" }, + ]} /> + </SimpleForm> + </CardContent> + </Card> + </Container> + ); }; ``` -{% endraw %} **Tip**: If you just use the return value of `useCreateController` to put it in an `CreateContext`, use [the `<CreateBase>` component](./CreateBase.md) instead for simpler markup. +## Input Format + `useCreateController` accepts an options argument, with the following fields, all optional: * [`disableAuthentication`](./Create.md#disableauthentication): disable the authentication check @@ -52,11 +55,16 @@ export const BookCreate = () => { * [`resource`](./Create.md#resource): override the name of the resource to create * [`transform`](./Create.md#transform): transform the form data before calling `dataProvider.create()` +These fields are documented in [the `<Create>` component](./Create.md) documentation. + +## Return Value + `useCreateController` returns an object with the following fields: ```jsx const { defaultTitle, // the translated title based on the resource, e.g. 'Create New Post' + record, // the default values of the creation form redirect, // the default redirection route. Defaults to 'list' resource, // the resource name, deduced from the location. e.g. 'posts' save, // the update callback, to be passed to the underlying form as submit handler diff --git a/docs/useEditController.md b/docs/useEditController.md index 5d1acd30aa4..2ce7d2a0f99 100644 --- a/docs/useEditController.md +++ b/docs/useEditController.md @@ -5,17 +5,17 @@ title: "The useEditController hook" # `useEditController` -The `useEditController` hook contains the logic of [the `<Edit>` component](./Edit.md): it fetches a record based on the URL, prepares a form submit handler, and returns all the data and callbacks necessary to render an edition view. +`useEditController` contains the headless logic of the [`<Edit>`](./Edit.md) component. It's useful to create a custom edition view. It's also the base hook when building a custom view with another UI kit than Material UI. -React-admin calls `useEditController` internally when you use the `<Edit>`, `<EditBase>`, or `<EditGuesser>` component. +`useEditController` reads the resource name and id from the resource context and browser location, fetches the record via `dataProvider.getOne()` to initialize the form, prepares a form submit handler based on `dataProvider.update()`, computes the default page title, and returns them. Its return value matches the [`EditContext`](./useEditContext.md) shape. + +`useEditController` is used internally by [`<Edit>`](./Edit.md) and [`<EditBase>`](./EditBase.md). If your Edit view uses react-admin components like [`<SimpleForm>`](./SimpleForm.md), prefer [`<EditBase>`](./EditBase.md) to `useEditController` as it takes care of creating a `<EditContext>`. ## Usage Use `useEditController` to create a custom Edition view, with exactly the content you need. -{% raw %} ```jsx -import * as React from "react"; import { useParams } from "react-router-dom"; import { useEditController, EditContextProvider, SimpleForm, TextInput, SelectInput } from "react-admin"; import { Card } from "@mui/material"; @@ -42,10 +42,11 @@ export const BookEdit = () => { ); }; ``` -{% endraw %} **Tip**: If you just use the return value of `useEditController` to put it in an `EditContext`, use [the `<EditBase>` component](./EditBase.md) instead for simpler markup. +## Input Format + `useEditController` accepts an options argument, with the following fields, all optional: * [`disableAuthentication`](./Edit.md#disableauthentication): disable the authentication check @@ -57,6 +58,10 @@ export const BookEdit = () => { * [`resource`](./Edit.md#resource): override the name of the resource to create * [`transform`](./Edit.md#transform): transform the form data before calling `dataProvider.update()` +These fields are documented in [the `<Edit>` component](./Edit.md) documentation. + +## Return Value + `useEditController` returns an object with the following fields: ```jsx diff --git a/docs/useInput.md b/docs/useInput.md index de524ef7a8a..0951a36975f 100644 --- a/docs/useInput.md +++ b/docs/useInput.md @@ -59,10 +59,10 @@ Additional props are passed to [react-hook-form's `useController` hook](https:// ```jsx // in LatLongInput.js import TextField from '@mui/material/TextField'; -import { useInput, required } from 'react-admin'; +import { useInput, required, InputHelperText } from 'react-admin'; const BoundedTextField = (props) => { - const { onChange, onBlur, label, ...rest } = props; + const { onChange, onBlur, label, helperText, ...rest } = props; const { field, fieldState: { isTouched, invalid, error }, @@ -81,12 +81,22 @@ const BoundedTextField = (props) => { {...field} label={label} error={(isTouched || isSubmitted) && invalid} - helperText={(isTouched || isSubmitted) && invalid ? error : ''} + helperText={helperText !== false || ((isTouched || isSubmitted) && invalid) + ? ( + <InputHelperText + touched={isTouched || isSubmitted} + error={error?.message} + helperText={helperText} + /> + ) + : '' + } required={isRequired} {...rest} /> ); }; + const LatLngInput = props => { const { source, ...rest } = props; @@ -99,6 +109,7 @@ const LatLngInput = props => { ); }; ``` + ## Usage with Material UI `<Select>` ```jsx diff --git a/docs/useListController.md b/docs/useListController.md index afb9723a648..6e902e4ccc7 100644 --- a/docs/useListController.md +++ b/docs/useListController.md @@ -5,13 +5,89 @@ title: "useListController" # `useListController` -The `useListController` hook fetches the data, prepares callbacks for modifying the pagination, filters, sort and selection, and returns them. Its return value match the `ListContext` shape. `useListController` is used internally by the `<List>` and `<ListBase>` components. +`useListController` contains the headless logic of the [`<List>`](./List.md) component. It's useful to create a custom List view. It's also the base hook when building a custom view with another UI kit than Material UI. -You can use it to create a custom List view, although its component counterpart, [`<ListBase>`](./ListBase.md), is probably better in most cases. +![List view built with Ant Design](./img/list_ant_design.png) + +`useListController` reads the list parameters from the URL, calls `dataProvider.getList()`, prepares callbacks for modifying the pagination, filters, sort and selection, and returns them together with the data. Its return value matches the [`ListContext`](./useListContext.md) shape. + +`useListController` is used internally by [`<List>`](./List.md) and [`<ListBase>`](./ListBase.md). If your list view uses react-admin components like [`<Datagrid>`](./Datagrid.md), prefer [`<ListBase>`](./ListBase.md) to `useListController` as it takes care of creating a `<ListContext>`. ## Usage -It's common to call `useListController()` without parameters, and to put the result in a `ListContext` to make it available to the rest of the component tree. +`useListController` expects a parameters object defining the list sorting, pagination, and filters. It returns an object with the fetched data, and callbacks to modify the list parameters. + +Here the code for the post list view above, built with [Ant Design](https://ant.design/): + +{% raw %} +```jsx +import { useListController } from 'react-admin'; +import { Card, Table, Button } from 'antd'; +import { + CheckCircleOutlined, + PlusOutlined, + EditOutlined, +} from '@ant-design/icons'; +import { Link } from 'react-router-dom'; + +const PostList = () => { + const { data, page, total, setPage, isPending } = useListController({ + sort: { field: 'published_at', order: 'DESC' }, + perPage: 10, + }); + const handleTableChange = (pagination) => { + setPage(pagination.current); + }; + return ( + <> + <div style={{ margin: 10, textAlign: 'right' }}> + <Link to="/posts/create"> + <Button icon={<PlusOutlined />}>Create</Button> + </Link> + </div> + <Card bodyStyle={{ padding: '0' }} loading={isPending}> + <Table + size="small" + dataSource={data} + columns={columns} + pagination={{ current: page, pageSize: 10, total }} + onChange={handleTableChange} + /> + </Card> + </> + ); +}; + +const columns = [ + { title: 'Id', dataIndex: 'id', key: 'id' }, + { title: 'Title', dataIndex: 'title', key: 'title' }, + { + title: 'Publication date', + dataIndex: 'published_at', + key: 'pub_at', + render: (value) => new Date(value).toLocaleDateString(), + }, + { + title: 'Commentable', + dataIndex: 'commentable', + key: 'commentable', + render: (value) => (value ? <CheckCircleOutlined /> : null), + }, + { + title: 'Actions', + render: (_, record) => ( + <Link to={`/posts/${record.id}`}> + <Button icon={<EditOutlined />}>Edit</Button> + </Link> + ), + }, +]; + +export default PostList; +``` +{% endraw %} + +When using react-admin components, it's common to call `useListController()` without parameters, and to put the result in a `ListContext` to make it available to the rest of the component tree. ```jsx import { diff --git a/docs/useShowController.md b/docs/useShowController.md index 34a27402a29..e06c40931db 100644 --- a/docs/useShowController.md +++ b/docs/useShowController.md @@ -5,13 +5,11 @@ title: "useShowController" # `useShowController` -`useShowController` is the hook that handles all the controller logic for Show views. It's used by [`<Show>`](./Show.md) and [`<ShowBase>`](./ShowBase.md). +`useShowController` contains the headless logic of the [`<Show>`](./Show.md) component. It's useful to create a custom Show view. It's also the base hook when building a custom view with another UI kit than Material UI. -This hook takes care of three things: +`useShowController` reads the resource name and id from the resource context and browser location, fetches the record from the data provider via `dataProvider.getOne()`, computes the default page title, and returns them. Its return value matches the [`ShowContext`](./useShowContext.md) shape. -- it reads the resource name and id from the resource context and browser location -- it fetches the record from the data provider via `dataProvider.getOne()`, -- it computes the default page title +`useShowController` is used internally by [`<Show>`](./Show.md) and [`<ShowBase>`](./ShowBase.md). If your Show view uses react-admin components like `<TextField>`, prefer [`<ShowBase>`](./ShowBase.md) to `useShowController` as it takes care of creating a `<ShowContext>`. ## Usage diff --git a/examples/data-generator/package.json b/examples/data-generator/package.json index e3c847bb919..7f63a89bc72 100644 --- a/examples/data-generator/package.json +++ b/examples/data-generator/package.json @@ -1,6 +1,6 @@ { "name": "data-generator-retail", - "version": "4.15.5", + "version": "4.16.2", "homepage": "https://github.com/marmelab/react-admin/tree/master/examples/data-generator", "bugs": "https://github.com/marmelab/react-admin/issues", "license": "MIT", @@ -19,7 +19,7 @@ }, "devDependencies": { "cross-env": "^5.2.0", - "ra-core": "^4.15.5", + "ra-core": "^4.16.2", "rimraf": "^3.0.2", "typescript": "^5.1.3" }, diff --git a/examples/simple/package.json b/examples/simple/package.json index bfe84121f86..5417603b2f7 100644 --- a/examples/simple/package.json +++ b/examples/simple/package.json @@ -1,6 +1,6 @@ { "name": "simple", - "version": "4.15.5", + "version": "4.16.2", "private": true, "scripts": { "dev": "vite", @@ -19,13 +19,13 @@ "lodash": "~4.17.5", "prop-types": "^15.8.1", "proxy-polyfill": "^0.3.0", - "ra-data-fakerest": "^4.15.5", - "ra-i18n-polyglot": "^4.15.5", - "ra-input-rich-text": "^4.15.5", - "ra-language-english": "^4.15.5", - "ra-language-french": "^4.15.5", + "ra-data-fakerest": "^4.16.2", + "ra-i18n-polyglot": "^4.16.2", + "ra-input-rich-text": "^4.16.2", + "ra-language-english": "^4.16.2", + "ra-language-french": "^4.16.2", "react": "^18.0.0", - "react-admin": "^4.15.5", + "react-admin": "^4.16.2", "react-dom": "^18.2.0", "react-hook-form": "^7.43.9", "react-router": "^6.1.0", diff --git a/lerna.json b/lerna.json index 0a2192f0343..d956d982eef 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "lerna": "2.5.1", "packages": ["examples/data-generator", "examples/simple", "packages/*"], - "version": "4.15.5" + "version": "4.16.2" } diff --git a/packages/create-react-admin/package.json b/packages/create-react-admin/package.json index 4f1d1929499..1128ded76d9 100644 --- a/packages/create-react-admin/package.json +++ b/packages/create-react-admin/package.json @@ -1,7 +1,7 @@ { "name": "create-react-admin", "description": "A CLI to quickly start a new react-admin project", - "version": "4.15.5", + "version": "4.16.1", "license": "MIT", "bin": "lib/cli.js", "authors": [ diff --git a/packages/create-react-admin/src/generateProject.ts b/packages/create-react-admin/src/generateProject.ts index 79b2497aae6..99ab227c6fc 100644 --- a/packages/create-react-admin/src/generateProject.ts +++ b/packages/create-react-admin/src/generateProject.ts @@ -194,7 +194,7 @@ const BasePackageJson = { }, dependencies: { react: '^18.2.0', - 'react-admin': '^4.14.0', + 'react-admin': '^4.16.0', 'react-dom': '^18.2.0', }, devDependencies: { diff --git a/packages/create-react-admin/templates/ra-data-fakerest/package.json b/packages/create-react-admin/templates/ra-data-fakerest/package.json index 3485c59f584..505ce583884 100644 --- a/packages/create-react-admin/templates/ra-data-fakerest/package.json +++ b/packages/create-react-admin/templates/ra-data-fakerest/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "ra-data-fakerest": "^4.14.0" + "ra-data-fakerest": "^4.16.0" } } diff --git a/packages/create-react-admin/templates/ra-data-json-server/package.json b/packages/create-react-admin/templates/ra-data-json-server/package.json index bc483a2c452..78ed680e9bd 100644 --- a/packages/create-react-admin/templates/ra-data-json-server/package.json +++ b/packages/create-react-admin/templates/ra-data-json-server/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "ra-data-json-server": "^4.14.0" + "ra-data-json-server": "^4.16.0" } } diff --git a/packages/create-react-admin/templates/ra-data-simple-rest/package.json b/packages/create-react-admin/templates/ra-data-simple-rest/package.json index 6568cebd94f..fa5ca66364c 100644 --- a/packages/create-react-admin/templates/ra-data-simple-rest/package.json +++ b/packages/create-react-admin/templates/ra-data-simple-rest/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "ra-data-simple-rest": "^4.14.0" + "ra-data-simple-rest": "^4.16.0" } } diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 10651b383fe..640d267e0e9 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -1,6 +1,6 @@ { "name": "ra-core", - "version": "4.15.5", + "version": "4.16.2", "description": "Core components of react-admin, a frontend Framework for building admin applications on top of REST services, using ES6, React", "files": [ "*.md", diff --git a/packages/ra-core/src/controller/record/useRecordContext.ts b/packages/ra-core/src/controller/record/useRecordContext.ts index 8d88a6e0a80..fa2834b8407 100644 --- a/packages/ra-core/src/controller/record/useRecordContext.ts +++ b/packages/ra-core/src/controller/record/useRecordContext.ts @@ -37,7 +37,7 @@ export const useRecordContext = < ): RecordType | undefined => { // Can't find a way to specify the RecordType when CreateContext is declared // @ts-ignore - const context = useContext<RecordType>(RecordContext); + const context = useContext<RecordType | undefined>(RecordContext); return (props && props.record) || context; }; diff --git a/packages/ra-core/src/dataProvider/useUpdateMany.ts b/packages/ra-core/src/dataProvider/useUpdateMany.ts index 71504b5a99c..50926cf0dc9 100644 --- a/packages/ra-core/src/dataProvider/useUpdateMany.ts +++ b/packages/ra-core/src/dataProvider/useUpdateMany.ts @@ -299,7 +299,7 @@ export const useUpdateMany = < ) => { const { mutationMode, - returnPromise, + returnPromise = mutationOptions.returnPromise, ...otherCallTimeOptions } = callTimeOptions; @@ -470,7 +470,7 @@ export type UseUpdateManyOptions< Array<RecordType['id']>, MutationError, Partial<Omit<UseUpdateManyMutateParams<RecordType>, 'mutationFn'>> -> & { mutationMode?: MutationMode }; +> & { mutationMode?: MutationMode; returnPromise?: boolean }; export type UseUpdateManyResult< RecordType extends RaRecord = any, diff --git a/packages/ra-data-fakerest/package.json b/packages/ra-data-fakerest/package.json index 7be7e0822ea..24515dcab4b 100644 --- a/packages/ra-data-fakerest/package.json +++ b/packages/ra-data-fakerest/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-fakerest", - "version": "4.15.5", + "version": "4.16.2", "description": "JSON Server data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -43,7 +43,7 @@ "@types/jest": "^29.5.2", "cross-env": "^5.2.0", "expect": "^27.4.6", - "ra-core": "^4.15.5", + "ra-core": "^4.16.2", "rimraf": "^3.0.2", "typescript": "^5.1.3" }, diff --git a/packages/ra-data-graphql-simple/package.json b/packages/ra-data-graphql-simple/package.json index 45d90a86cf9..51d1ce150f6 100644 --- a/packages/ra-data-graphql-simple/package.json +++ b/packages/ra-data-graphql-simple/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-graphql-simple", - "version": "4.15.5", + "version": "4.16.2", "description": "A GraphQL simple data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -37,7 +37,7 @@ "graphql-ast-types-browser": "~1.0.2", "lodash": "~4.17.5", "pluralize": "~7.0.0", - "ra-data-graphql": "^4.15.5" + "ra-data-graphql": "^4.16.2" }, "peerDependencies": { "graphql": "^15.6.0", diff --git a/packages/ra-data-graphql/README.md b/packages/ra-data-graphql/README.md index 3658d014875..1d9ed087345 100644 --- a/packages/ra-data-graphql/README.md +++ b/packages/ra-data-graphql/README.md @@ -39,39 +39,35 @@ yarn add graphql ra-data-graphql ## Usage +Build the data provider on mount, and pass it to the `<Admin>` component when ready: + ```jsx // in App.js import * as React from 'react'; -import { Component } from 'react'; +import { useState, useEffect } from 'react'; import buildGraphQLProvider from 'ra-data-graphql'; -import { Admin, Resource, Delete } from 'react-admin'; +import { Admin, Resource } from 'react-admin'; import buildQuery from './buildQuery'; // see Specify your queries and mutations section below import { PostCreate, PostEdit, PostList } from '../components/admin/posts'; -class App extends Component { - constructor() { - super(); - this.state = { dataProvider: null }; - } - componentDidMount() { - buildGraphQLProvider({ buildQuery }) - .then(dataProvider => this.setState({ dataProvider })); - } - - render() { - const { dataProvider } = this.state; +const App = () => { + const [dataProvider, setDataProvider] = useState(null); - if (!dataProvider) { - return <div>Loading</div>; - } + useEffect(() => { + buildGraphQLProvider({ buildQuery }) + .then(dataProvider => setDataProvider(dataProvider)); + }, []); - return ( - <Admin dataProvider={dataProvider}> - <Resource name="Post" list={PostList} edit={PostEdit} create={PostCreate} /> - </Admin> - ); + if (!dataProvider) { + return <div>Loading</div>; } + + return ( + <Admin dataProvider={dataProvider}> + <Resource name="Post" list={PostList} edit={PostEdit} create={PostCreate} /> + </Admin> + ); } export default App; @@ -79,51 +75,11 @@ export default App; ## Options -### Customize the Apollo client - -You can specify the client options by calling `buildGraphQLProvider` like this: - -```js -import { createNetworkInterface } from 'react-apollo'; - -buildGraphQLProvider({ - client: { - networkInterface: createNetworkInterface({ - uri: 'http://api.myproduct.com/graphql', - }), - }, -}); -``` - -You can pass any options supported by the [ApolloClient](https://www.apollographql.com/docs/react/api/core/ApolloClient/) constructor with the addition of `uri` which can be specified so that we create the network interface for you. Pass those options as `clientOptions`. - -You can also supply your own [ApolloClient](https://www.apollographql.com/docs/react/api/core/ApolloClient/) instance directly with: - -```js -buildGraphQLProvider({ client: myClient }); -``` - -### Introspection Options - -Instead of running an introspection query you can also provide the introspection query result directly. This speeds up the initial rendering of the `Admin` component as it no longer has to wait for the introspection query request to resolve. - -```js -import { __schema as schema } from './schema'; - -buildGraphQLProvider({ - introspection: { schema } -}); -``` - -The `./schema` file is a `schema.json` in `./src` retrieved with [get-graphql-schema --json <graphql_endpoint>](https://github.com/graphcool/get-graphql-schema). - -> Note: Importing the `schema.json` file will significantly increase the bundle size. +## Specify queries and mutations -## Specify your queries and mutations +For the provider to know how to map react-admin request to apollo queries and mutations, you must provide a `buildQuery` option. The `buildQuery` is a factory function that will be called with the introspection query result. -For the provider to know how to map react-admin request to apollo queries and mutations, you must provide a `buildQuery` option. The `buildQuery` is a factory function which will be called with the introspection query result. - -The introspection result is an object with 4 properties: +As a reminder, the result of a GraphQL introspection query is an object with 4 properties: - `types`: an array of all the GraphQL types discovered on your endpoint - `queries`: an array of all the GraphQL queries and mutations discovered on your endpoint @@ -181,7 +137,7 @@ For example: } ``` -The `buildQuery` function must return a function which will be called with the same parameters as the react-admin data provider, but must return an object matching the `options` of the ApolloClient [query](http://dev.apollodata.com/core/apollo-client-api.html#ApolloClient.query) method with an additional `parseResponse` function. +The `buildQuery` function receives this object and must return a function which will be called with the same parameters as the react-admin data provider, but must return an object matching the `options` of the ApolloClient [query](http://dev.apollodata.com/core/apollo-client-api.html#ApolloClient.query) method with an additional `parseResponse` function. This `parseResponse` function will be called with an [ApolloQueryResult](http://dev.apollodata.com/core/apollo-client-api.html#ApolloQueryResult) and must return the data expected by react-admin. @@ -214,6 +170,46 @@ const buildQuery = introspectionResults => (raFetchType, resourceName, params) = buildGraphQLProvider({ buildQuery }); ``` +### Customize the Apollo client + +You can specify the client options by calling `buildGraphQLProvider` like this: + +```js +import { createNetworkInterface } from 'react-apollo'; + +buildGraphQLProvider({ + client: { + networkInterface: createNetworkInterface({ + uri: 'http://api.myproduct.com/graphql', + }), + }, +}); +``` + +You can pass any options supported by the [ApolloClient](https://www.apollographql.com/docs/react/api/core/ApolloClient/) constructor with the addition of `uri` which can be specified so that we create the network interface for you. Pass those options as `clientOptions`. + +You can also supply your own [ApolloClient](https://www.apollographql.com/docs/react/api/core/ApolloClient/) instance directly with: + +```js +buildGraphQLProvider({ client: myClient }); +``` + +### Introspection Options + +Instead of running an introspection query you can also provide the introspection query result directly. This speeds up the initial rendering of the `Admin` component as it no longer has to wait for the introspection query request to resolve. + +```js +import { __schema as schema } from './schema'; + +buildGraphQLProvider({ + introspection: { schema } +}); +``` + +The `./schema` file is a `schema.json` in `./src` retrieved with [get-graphql-schema --json <graphql_endpoint>](https://github.com/graphcool/get-graphql-schema). + +> Note: Importing the `schema.json` file will significantly increase the bundle size. + ## Troubleshooting ## When I create or edit a resource, the list or edit page does not refresh its data diff --git a/packages/ra-data-graphql/package.json b/packages/ra-data-graphql/package.json index 0cc0f2752b6..e8a9907c62d 100644 --- a/packages/ra-data-graphql/package.json +++ b/packages/ra-data-graphql/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-graphql", - "version": "4.15.5", + "version": "4.16.2", "description": "A GraphQL data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/packages/ra-data-graphql/src/index.ts b/packages/ra-data-graphql/src/index.ts index c83676c2182..97985d7756b 100644 --- a/packages/ra-data-graphql/src/index.ts +++ b/packages/ra-data-graphql/src/index.ts @@ -43,18 +43,19 @@ export const QUERY_TYPES = INNER_QUERY_TYPES; export const MUTATION_TYPES = INNER_MUTATION_TYPES; export const ALL_TYPES = INNER_ALL_TYPES; -const RaFetchMethodMap = { - getList: GET_LIST, - getMany: GET_MANY, - getManyReference: GET_MANY_REFERENCE, - getOne: GET_ONE, - create: CREATE, - delete: DELETE, - deleteMany: DELETE_MANY, - update: UPDATE, - updateMany: UPDATE_MANY, -}; - +/** + * Map dataProvider method names to GraphQL queries and mutations + * + * @example for the Customer resource: + * dataProvider.getList() // query allCustomers() { ... } + * dataProvider.getOne() // query Customer($id: id) { ... } + * dataProvider.getMany() // query allCustomers($filter: { ids: [ids] }) { ... } + * dataProvider.getManyReference() // query allCustomers($filter: { [target]: [id] }) { ... } + * dataProvider.create() // mutation createCustomer($firstName: firstName, $lastName: lastName, ...) { ... } + * dataProvider.update() // mutation updateCustomer($id: id, firstName: firstName, $lastName: lastName, ...) { ... } + * dataProvider.delete() // mutation deleteCustomer($id: id) { ... } + * // note that updateMany and deleteMany aren't mapped in this adapter + */ export const defaultOptions = { resolveIntrospection: introspectSchema, introspection: { @@ -126,7 +127,10 @@ export type Options = { watchQuery?: GetWatchQueryOptions; }; -export default async (options: Options): Promise<DataProvider> => { +// @FIXME in v5: This doesn't need to be an async method +const buildGraphQLProvider = async ( + options: Options +): Promise<DataProvider> => { const { client: clientObject, clientOptions, @@ -149,79 +153,78 @@ export default async (options: Options): Promise<DataProvider> => { let introspectionResults; let introspectionResultsPromise; - const raDataProvider = new Proxy<DataProvider>(defaultDataProvider, { - get: (target, name) => { - if (typeof name === 'symbol' || name === 'then') { - return; - } - const raFetchMethod = RaFetchMethodMap[name]; - return async (resource, params) => { - if (introspection) { - if (!introspectionResultsPromise) { - introspectionResultsPromise = resolveIntrospection( - client, - introspection - ); - } - - introspectionResults = await introspectionResultsPromise; - } - - const buildQuery = buildQueryFactory(introspectionResults); - const overriddenBuildQuery = get( - override, - `${resource}.${raFetchMethod}` + const callApollo = async (raFetchMethod, resource, params) => { + if (introspection) { + if (!introspectionResultsPromise) { + introspectionResultsPromise = resolveIntrospection( + client, + introspection ); + } - const { parseResponse, ...query } = overriddenBuildQuery - ? { - ...buildQuery(raFetchMethod, resource, params), - ...overriddenBuildQuery(params), - } - : buildQuery(raFetchMethod, resource, params); - - const operation = getQueryOperation(query.query); - - if (operation === 'query') { - const apolloQuery = { - ...query, - fetchPolicy: 'network-only', - ...getOptions( - otherOptions.query, - raFetchMethod, - resource - ), - }; - - return ( - client - // @ts-ignore - .query(apolloQuery) - .then(response => parseResponse(response)) - .catch(handleError) - ); - } - - const apolloQuery = { - mutation: query.query, - variables: query.variables, - ...getOptions( - otherOptions.mutation, - raFetchMethod, - resource - ), - }; - - return ( - client - // @ts-ignore - .mutate(apolloQuery) - .then(parseResponse) - .catch(handleError) - ); + introspectionResults = await introspectionResultsPromise; + } + + const buildQuery = buildQueryFactory(introspectionResults); + const overriddenBuildQuery = get( + override, + `${resource}.${raFetchMethod}` + ); + + const { parseResponse, ...query } = overriddenBuildQuery + ? { + ...buildQuery(raFetchMethod, resource, params), + ...overriddenBuildQuery(params), + } + : buildQuery(raFetchMethod, resource, params); + + const operation = getQueryOperation(query.query); + + if (operation === 'query') { + const apolloQuery = { + ...query, + fetchPolicy: 'network-only', + ...getOptions(otherOptions.query, raFetchMethod, resource), }; - }, - }); + + return ( + client + // @ts-ignore + .query(apolloQuery) + .then(response => parseResponse(response)) + .catch(handleError) + ); + } + + const apolloQuery = { + mutation: query.query, + variables: query.variables, + ...getOptions(otherOptions.mutation, raFetchMethod, resource), + }; + + return ( + client + // @ts-ignore + .mutate(apolloQuery) + .then(parseResponse) + .catch(handleError) + ); + }; + + const raDataProvider = { + create: (resource, params) => callApollo(CREATE, resource, params), + delete: (resource, params) => callApollo(DELETE, resource, params), + deleteMany: (resource, params) => + callApollo(DELETE_MANY, resource, params), + getList: (resource, params) => callApollo(GET_LIST, resource, params), + getMany: (resource, params) => callApollo(GET_MANY, resource, params), + getManyReference: (resource, params) => + callApollo(GET_MANY_REFERENCE, resource, params), + getOne: (resource, params) => callApollo(GET_ONE, resource, params), + update: (resource, params) => callApollo(UPDATE, resource, params), + updateMany: (resource, params) => + callApollo(UPDATE_MANY, resource, params), + }; return raDataProvider; }; @@ -245,15 +248,4 @@ const getQueryOperation = query => { throw new Error('Unable to determine the query operation'); }; -// Only used to initialize proxy -const defaultDataProvider = { - create: () => Promise.resolve({ data: null }), // avoids adding a context in tests - delete: () => Promise.resolve({ data: null }), // avoids adding a context in tests - deleteMany: () => Promise.resolve({ data: [] }), // avoids adding a context in tests - getList: () => Promise.resolve({ data: [], total: 0 }), // avoids adding a context in tests - getMany: () => Promise.resolve({ data: [] }), // avoids adding a context in tests - getManyReference: () => Promise.resolve({ data: [], total: 0 }), // avoids adding a context in tests - getOne: () => Promise.resolve({ data: null }), // avoids adding a context in tests - update: () => Promise.resolve({ data: null }), // avoids adding a context in tests - updateMany: () => Promise.resolve({ data: [] }), // avoids adding a context in tests -}; +export default buildGraphQLProvider; diff --git a/packages/ra-data-json-server/package.json b/packages/ra-data-json-server/package.json index 2e707da9ecf..0c7ad7d9658 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": "4.15.5", + "version": "4.16.2", "description": "JSON Server data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -26,7 +26,7 @@ }, "dependencies": { "query-string": "^7.1.1", - "ra-core": "^4.15.5" + "ra-core": "^4.16.2" }, "devDependencies": { "cross-env": "^5.2.0", diff --git a/packages/ra-data-localforage/package.json b/packages/ra-data-localforage/package.json index 78402a97eb8..96ebbba8c27 100644 --- a/packages/ra-data-localforage/package.json +++ b/packages/ra-data-localforage/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-local-forage", - "version": "4.15.5", + "version": "4.16.2", "description": "LocalForage data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -42,7 +42,7 @@ "dependencies": { "localforage": "^1.7.1", "lodash": "~4.17.5", - "ra-data-fakerest": "^4.15.5" + "ra-data-fakerest": "^4.16.2" }, "devDependencies": { "cross-env": "^5.2.0", diff --git a/packages/ra-data-localstorage/package.json b/packages/ra-data-localstorage/package.json index 881506da4f1..2f9337261ea 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": "4.15.5", + "version": "4.16.2", "description": "Local storage data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -38,7 +38,7 @@ }, "dependencies": { "lodash": "~4.17.5", - "ra-data-fakerest": "^4.15.5" + "ra-data-fakerest": "^4.16.2" }, "devDependencies": { "cross-env": "^5.2.0", diff --git a/packages/ra-data-simple-rest/package.json b/packages/ra-data-simple-rest/package.json index b5752c50bb5..a877f8c86e4 100644 --- a/packages/ra-data-simple-rest/package.json +++ b/packages/ra-data-simple-rest/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-simple-rest", - "version": "4.15.5", + "version": "4.16.2", "description": "Simple REST data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -29,7 +29,7 @@ }, "devDependencies": { "cross-env": "^5.2.0", - "ra-core": "^4.15.5", + "ra-core": "^4.16.2", "rimraf": "^3.0.2", "typescript": "^5.1.3" }, diff --git a/packages/ra-i18n-i18next/package.json b/packages/ra-i18n-i18next/package.json index 2baf3b89144..fed53a6b793 100644 --- a/packages/ra-i18n-i18next/package.json +++ b/packages/ra-i18n-i18next/package.json @@ -1,6 +1,6 @@ { "name": "ra-i18n-i18next", - "version": "4.15.5", + "version": "4.16.2", "description": "i18next i18n provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -26,7 +26,7 @@ }, "dependencies": { "i18next": "^23.5.1", - "ra-core": "^4.15.5", + "ra-core": "^4.16.2", "react-i18next": "^13.2.2" }, "devDependencies": { diff --git a/packages/ra-i18n-polyglot/package.json b/packages/ra-i18n-polyglot/package.json index 916bb213e1e..2e40c603cd1 100644 --- a/packages/ra-i18n-polyglot/package.json +++ b/packages/ra-i18n-polyglot/package.json @@ -1,6 +1,6 @@ { "name": "ra-i18n-polyglot", - "version": "4.15.5", + "version": "4.16.2", "description": "Polyglot i18n provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -26,7 +26,7 @@ }, "dependencies": { "node-polyglot": "^2.2.2", - "ra-core": "^4.15.5" + "ra-core": "^4.16.2" }, "devDependencies": { "cross-env": "^5.2.0", diff --git a/packages/ra-input-rich-text/package.json b/packages/ra-input-rich-text/package.json index c39d01296b2..17b41be7377 100644 --- a/packages/ra-input-rich-text/package.json +++ b/packages/ra-input-rich-text/package.json @@ -1,6 +1,6 @@ { "name": "ra-input-rich-text", - "version": "4.15.5", + "version": "4.16.2", "description": "<RichTextInput> component for react-admin, useful for editing HTML code in admin GUIs.", "author": "Gildas Garcia", "repository": "marmelab/react-admin", @@ -51,10 +51,10 @@ "@testing-library/react": "^14.1.2", "@tiptap/extension-mention": "^2.0.3", "@tiptap/suggestion": "^2.0.3", - "data-generator-retail": "^4.15.5", - "ra-core": "^4.15.5", - "ra-data-fakerest": "^4.15.5", - "ra-ui-materialui": "^4.15.5", + "data-generator-retail": "^4.16.2", + "ra-core": "^4.16.2", + "ra-data-fakerest": "^4.16.2", + "ra-ui-materialui": "^4.16.2", "react": "^18.0.0", "react-dom": "^18.2.0", "react-hook-form": "^7.43.9", diff --git a/packages/ra-language-english/package.json b/packages/ra-language-english/package.json index f319cd4656c..8342957c6ae 100644 --- a/packages/ra-language-english/package.json +++ b/packages/ra-language-english/package.json @@ -1,6 +1,6 @@ { "name": "ra-language-english", - "version": "4.15.5", + "version": "4.16.2", "description": "English messages for react-admin, the frontend framework for building admin applications on top of REST/GraphQL services", "repository": { "type": "git", @@ -21,7 +21,7 @@ "watch": "tsc --outDir dist/esm --module es2015 --watch" }, "dependencies": { - "ra-core": "^4.15.5" + "ra-core": "^4.16.2" }, "devDependencies": { "rimraf": "^3.0.2", diff --git a/packages/ra-language-french/package.json b/packages/ra-language-french/package.json index e02db6591d4..a8710bfb2db 100644 --- a/packages/ra-language-french/package.json +++ b/packages/ra-language-french/package.json @@ -1,6 +1,6 @@ { "name": "ra-language-french", - "version": "4.15.5", + "version": "4.16.2", "description": "French messages for react-admin, the frontend framework for building admin applications on top of REST/GraphQL services", "repository": { "type": "git", @@ -21,7 +21,7 @@ "watch": "tsc --outDir dist/esm --module es2015 --watch" }, "dependencies": { - "ra-core": "^4.15.5" + "ra-core": "^4.16.2" }, "devDependencies": { "rimraf": "^3.0.2", diff --git a/packages/ra-no-code/package.json b/packages/ra-no-code/package.json index acf7dd731aa..fbfcdd592c1 100644 --- a/packages/ra-no-code/package.json +++ b/packages/ra-no-code/package.json @@ -1,6 +1,6 @@ { "name": "ra-no-code", - "version": "4.15.5", + "version": "4.16.2", "description": "", "files": [ "*.md", @@ -49,8 +49,8 @@ "lodash": "~4.17.5", "papaparse": "^5.3.0", "prop-types": "^15.8.1", - "ra-data-local-storage": "^4.15.5", - "react-admin": "^4.15.5", + "ra-data-local-storage": "^4.16.2", + "react-admin": "^4.16.2", "react-dropzone": "^12.0.4" }, "gitHead": "b227592132da6ae5f01438fa8269e04596cdfdd8" diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index 833c4433f33..0834996ecec 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -1,6 +1,6 @@ { "name": "ra-ui-materialui", - "version": "4.15.5", + "version": "4.16.2", "description": "UI Components for react-admin with Material UI", "files": [ "*.md", @@ -39,9 +39,9 @@ "file-api": "~0.10.4", "history": "^5.1.0", "ignore-styles": "~5.0.1", - "ra-core": "^4.15.5", - "ra-i18n-polyglot": "^4.15.5", - "ra-language-english": "^4.15.5", + "ra-core": "^4.16.2", + "ra-i18n-polyglot": "^4.16.2", + "ra-language-english": "^4.16.2", "react": "^18.0.0", "react-dom": "^18.2.0", "react-hook-form": "^7.43.9", diff --git a/packages/ra-ui-materialui/src/detail/Tab.tsx b/packages/ra-ui-materialui/src/detail/Tab.tsx index d24ece1ca1e..2a50a5202c6 100644 --- a/packages/ra-ui-materialui/src/detail/Tab.tsx +++ b/packages/ra-ui-materialui/src/detail/Tab.tsx @@ -2,7 +2,12 @@ import * as React from 'react'; import { isValidElement, ReactElement, ReactNode } from 'react'; import PropTypes from 'prop-types'; import { Link, useLocation } from 'react-router-dom'; -import { Tab as MuiTab, TabProps as MuiTabProps, Stack } from '@mui/material'; +import { + Tab as MuiTab, + TabProps as MuiTabProps, + Stack, + styled, +} from '@mui/material'; import { ResponsiveStyleValue } from '@mui/system'; import { useTranslate, RaRecord } from 'ra-core'; import clsx from 'clsx'; @@ -101,7 +106,7 @@ export const Tab = ({ }; const renderContent = () => ( - <Stack className={contentClassName} spacing={spacing} divider={divider}> + <Root className={contentClassName} spacing={spacing} divider={divider}> {React.Children.map(children, field => field && isValidElement<any>(field) ? ( <Labeled @@ -110,6 +115,7 @@ export const Tab = ({ 'ra-field', field.props.source && `ra-field-${field.props.source}`, + TabClasses.row, field.props.className )} > @@ -117,12 +123,27 @@ export const Tab = ({ </Labeled> ) : null )} - </Stack> + </Root> ); return context === 'header' ? renderHeader() : renderContent(); }; +const PREFIX = 'RaTab'; + +export const TabClasses = { + row: `${PREFIX}-row`, +}; + +const Root = styled(Stack, { + name: PREFIX, + overridesResolver: (props, styles) => styles.root, +})(() => ({ + [`& .${TabClasses.row}`]: { + display: 'inline', + }, +})); + Tab.propTypes = { children: PropTypes.node, className: PropTypes.string, diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.tsx index 21e36294f46..8a34cba3dff 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.tsx @@ -230,6 +230,7 @@ export const ReferenceFieldView = < to={link} className={ReferenceFieldClasses.link} onClick={stopPropagation} + state={{ _scrollToTop: true }} > {child} </Link> diff --git a/packages/ra-ui-materialui/src/form/SimpleForm.stories.tsx b/packages/ra-ui-materialui/src/form/SimpleForm.stories.tsx index 3cb0106bcfe..a5baf5eaa30 100644 --- a/packages/ra-ui-materialui/src/form/SimpleForm.stories.tsx +++ b/packages/ra-ui-materialui/src/form/SimpleForm.stories.tsx @@ -5,12 +5,13 @@ import { ResourceContextProvider, testDataProvider, } from 'ra-core'; +import { Stack, ThemeProvider, createTheme } from '@mui/material'; +import { MemoryRouter } from 'react-router-dom'; import { AdminContext } from '../AdminContext'; import { Edit } from '../detail'; import { NumberInput, TextInput } from '../input'; import { SimpleForm } from './SimpleForm'; -import { Stack } from '@mui/material'; export default { title: 'ra-ui-materialui/forms/SimpleForm' }; @@ -33,7 +34,7 @@ const Wrapper = ({ i18nProvider={i18nProvider} dataProvider={testDataProvider({ getOne: () => Promise.resolve({ data }), - })} + } as any)} defaultTheme="light" > <ResourceContextProvider value="books"> @@ -170,3 +171,22 @@ export const InputBasedValidation = () => ( </SimpleForm> </Wrapper> ); + +export const Controlled = () => { + const [record, setRecord] = React.useState({} as any); + return ( + <MemoryRouter> + <ThemeProvider theme={createTheme()}> + <SimpleForm + resource="books" + onSubmit={values => setRecord(values)} + > + <TextInput source="title" fullWidth /> + <TextInput source="author" /> + <NumberInput source="year" /> + </SimpleForm> + <div>Record values: {JSON.stringify(record)}</div> + </ThemeProvider> + </MemoryRouter> + ); +}; diff --git a/packages/ra-ui-materialui/src/input/FileInput.spec.tsx b/packages/ra-ui-materialui/src/input/FileInput.spec.tsx index 7a384e0fea8..8282038e1fb 100644 --- a/packages/ra-ui-materialui/src/input/FileInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/FileInput.spec.tsx @@ -6,12 +6,13 @@ import { waitFor, waitForElementToBeRemoved, } from '@testing-library/react'; -import { testDataProvider } from 'ra-core'; +import { required, testDataProvider } from 'ra-core'; import { AdminContext } from '../AdminContext'; import { SimpleForm, Toolbar } from '../form'; import { FileField, ImageField } from '../field'; import { FileInput } from './FileInput'; +import { TextInput } from './TextInput'; import { SaveButton } from '../button'; describe('<FileInput />', () => { @@ -469,6 +470,105 @@ describe('<FileInput />', () => { test(<CustomLabel />, 'Custom label in component'); }); + describe('Validation', () => { + it('should display a validation error if the value is required and there is no file', async () => { + const onSubmit = jest.fn(); + + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={onSubmit}> + <FileInput + {...defaultPropsMultiple} + validate={required()} + > + <FileField source="src" title="title" /> + </FileInput> + <TextInput source="title" resource="posts" /> + </SimpleForm> + </AdminContext> + ); + + fireEvent.change( + await screen.findByLabelText('resources.posts.fields.title'), + { + target: { value: 'Hello world!' }, + } + ); + fireEvent.click(screen.getByLabelText('ra.action.save')); + + await screen.findByText('ra.validation.required'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('should display a validation error if the value is required and the file is removed', async () => { + const onSubmit = jest.fn(); + + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm + onSubmit={onSubmit} + defaultValues={{ + images: [ + { + src: 'test.png', + title: 'cats', + }, + ], + }} + > + <FileInput + {...defaultPropsMultiple} + validate={required()} + > + <FileField source="src" title="title" /> + </FileInput> + </SimpleForm> + </AdminContext> + ); + + expect(screen.getByTitle('cats')).not.toBeNull(); + fireEvent.click(screen.getAllByLabelText('ra.action.delete')[0]); + fireEvent.click(screen.getByLabelText('ra.action.save')); + + await screen.findByText('ra.validation.required'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('should display a validation error right away when form mode is onChange', async () => { + const onSubmit = jest.fn(); + + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm + onSubmit={onSubmit} + defaultValues={{ + images: [ + { + src: 'test.png', + title: 'cats', + }, + ], + }} + mode="onChange" + > + <FileInput + {...defaultPropsMultiple} + validate={required()} + > + <FileField source="src" title="title" /> + </FileInput> + </SimpleForm> + </AdminContext> + ); + + expect(screen.getByTitle('cats')).not.toBeNull(); + fireEvent.click(screen.getAllByLabelText('ra.action.delete')[0]); + + await screen.findByText('ra.validation.required'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + }); + describe('Image Preview', () => { it('should display file preview using child as preview component', () => { render( diff --git a/packages/ra-ui-materialui/src/input/FileInput.tsx b/packages/ra-ui-materialui/src/input/FileInput.tsx index 16b74d4b932..100c2be0bac 100644 --- a/packages/ra-ui-materialui/src/input/FileInput.tsx +++ b/packages/ra-ui-materialui/src/input/FileInput.tsx @@ -83,7 +83,7 @@ export const FileInput = (props: FileInputProps) => { const { id, - field: { onChange, value }, + field: { onChange, onBlur, value }, fieldState, formState: { isSubmitted }, isRequired, @@ -102,8 +102,10 @@ export const FileInput = (props: FileInputProps) => { if (multiple) { onChange(updatedFiles); + onBlur(); } else { onChange(updatedFiles[0]); + onBlur(); } if (onDropProp) { @@ -124,8 +126,10 @@ export const FileInput = (props: FileInputProps) => { stateFile => !shallowEqual(stateFile, file) ); onChange(filteredFiles as any); + onBlur(); } else { onChange(null); + onBlur(); } if (onRemoveProp) { diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx index 81b9b750370..0d6a5dffa41 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx @@ -123,7 +123,9 @@ const DatagridRow: FC<DatagridRowProps> = React.forwardRef((props, ref) => { return; } if (['edit', 'show'].includes(type)) { - navigate(createPath({ resource, id, type })); + navigate(createPath({ resource, id, type }), { + state: { _scrollToTop: true }, + }); return; } if (type === 'expand') { diff --git a/packages/ra-ui-materialui/src/list/filter/FilterButton.spec.tsx b/packages/ra-ui-materialui/src/list/filter/FilterButton.spec.tsx index f64ee6c04e5..04b4878e11d 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterButton.spec.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterButton.spec.tsx @@ -188,17 +188,23 @@ describe('<FilterButton />', () => { it('should close the filter menu on removing all filters', async () => { render(<WithAutoCompleteArrayInput />); + // Open Posts List + fireEvent.click(await screen.findByText('Posts')); + await waitFor(() => { expect(screen.queryAllByRole('checkbox')).toHaveLength(11); }); - fireEvent.click(screen.getByLabelText('Open')); + fireEvent.click(await screen.findByLabelText('Open')); fireEvent.click(await screen.findByText('Sint...')); await screen.findByLabelText('Add filter'); - await waitFor(() => { - expect(screen.getAllByRole('checkbox')).toHaveLength(2); - }); + await waitFor( + () => { + expect(screen.getAllByRole('checkbox')).toHaveLength(2); + }, + { timeout: 10000 } + ); fireEvent.click(screen.getByLabelText('Add filter')); fireEvent.click(await screen.findByText('Remove all filters')); diff --git a/packages/ra-ui-materialui/src/list/filter/FilterButton.stories.tsx b/packages/ra-ui-materialui/src/list/filter/FilterButton.stories.tsx index 0dd390b90b6..80126fdc8b4 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterButton.stories.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterButton.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ListBase } from 'ra-core'; +import { ListBase, memoryStore } from 'ra-core'; import { Admin, Resource, @@ -14,22 +14,23 @@ import { SearchInput, } from 'react-admin'; import fakerestDataProvider from 'ra-data-fakerest'; -import { AutocompleteArrayInput } from '../../input'; +import { + ArrayInput, + AutocompleteArrayInput, + SimpleFormIterator, +} from '../../input'; +import { MemoryRouter } from 'react-router'; export default { title: 'ra-ui-materialui/list/filter/FilterButton', argTypes: { disableSaveQuery: { - control: { - type: 'select', - options: [false, true], - }, + control: 'select', + options: [false, true], }, size: { - control: { - type: 'select', - options: [undefined, 'small', 'medium'], - }, + control: 'select', + options: [undefined, 'small', 'medium'], }, }, }; @@ -198,21 +199,46 @@ export const Basic = (args: { disableSaveQuery?: boolean }) => { source="title" defaultValue="Accusantium qui nihil voluptatum quia voluptas maxime ab similique" />, - <TextInput - label="Nested" - source="nested" - defaultValue={{ foo: 'bar' }} - format={v => v?.foo || ''} - parse={v => ({ foo: v })} - />, + <TextInput label="Nested" source="nested.foo" defaultValue="bar" />, ]; return ( - <Admin dataProvider={fakerestDataProvider(data)}> - <Resource - name="posts" - list={<PostList postFilters={postFilters} args={args} />} - /> - </Admin> + <MemoryRouter> + <Admin + dataProvider={fakerestDataProvider(data)} + store={memoryStore()} + > + <Resource + name="posts" + list={<PostList postFilters={postFilters} args={args} />} + /> + </Admin> + </MemoryRouter> + ); +}; + +export const WithArrayInput = (args: { disableSaveQuery?: boolean }) => { + const postFilters: React.ReactElement[] = [ + <ArrayInput source="title" label="Title include" alwaysOn> + <SimpleFormIterator disableReordering> + <TextInput source="" label="Title" helperText={false} /> + </SimpleFormIterator> + </ArrayInput>, + ]; + return ( + <MemoryRouter> + <Admin + dataProvider={fakerestDataProvider( + data, + process.env.NODE_ENV !== 'test' + )} + store={memoryStore()} + > + <Resource + name="posts" + list={<PostList postFilters={postFilters} args={args} />} + /> + </Admin> + </MemoryRouter> ); }; @@ -221,12 +247,17 @@ export const DisabledFilters = (args: { disableSaveQuery?: boolean }) => { <TextInput label="Title" source="title" disabled={true} />, ]; return ( - <Admin dataProvider={fakerestDataProvider(data)}> - <Resource - name="posts" - list={<PostList postFilters={postFilters} args={args} />} - /> - </Admin> + <MemoryRouter> + <Admin + dataProvider={fakerestDataProvider(data)} + store={memoryStore()} + > + <Resource + name="posts" + list={<PostList postFilters={postFilters} args={args} />} + /> + </Admin> + </MemoryRouter> ); }; @@ -252,18 +283,44 @@ export const WithSearchInput = (args: { />, ]; return ( - <Admin dataProvider={fakerestDataProvider(data)}> - <Resource - name="posts" - list={<PostList postFilters={postFilters} args={args} />} - /> - </Admin> + <MemoryRouter> + <Admin + dataProvider={fakerestDataProvider(data)} + store={memoryStore()} + > + <Resource + name="posts" + list={<PostList postFilters={postFilters} args={args} />} + /> + </Admin> + </MemoryRouter> ); }; -export const WithAutoCompleteArrayInput = () => { +const Dashboard = () => <h1>Dashboard</h1>; + +// necessary because fakerest doesn't support nested arrays as filter +const withNestedFiltersSupportDataProvider = () => { + const baseDataprovider = fakerestDataProvider(data); + return { + ...baseDataprovider, + getList: (resource: string, params: any) => { + const newParams = { ...params, filter: { ...params.filter } }; + if (newParams.filter?.nested?.foo) { + newParams.filter['nested.foo'] = newParams.filter.nested.foo; + delete newParams.filter.nested; + } + return baseDataprovider.getList(resource, newParams); + }, + }; +}; + +export const WithAutoCompleteArrayInput = (args: { + disableSaveQuery?: boolean; + size?: 'small' | 'medium'; +}) => { const postFilters: React.ReactElement[] = [ - <SearchInput source="q" alwaysOn />, + <SearchInput source="q" alwaysOn size={args.size} />, <AutocompleteArrayInput label="Title" source="id" @@ -282,21 +339,36 @@ export const WithAutoCompleteArrayInput = () => { { id: 12, name: 'Qui tempore...' }, { id: 13, name: 'Fusce...' }, ]} + size={args.size} alwaysOn - multiple + />, + <AutocompleteArrayInput + label="Nested" + source="nested.foo" + choices={[ + { id: 'bar', name: 'bar' }, + { id: 'baz', name: 'baz' }, + ]} + size={args.size} />, ]; return ( - <Admin dataProvider={fakerestDataProvider(data)}> - <Resource - name="posts" - list={ - <PostList - postFilters={postFilters} - args={{ disableSaveQuery: false }} - /> - } - /> - </Admin> + <MemoryRouter> + <Admin + dataProvider={withNestedFiltersSupportDataProvider()} + dashboard={Dashboard} + store={memoryStore()} + > + <Resource + name="posts" + list={ + <PostList + postFilters={postFilters} + args={{ disableSaveQuery: args.disableSaveQuery }} + /> + } + /> + </Admin> + </MemoryRouter> ); }; diff --git a/packages/ra-ui-materialui/src/list/filter/FilterForm.spec.tsx b/packages/ra-ui-materialui/src/list/filter/FilterForm.spec.tsx index 9635f95fbf4..202209ff925 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterForm.spec.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterForm.spec.tsx @@ -11,7 +11,11 @@ import * as React from 'react'; import { AdminContext } from '../../AdminContext'; import { ReferenceInput, SelectInput, TextInput } from '../../input'; import { Filter } from './Filter'; -import { Basic } from './FilterButton.stories'; +import { + Basic, + WithAutoCompleteArrayInput, + WithArrayInput, +} from './FilterButton.stories'; import { FilterForm, getFilterFormValues, @@ -196,6 +200,25 @@ describe('<FilterForm />', () => { expect(screen.queryByLabelText('Nested')).toBeNull(); }); + it('should provide a FormGroupContext', async () => { + render(<WithArrayInput />); + + fireEvent.click(await screen.findByLabelText('Add')); + fireEvent.change((await screen.findAllByLabelText('Title'))[0], { + target: { value: 'Sint dignissimos in architecto aut' }, + }); + fireEvent.click(await screen.findByLabelText('Add')); + + await waitFor(() => { + expect(screen.getAllByText('Title')).toHaveLength(3); + }); + fireEvent.change((await screen.findAllByLabelText('Title'))[1], { + target: { value: 'Sed quo et et fugiat modi' }, + }); + + await screen.findByText('1-2 of 2'); + }); + describe('mergeInitialValuesWithDefaultValues', () => { it('should correctly merge initial values with the default values of the alwaysOn filters', () => { const initialValues = { @@ -257,7 +280,7 @@ describe('<FilterForm />', () => { getFilterFormValues(currentFormValues, newFilterValues) ).toEqual({ classicToClear: '', - nestedToClear: '', + nestedToClear: { nestedValue: '' }, classicUpdated: 'ghi2', nestedUpdated: { nestedValue: 'jkl2' }, published_at: '2022-01-01T03:00:00.000Z', @@ -265,4 +288,48 @@ describe('<FilterForm />', () => { }); }); }); + + it('should not reapply previous filter form values when clearing nested AutocompleteArrayInput', async () => { + render(<WithAutoCompleteArrayInput />); + + // Open Posts List + fireEvent.click(await screen.findByText('Posts')); + + // Set nested filter value to 'bar' + fireEvent.click(await screen.findByLabelText('Add filter')); + fireEvent.click( + await screen.findByRole('menuitem', { name: 'Nested' }) + ); + fireEvent.click(await screen.findByText('bar')); + fireEvent.blur(await screen.findByLabelText('Nested')); + await screen.findByText('1-7 of 7'); + expect(screen.queryByRole('button', { name: 'bar' })).not.toBeNull(); + + // Navigate to Dashboard + fireEvent.click(await screen.findByText('Dashboard')); + // Navigate back to Posts List + fireEvent.click(await screen.findByText('Posts')); + // Filter should still be applied + await screen.findByText('1-7 of 7'); + expect(screen.queryByRole('button', { name: 'bar' })).not.toBeNull(); + + // Clear nested filter value + fireEvent.mouseDown( + await screen.findByLabelText('Nested', { selector: 'input' }) + ); + fireEvent.keyDown( + await screen.findByLabelText('Nested', { selector: 'input' }), + { + key: 'Backspace', + } + ); + fireEvent.blur( + await screen.findByLabelText('Nested', { selector: 'input' }) + ); + + // Wait until filter is cleared + await screen.findByText('1-10 of 13'); + // Make sure the 'bar' value is not displayed anymore + expect(screen.queryByRole('button', { name: 'bar' })).toBeNull(); + }, 10000); }); diff --git a/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx b/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx index 6bbba186fd6..5634f114716 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx @@ -9,6 +9,7 @@ import { import PropTypes from 'prop-types'; import { styled } from '@mui/material/styles'; import { + FormGroupsProvider, LabelPrefixContextProvider, ListFilterContextValue, useListContext, @@ -84,11 +85,13 @@ export const FilterForm = (props: FilterFormProps) => { return ( <FormProvider {...form}> - <FilterFormBase - onSubmit={handleFormSubmit} - filters={filters} - {...rest} - /> + <FormGroupsProvider> + <FilterFormBase + onSubmit={handleFormSubmit} + filters={filters} + {...rest} + /> + </FormGroupsProvider> </FormProvider> ); }; @@ -285,9 +288,6 @@ const getInputValue = ( innerKey, (filterValues || {})[key] ?? {} ); - if (nestedInputValue === '') { - return acc; - } acc[innerKey] = nestedInputValue; return acc; }, diff --git a/packages/ra-ui-materialui/src/theme/useTheme.spec.tsx b/packages/ra-ui-materialui/src/theme/useTheme.spec.tsx index b0923706afa..115223da36d 100644 --- a/packages/ra-ui-materialui/src/theme/useTheme.spec.tsx +++ b/packages/ra-ui-materialui/src/theme/useTheme.spec.tsx @@ -4,6 +4,9 @@ import expect from 'expect'; import { render, screen } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import { useTheme } from './useTheme'; +import { AdminContext } from '../AdminContext'; +import { ThemeTestWrapper } from '../layout/ThemeTestWrapper'; +import { defaultDarkTheme } from './defaultTheme'; const authProvider = { login: jest.fn().mockResolvedValueOnce(''), @@ -16,40 +19,111 @@ const authProvider = { const Foo = () => { const [theme] = useTheme(); return theme !== undefined ? ( - <div aria-label="has-theme">{theme}</div> + <div aria-label="has-theme">{theme as any}</div> ) : ( <></> ); }; describe('useTheme', () => { - it('should return undefined by default', () => { + it('should return the light theme by default', () => { render( <CoreAdminContext authProvider={authProvider}> <Foo /> </CoreAdminContext> ); - expect(screen.queryByLabelText('has-theme')).toBeNull(); + expect(screen.queryByText('light')).not.toBeNull(); + }); + + it('should return the light theme when no dark theme is provided even though user prefers dark mode', () => { + render( + <ThemeTestWrapper mode="dark"> + <CoreAdminContext authProvider={authProvider}> + <Foo /> + </CoreAdminContext> + </ThemeTestWrapper> + ); + expect(screen.queryByText('light')).not.toBeNull(); + }); + + it('should return the light theme when no dark theme is provided even though the stored theme is dark', () => { + const store = memoryStore({ theme: 'dark' }); + render( + <CoreAdminContext authProvider={authProvider} store={store}> + <Foo /> + </CoreAdminContext> + ); + expect(screen.queryByText('light')).not.toBeNull(); + }); + + it('should return the user preferred theme by default', async () => { + const ssrMatchMedia = query => ({ + matches: query === '(prefers-color-scheme: dark)' ? true : false, + addListener: () => {}, + removeListener: () => {}, + }); + + render( + <ThemeTestWrapper mode="dark"> + <AdminContext + authProvider={authProvider} + darkTheme={{ + ...defaultDarkTheme, + components: { + MuiUseMediaQuery: { + defaultProps: { + ssrMatchMedia, + matchMedia: ssrMatchMedia, + }, + }, + }, + }} + > + <Foo /> + </AdminContext> + </ThemeTestWrapper> + ); + await screen.findByText('dark'); }); it('should return current theme when set', () => { render( - <CoreAdminContext + <AdminContext authProvider={authProvider} store={memoryStore({ theme: 'dark' })} + darkTheme={defaultDarkTheme} > <Foo /> - </CoreAdminContext> + </AdminContext> ); expect(screen.getByLabelText('has-theme')).not.toBeNull(); + expect(screen.queryByText('dark')).not.toBeNull(); }); it('should return theme from settings when available', () => { - const { result: storeResult } = renderHook(() => useStore('theme')); + const { result: storeResult } = renderHook(() => useStore('theme'), { + wrapper: ({ children }: any) => ( + <AdminContext + authProvider={authProvider} + darkTheme={defaultDarkTheme} + > + {children} + </AdminContext> + ), + }); const [_, setTheme] = storeResult.current; setTheme('dark'); - const { result: themeResult } = renderHook(() => useTheme()); + const { result: themeResult } = renderHook(() => useTheme(), { + wrapper: ({ children }: any) => ( + <AdminContext + authProvider={authProvider} + darkTheme={defaultDarkTheme} + > + {children} + </AdminContext> + ), + }); const [theme, __] = themeResult.current; expect(theme).toEqual('dark'); diff --git a/packages/ra-ui-materialui/src/theme/useTheme.ts b/packages/ra-ui-materialui/src/theme/useTheme.ts index ec3ead4cdd7..72954324d49 100644 --- a/packages/ra-ui-materialui/src/theme/useTheme.ts +++ b/packages/ra-ui-materialui/src/theme/useTheme.ts @@ -1,5 +1,7 @@ import { useStore } from 'ra-core'; import { RaThemeOptions, ThemeType } from './types'; +import { useMediaQuery } from '@mui/material'; +import { useThemesContext } from './useThemesContext'; export type ThemeSetter = (theme: ThemeType | RaThemeOptions) => void; @@ -23,7 +25,16 @@ export type ThemeSetter = (theme: ThemeType | RaThemeOptions) => void; export const useTheme = ( type?: ThemeType | RaThemeOptions ): [ThemeType | RaThemeOptions, ThemeSetter] => { + const { darkTheme } = useThemesContext(); + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)', { + noSsr: true, + }); // FIXME: remove legacy mode in v5, and remove the RaThemeOptions type - const [theme, setter] = useStore<ThemeType | RaThemeOptions>('theme', type); - return [theme, setter]; + const [theme, setter] = useStore<ThemeType | RaThemeOptions>( + 'theme', + type ?? (prefersDarkMode && darkTheme ? 'dark' : 'light') + ); + + // Ensure that even though the store has its value set to 'dark', we still use the light theme when no dark theme is available + return [darkTheme != null ? theme : 'light', setter]; }; diff --git a/packages/react-admin/package.json b/packages/react-admin/package.json index b2a12786485..10971f3baee 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -1,6 +1,6 @@ { "name": "react-admin", - "version": "4.15.5", + "version": "4.16.2", "description": "A frontend Framework for building admin applications on top of REST services, using ES6, React and Material UI", "files": [ "*.md", @@ -41,10 +41,10 @@ "@mui/icons-material": "^5.0.1", "@mui/material": "^5.0.1", "history": "^5.1.0", - "ra-core": "^4.15.5", - "ra-i18n-polyglot": "^4.15.5", - "ra-language-english": "^4.15.5", - "ra-ui-materialui": "^4.15.5", + "ra-core": "^4.16.2", + "ra-i18n-polyglot": "^4.16.2", + "ra-language-english": "^4.16.2", + "ra-ui-materialui": "^4.16.2", "react-hook-form": "^7.43.9", "react-router": "^6.1.0", "react-router-dom": "^6.1.0" diff --git a/yarn.lock b/yarn.lock index 4056b916924..af4c1d43548 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9969,14 +9969,14 @@ __metadata: languageName: node linkType: hard -"data-generator-retail@npm:^4.12.0, data-generator-retail@npm:^4.15.5, data-generator-retail@workspace:examples/data-generator": +"data-generator-retail@npm:^4.12.0, data-generator-retail@npm:^4.16.2, data-generator-retail@workspace:examples/data-generator": version: 0.0.0-use.local resolution: "data-generator-retail@workspace:examples/data-generator" dependencies: cross-env: "npm:^5.2.0" date-fns: "npm:^2.19.0" faker: "npm:^4.1.0" - ra-core: "npm:^4.15.5" + ra-core: "npm:^4.16.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -18484,7 +18484,7 @@ __metadata: languageName: node linkType: hard -"ra-core@npm:^4.15.5, ra-core@workspace:packages/ra-core": +"ra-core@npm:^4.16.2, ra-core@workspace:packages/ra-core": version: 0.0.0-use.local resolution: "ra-core@workspace:packages/ra-core" dependencies: @@ -18530,7 +18530,7 @@ __metadata: languageName: unknown linkType: soft -"ra-data-fakerest@npm:^4.12.0, ra-data-fakerest@npm:^4.15.5, ra-data-fakerest@workspace:packages/ra-data-fakerest": +"ra-data-fakerest@npm:^4.12.0, ra-data-fakerest@npm:^4.16.2, ra-data-fakerest@workspace:packages/ra-data-fakerest": version: 0.0.0-use.local resolution: "ra-data-fakerest@workspace:packages/ra-data-fakerest" dependencies: @@ -18538,7 +18538,7 @@ __metadata: cross-env: "npm:^5.2.0" expect: "npm:^27.4.6" fakerest: "npm:^3.0.0" - ra-core: "npm:^4.15.5" + ra-core: "npm:^4.16.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -18556,7 +18556,7 @@ __metadata: graphql-ast-types-browser: "npm:~1.0.2" lodash: "npm:~4.17.5" pluralize: "npm:~7.0.0" - ra-data-graphql: "npm:^4.15.5" + ra-data-graphql: "npm:^4.16.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -18565,7 +18565,7 @@ __metadata: languageName: unknown linkType: soft -"ra-data-graphql@npm:^4.12.0, ra-data-graphql@npm:^4.15.5, ra-data-graphql@workspace:packages/ra-data-graphql": +"ra-data-graphql@npm:^4.12.0, ra-data-graphql@npm:^4.16.2, ra-data-graphql@workspace:packages/ra-data-graphql": version: 0.0.0-use.local resolution: "ra-data-graphql@workspace:packages/ra-data-graphql" dependencies: @@ -18588,7 +18588,7 @@ __metadata: dependencies: cross-env: "npm:^5.2.0" query-string: "npm:^7.1.1" - ra-core: "npm:^4.15.5" + ra-core: "npm:^4.16.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" languageName: unknown @@ -18601,7 +18601,7 @@ __metadata: cross-env: "npm:^5.2.0" localforage: "npm:^1.7.1" lodash: "npm:~4.17.5" - ra-data-fakerest: "npm:^4.15.5" + ra-data-fakerest: "npm:^4.16.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -18609,13 +18609,13 @@ __metadata: languageName: unknown linkType: soft -"ra-data-local-storage@npm:^4.12.0, ra-data-local-storage@npm:^4.15.5, ra-data-local-storage@workspace:packages/ra-data-localstorage": +"ra-data-local-storage@npm:^4.12.0, ra-data-local-storage@npm:^4.16.2, ra-data-local-storage@workspace:packages/ra-data-localstorage": version: 0.0.0-use.local resolution: "ra-data-local-storage@workspace:packages/ra-data-localstorage" dependencies: cross-env: "npm:^5.2.0" lodash: "npm:~4.17.5" - ra-data-fakerest: "npm:^4.15.5" + ra-data-fakerest: "npm:^4.16.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -18629,7 +18629,7 @@ __metadata: dependencies: cross-env: "npm:^5.2.0" query-string: "npm:^7.1.1" - ra-core: "npm:^4.15.5" + ra-core: "npm:^4.16.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -18644,26 +18644,26 @@ __metadata: cross-env: "npm:^5.2.0" i18next: "npm:^23.5.1" i18next-resources-to-backend: "npm:^1.1.4" - ra-core: "npm:^4.15.5" + ra-core: "npm:^4.16.2" react-i18next: "npm:^13.2.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" languageName: unknown linkType: soft -"ra-i18n-polyglot@npm:^4.12.0, ra-i18n-polyglot@npm:^4.15.5, ra-i18n-polyglot@workspace:packages/ra-i18n-polyglot": +"ra-i18n-polyglot@npm:^4.12.0, ra-i18n-polyglot@npm:^4.16.2, ra-i18n-polyglot@workspace:packages/ra-i18n-polyglot": version: 0.0.0-use.local resolution: "ra-i18n-polyglot@workspace:packages/ra-i18n-polyglot" dependencies: cross-env: "npm:^5.2.0" node-polyglot: "npm:^2.2.2" - ra-core: "npm:^4.15.5" + ra-core: "npm:^4.16.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" languageName: unknown linkType: soft -"ra-input-rich-text@npm:^4.12.0, ra-input-rich-text@npm:^4.15.5, ra-input-rich-text@workspace:packages/ra-input-rich-text": +"ra-input-rich-text@npm:^4.12.0, ra-input-rich-text@npm:^4.16.2, ra-input-rich-text@workspace:packages/ra-input-rich-text": version: 0.0.0-use.local resolution: "ra-input-rich-text@workspace:packages/ra-input-rich-text" dependencies: @@ -18685,10 +18685,10 @@ __metadata: "@tiptap/starter-kit": "npm:^2.0.3" "@tiptap/suggestion": "npm:^2.0.3" clsx: "npm:^1.1.1" - data-generator-retail: "npm:^4.15.5" - ra-core: "npm:^4.15.5" - ra-data-fakerest: "npm:^4.15.5" - ra-ui-materialui: "npm:^4.15.5" + data-generator-retail: "npm:^4.16.2" + ra-core: "npm:^4.16.2" + ra-data-fakerest: "npm:^4.16.2" + ra-ui-materialui: "npm:^4.16.2" react: "npm:^18.0.0" react-dom: "npm:^18.2.0" react-hook-form: "npm:^7.43.9" @@ -18705,21 +18705,21 @@ __metadata: languageName: unknown linkType: soft -"ra-language-english@npm:^4.12.0, ra-language-english@npm:^4.15.5, ra-language-english@workspace:packages/ra-language-english": +"ra-language-english@npm:^4.12.0, ra-language-english@npm:^4.16.2, ra-language-english@workspace:packages/ra-language-english": version: 0.0.0-use.local resolution: "ra-language-english@workspace:packages/ra-language-english" dependencies: - ra-core: "npm:^4.15.5" + ra-core: "npm:^4.16.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" languageName: unknown linkType: soft -"ra-language-french@npm:^4.12.0, ra-language-french@npm:^4.15.5, ra-language-french@workspace:packages/ra-language-french": +"ra-language-french@npm:^4.12.0, ra-language-french@npm:^4.16.2, ra-language-french@workspace:packages/ra-language-french": version: 0.0.0-use.local resolution: "ra-language-french@workspace:packages/ra-language-french" dependencies: - ra-core: "npm:^4.15.5" + ra-core: "npm:^4.16.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" languageName: unknown @@ -18739,9 +18739,9 @@ __metadata: lodash: "npm:~4.17.5" papaparse: "npm:^5.3.0" prop-types: "npm:^15.8.1" - ra-data-local-storage: "npm:^4.15.5" + ra-data-local-storage: "npm:^4.16.2" react: "npm:^18.0.0" - react-admin: "npm:^4.15.5" + react-admin: "npm:^4.16.2" react-dom: "npm:^18.2.0" react-dropzone: "npm:^12.0.4" react-router: "npm:^6.1.0" @@ -18756,7 +18756,7 @@ __metadata: languageName: unknown linkType: soft -"ra-ui-materialui@npm:^4.15.5, ra-ui-materialui@workspace:packages/ra-ui-materialui": +"ra-ui-materialui@npm:^4.16.2, ra-ui-materialui@workspace:packages/ra-ui-materialui": version: 0.0.0-use.local resolution: "ra-ui-materialui@workspace:packages/ra-ui-materialui" dependencies: @@ -18784,9 +18784,9 @@ __metadata: lodash: "npm:~4.17.5" prop-types: "npm:^15.8.1" query-string: "npm:^7.1.1" - ra-core: "npm:^4.15.5" - ra-i18n-polyglot: "npm:^4.15.5" - ra-language-english: "npm:^4.15.5" + ra-core: "npm:^4.16.2" + ra-i18n-polyglot: "npm:^4.16.2" + ra-language-english: "npm:^4.16.2" react: "npm:^18.0.0" react-dom: "npm:^18.2.0" react-dropzone: "npm:^12.0.4" @@ -18963,7 +18963,7 @@ __metadata: languageName: unknown linkType: soft -"react-admin@npm:^4.12.0, react-admin@npm:^4.15.5, react-admin@workspace:packages/react-admin": +"react-admin@npm:^4.12.0, react-admin@npm:^4.16.2, react-admin@workspace:packages/react-admin": version: 0.0.0-use.local resolution: "react-admin@workspace:packages/react-admin" dependencies: @@ -18974,10 +18974,10 @@ __metadata: cross-env: "npm:^5.2.0" expect: "npm:^27.4.6" history: "npm:^5.1.0" - ra-core: "npm:^4.15.5" - ra-i18n-polyglot: "npm:^4.15.5" - ra-language-english: "npm:^4.15.5" - ra-ui-materialui: "npm:^4.15.5" + ra-core: "npm:^4.16.2" + ra-i18n-polyglot: "npm:^4.16.2" + ra-language-english: "npm:^4.16.2" + ra-ui-materialui: "npm:^4.16.2" react-hook-form: "npm:^7.43.9" react-router: "npm:^6.1.0" react-router-dom: "npm:^6.1.0" @@ -20500,13 +20500,13 @@ __metadata: lodash: "npm:~4.17.5" prop-types: "npm:^15.8.1" proxy-polyfill: "npm:^0.3.0" - ra-data-fakerest: "npm:^4.15.5" - ra-i18n-polyglot: "npm:^4.15.5" - ra-input-rich-text: "npm:^4.15.5" - ra-language-english: "npm:^4.15.5" - ra-language-french: "npm:^4.15.5" + ra-data-fakerest: "npm:^4.16.2" + ra-i18n-polyglot: "npm:^4.16.2" + ra-input-rich-text: "npm:^4.16.2" + ra-language-english: "npm:^4.16.2" + ra-language-french: "npm:^4.16.2" react: "npm:^18.0.0" - react-admin: "npm:^4.15.5" + react-admin: "npm:^4.16.2" react-app-polyfill: "npm:^1.0.4" react-dom: "npm:^18.2.0" react-hook-form: "npm:^7.43.9"