diff --git a/cypress/e2e/edit.cy.js b/cypress/e2e/edit.cy.js index 6e0d47f29f1..487b959473d 100644 --- a/cypress/e2e/edit.cy.js +++ b/cypress/e2e/edit.cy.js @@ -319,7 +319,7 @@ describe('Edit Page', () => { cy.get('body').click('left'); // dismiss notification cy.get('div[role="alert"]').should(el => - expect(el).to.have.text('this title cannot be used') + expect(el).to.have.text('The form is invalid') ); cy.get(ListPagePosts.elements.recordRows) diff --git a/docs/Admin.md b/docs/Admin.md index eb262c9f3d5..b8e9fc62c7f 100644 --- a/docs/Admin.md +++ b/docs/Admin.md @@ -209,7 +209,7 @@ const App = () => ( ## `dashboard` -By default, the homepage of an admin app is the `list` of the first child ``. But you can also specify a custom component instead. To fit in the general design, use MUI's `` component, and react-admin's `` component to set the title in the AppBar: +By default, the homepage of an admin app is the `list` of the first child `<Resource>`. But you can also specify a custom component instead. To fit in the general design, use MUI's `<Card>` component, and [react-admin's `<Title>` component](./Title.md) to set the title in the AppBar: ```jsx // in src/Dashboard.js @@ -270,7 +270,7 @@ When users type URLs that don't match any of the children `<Resource>` component ![Not Found](./img/not-found.png) -You can customize this page to use the component of your choice by passing it as the `catchAll` prop. To fit in the general design, use MUI's `<Card>` component, and react-admin's `<Title>` component: +You can customize this page to use the component of your choice by passing it as the `catchAll` prop. To fit in the general design, use MUI's `<Card>` component, and [react-admin's `<Title>` component](./Title.md): ```jsx // in src/NotFound.js diff --git a/docs/AppBar.md b/docs/AppBar.md index d4e5e89a503..0a319ca2de5 100644 --- a/docs/AppBar.md +++ b/docs/AppBar.md @@ -7,17 +7,22 @@ title: "The AppBar Component" The default react-admin layout renders a horizontal app bar at the top, which is rendered by the `<AppBar>` component. -![standard layout](./img/layout-component.gif) +<video controls autoplay muted loop width="100%"> + <source src="./img/AppBar.webm" type="video/webm"> + Your browser does not support the video tag. +</video> By default, the `<AppBar>` component displays: - a hamburger icon to toggle the sidebar width, -- the application title, +- the page title, - a button to change locales (if the application uses [i18n](./Translation.md)), - a loading indicator, - a button to display the user menu. -You can customize the App Bar by creating a custom component based on `<AppBar>`, with different props. +You can customize the App Bar by creating a custom component based on `<AppBar>`, with different props. + +**Tip**: Don't mix react-admin's `<AppBar>` component with [MUI's `<AppBar>` component](https://mui.com/material-ui/api/app-bar/). The first one leverages the second but adds some react-admin-specific features. ## Usage @@ -28,16 +33,7 @@ Create a custom app bar based on react-admin's `<AppBar>`: import { AppBar } from 'react-admin'; import { Typography } from '@mui/material'; -const MyAppBar = props => ( - <AppBar {...props} color="primary"> - <Typography - variant="h6" - color="inherit" - className={classes.title} - id="react-admin-title" - /> - </AppBar> -); +const MyAppBar = () => <AppBar color="primary" position="sticky" />; ``` Then, create a custom layout based on react-admin's `<Layout>`: @@ -68,16 +64,375 @@ const App = () => ( | Prop | Required | Type | Default | Description | | ------------------- | -------- | -------------- | -------- | --------------------------------------------------- | +| `alwaysOn` | Optional | `boolean` | - | When true, the app bar is always visible | | `children` | Optional | `ReactElement` | - | What to display in the central part of the app bar | | `color` | Optional | `string` | - | The background color of the app bar | -| `enableColorOnDark` | Optional | `boolean` | - | If true, the `color` prop is applied in dark mode | -| `position` | Optional | `string` | - | The positioning type. | | `sx` | Optional | `SxProps` | - | Style overrides, powered by MUI System | -| `title` | Optional | `ReactElement` | - | A React element rendered at left side of the screen | +| `toolbar` | Optional | `ReactElement` | - | The content of the toolbar | | `userMenu` | Optional | `ReactElement` | - | The content of the dropdown user menu | Additional props are passed to [the underlying MUI `<AppBar>` element](https://mui.com/material-ui/api/app-bar/). +## `alwaysOn` + +By default, the app bar is hidden when the user scrolls down the page. This is useful to save space on small screens. But if you want to keep the app bar always visible, you can set the `alwaysOn` prop to `true`. + +```jsx +// in src/MyAppBar.js +import { AppBar } from 'react-admin'; + +const MyAppBar = () => <AppBar alwaysOn />; +``` + +## `children` + +The `<AppBar>` component accepts a `children` prop, which is displayed in the central part of the app bar. This is useful to add buttons to the app bar, for instance, a light/dark theme switcher. + +```jsx +// in src/MyAppBar.js +import { + AppBar, + TitlePortal, + ToggleThemeButton, + defaultTheme, +} from 'react-admin'; + +const darkTheme = { palette: { mode: 'dark' } }; + +export const MyAppBar = () => ( + <AppBar> + <TitlePortal /> + <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> + </AppBar> +); +``` + +![App bar with a toggle theme button](./img/AppBar-children.png) + +**Tip**: Whats the `<TitlePortal>`? It's a placeholder for the page title, that components in the page can fill using [the `<Title>` component](./Title.md). `<Title>` uses a [React Portal](https://reactjs.org/docs/portals.html) under the hood. `<TitlePortal>` takes all the available space in the app bar, so it "pushes" the following children to the right. + +If you omit `<PagePortal>`, `<AppBar>` will no longer display the page title. This can be done on purpose, e.g. if you want to render something completely different in the AppBar, like a company logo and a search engine: + +```jsx +// in src/MyAppBar.js +import { AppBar } from 'react-admin'; +import { Box } from '@mui/material'; +import { Search } from "@react-admin/ra-search"; + +import { Logo } from './Logo'; + +const MyAppBar = () => ( + <AppBar> + <Box component="span" flex={1} /> + <Logo /> + <Box component="span" flex={1} /> + <Search /> + </AppBar> +); +``` + +## `color` + +React-admin's `<AppBar>` renders an MUI `<AppBar>`, which supports a `color` prop to set the app bar color depending on the theme. By default, the app bar color is set to the `secondary` theme color. + +This means you can set the app bar color to 'default', 'inherit', 'primary', 'secondary', 'transparent', or any string. + +```jsx +// in src/MyAppBar.js +import { AppBar } from 'react-admin'; + +export const MyAppBar = () => <AppBar color="primary" />; +``` + +![App bar in primary color](./img/AppBar-color.png) + +## `sx`: CSS API + +Pass an `sx` prop to customize the style of the main component and the underlying elements. + +{% raw %} +```jsx +// in src/MyAppBar.js +import { AppBar } from 'react-admin'; + +export const MyAppBar = () => ( + <AppBar + sx={{ + color: 'lightblue', + '& .RaAppBar-toolbar': { padding: 0 }, + }} + /> +); +``` +{% endraw %} + +This property accepts the following subclasses: + +| Rule name | Description | +|--------------------------|------------------------------ | +| `& .RaAppBar-toolbar` | Applied the main toolbar | +| `& .RaAppBar-menuButton` | Applied to the hamburger icon | +| `& .RaAppBar-title` | Applied to the title portal | + +To override the style of `<AppBar>` using the [MUI style overrides](https://mui.com/customization/theme-components/), use the `RaAppBar` key. + +## `toolbar` + +By default, the `<AppBar>` renders two buttons in addition to the user menu: the language menu and the refresh button. + +If you want to reorder or remove these buttons, you can customize the toolbar by passing a `toolbar` prop. + +```jsx +// in src/MyAppBar.js +import { + AppBar, + LocalesMenuButton, + RefreshIconButton, + ToggleThemeButton, + defaultTheme, +} from 'react-admin'; + +const darkTheme = { + palette: { mode: 'dark' }, +}; + +export const MyAppBar = () => ( + <AppBar toolbar={ + <> + <LocalesMenuButton /> + <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> + <RefreshIconButton /> + </> + } > +); +``` + +**Tip**: If you only need to *add* buttons to the toolbar, you can pass them as children instead of overriding the entire toolbar. + +```jsx +// in src/MyAppBar.js +import { AppBar, TitlePortal, ToggleThemeButton, defaultTheme } from 'react-admin'; + +const darkTheme = { palette: { mode: 'dark' } }; + +export const MyAppBar = () => ( + <AppBar> + <TitlePortal /> + <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> + </AppBar> +); +``` + +## `userMenu` + +If your app uses [authentication](./Authentication.md), the `<AppBar>` component displays a button to display the user menu on the right side. By default, the user menu only contains a logout button. + +<video controls autoplay muted loop width="100%"> + <source src="./img/AppBar-user-menu.webm" type="video/webm"> + Your browser does not support the video tag. +</video> + + +The content of the user menu depends on the return value of `authProvider.getIdentity()`. The user menu icon renders an anonymous avatar, or the `avatar` property of the identity object if present. If the identity object contains a `fullName` property, it is displayed after the avatar. + +You can customize the user menu by passing a `userMenu` prop to the `<AppBar>` component. + +```jsx +import { AppBar, UserMenu, useUserMenu } from 'react-admin'; +import { MenuItem, ListItemIcon, ListItemText } from '@mui/material'; +import SettingsIcon from '@mui/icons-material/Settings'; + +const SettingsMenuItem = () => { + const { onClose } = useUserMenu(); + return ( + <MenuItem onClick={onClose}> + <ListItemIcon> + <SettingsIcon fontSize="small" /> + </ListItemIcon> + <ListItemText>Customize</ListItemText> + </MenuItem> + ); +}; + +const MyAppBar = () => ( + <AppBar + userMenu={ + <UserMenu> + <SettingsMenuItem /> + <Logout /> + </UserMenu> + } + /> +); +``` + +Note that you still have to include the `<Logout>` component in the user menu, as it is responsible for the logout action. Also, for other menu items to work, you must call the `onClose` callback when the user clicks on them to close the user menu. + +You can also hide the user menu by setting the `userMenu` prop to `false`. + +```jsx +const MyAppBar = () => <AppBar userMenu={false} />; +``` + +## Changing The Page Title + +The app bar displays the page title. CRUD page components (`<List>`, `<Edit>`, `<Create>`, `<Show>`) set the page title based on the current resource and record, and you can override the title by using their `title` prop: + +```jsx +// in src/posts/PostList.js +import { List } from 'react-admin'; + +export const PostList = () => ( + <List title="All posts"> + ... + </List> +); +``` + +On your custom pages, you need to use [the `<Title>` component](./Title.md) to set the page title: + +```jsx +// in src/MyCustomPage.js +import { Title } from 'react-admin'; + +export const MyCustomPage = () => ( + <> + <Title title="My custom page" /> + <div>My custom page content</div> + </> +); +``` + +**Tip**: The `<Title>` component uses a [React Portal](https://reactjs.org/docs/portals.html) to modify the title in the app bar. This is why you need to [include the `<TitlePortal>` component](#children) when you customize the `<AppBar>` children. + +## Displaying The Language Menu + +The language menu only appears if you use the [i18n](./Translation.md) feature and if you have more than one possible language. + +The `<AppBar>` calls [`i18nProvider.getLocales()`](./TranslationSetup.md#supporting-multiple-languages) to get the list of available languages. If this list has more than one item, it displays a language menu button on the right side of the app bar. + +This means that all you have to do to display the language menu is to set up the i18n provider correctly. For instance, if you're using `ra-i18n-polyglot`: + +```jsx +// in src/i18nProvider.js +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import en from 'ra-language-english'; +import fr from 'ra-language-french'; + +const translations = { en, fr }; + +export const i18nProvider = polyglotI18nProvider( + locale => translations[locale], + 'en', // default locale + [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'Français' } + ], +); +``` + +Or if you're defining your `i18nProvider` by hand: + +```jsx +// in src/i18nProvider.js +export const i18nProvider = { + translate: () => {/* ... */}, + changeLocale: () => {/* ... */}, + getLocale: () => 'en', + getLocales: () => [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'Français' }, + ], +}; +``` + ## Adding Buttons -## Customizing the User Menu \ No newline at end of file +To add buttons to the app bar, you can use the `<AppBar>` [`children` prop](#children). + +For instance, to add `<ToggleThemeButton>`: + +```jsx +// in src/MyAppBar.js +import { + AppBar, + TitlePortal, + ToggleThemeButton, + defaultTheme, +} from 'react-admin'; + +const darkTheme = { palette: { mode: 'dark' } }; + +export const MyAppBar = () => ( + <AppBar> + <TitlePortal /> + <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> + </AppBar> +); +``` + +**Tip**: The `<TitlePortal>` component displays the page title. As it takes all the available space in the app bar, it "pushes" the following children to the right. + +## Adding a Search Input + +A common use case for app bar customization is to add a site-wide search engine. The `<Search>` component is a good starting point for this. + +![ra-search](https://marmelab.com/ra-enterprise/modules/assets/ra-search-demo.gif) + +```jsx +// in src/MyAppBar.jsx +import { AppBar, TitlePortal } from "react-admin"; +import { Search } from "@react-admin/ra-search"; + +export const MyAppbar = () => ( + <AppBar> + <TitlePortal /> + <Search /> + </AppBar> +); +``` + +**Tip**: The `<TitlePortal>` component takes all the available space in the app bar, so it "pushes" the search input to the right. + +## Building Your Own AppBar + +If react-admin's `<AppBar>` component doesn't meet your needs, you can build your own component using MUI's `<AppBar>`. Here is an example: + +```jsx +// in src/MyAppBar.js +import { AppBar, Toolbar, Box } from '@mui/material'; +import { TitlePortal, RefreshIconButton } from 'react-admin'; + +export const MyAppBar = () => ( + <AppBar position="static"> + <Toolbar> + <TitlePortal /> + <Box flex="1"> + <RefreshIconButton /> + </Toolbar> + </AppBar> +); +``` + +Then, use your custom app bar in a custom `<Layout>` component: + +```jsx +// in src/MyLayout.js +import { Layout } from 'react-admin'; + +import { MyAppBar } from './MyAppBar'; + +export const MyLayout = (props) => ( + <Layout {...props} appBar={MyAppBar} /> +); +``` + +## Configurable + +By default, users can override the page title [in configurable mode](./Features.md#configurable-ui). + +<video controls autoplay muted loop width="100%"> + <source src="./img/TitleConfigurable.webm" type="video/webm"> + Your browser does not support the video tag. +</video> + diff --git a/docs/Authentication.md b/docs/Authentication.md index 43c759fb110..82f2ad5ad3e 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -232,6 +232,42 @@ It's up to you to decide when to redirect users to the third party authenticatio * Directly in the `AuthProvider.checkAuth()` method as above; * When users click a button on a [custom login page](#customizing-the-login-component) +## Handling Refresh Tokens + +[Refresh tokens](https://oauth.net/2/refresh-tokens/) are an important security mechanism. In order to leverage them, you should decorate the `dataProvider` and the `authProvider` so that they can check whether the authentication must be refreshed and actually refresh it. + +You can use the [`addRefreshAuthToDataProvider`](./addRefreshAuthToDataProvider.md) and [`addRefreshAuthToAuthProvider`](./addRefreshAuthToAuthProvider.md) functions for this purpose. They accept a `dataProvider` or `authProvider` respectively and a function that should refresh the authentication token if necessary: + +```jsx +// in src/refreshAuth.js +import { getAuthTokensFromLocalStorage } from './getAuthTokensFromLocalStorage'; +import { refreshAuthTokens } from './refreshAuthTokens'; + +export const refreshAuth = () => { + const { accessToken, refreshToken } = getAuthTokensFromLocalStorage(); + if (accessToken.exp < Date.now().getTime() / 1000) { + // This function will fetch the new tokens from the authentication service and update them in localStorage + return refreshAuthTokens(refreshToken); + } + return Promise.resolve(); +} + +// in src/authProvider.js +import { addRefreshAuthToAuthProvider } from 'react-admin'; +import { refreshAuth } from 'refreshAuth'; +const myAuthProvider = { + // ...Usual AuthProvider methods +}; +export const authProvider = addRefreshAuthToAuthProvider(myAuthProvider, refreshAuth); + +// in src/dataProvider.js +import { addRefreshAuthToDataProvider } from 'react-admin'; +import simpleRestProvider from 'ra-data-simple-rest'; +import { refreshAuth } from 'refreshAuth'; +const baseDataProvider = simpleRestProvider('http://path.to.my.api/'); +export const dataProvider = addRefreshAuthToDataProvider(baseDataProvider, refreshAuth); +``` + ## Customizing The Login Component Using `authProvider` is enough to implement a full-featured authorization system if the authentication relies on a username and password. diff --git a/docs/Configurable.md b/docs/Configurable.md index a0d322e2d91..9c1716f706f 100644 --- a/docs/Configurable.md +++ b/docs/Configurable.md @@ -14,7 +14,7 @@ Some react-admin components are already configurable - or rather they have a con - [`<DatagridConfigurable>`](./Datagrid.md#configurable) - [`<SimpleListConfigurable>`](./SimpleList.md#configurable) - [`<SimpleFormConfigurable>`](./SimpleForm.md#configurable) -- `<PageTitleConfigurable>` - used by the `<Title>` component +- `<PageTitleConfigurable>` - used by [the `<Title>` component](./Title.md) ## Usage @@ -197,14 +197,14 @@ Users will be able to customize each component independently. ## `<InspectorButton>` -Add the `<InspectorButton>` to the `<AppBar>` component in order to let users enter the configuration mode and show the configuration editing panel. +Add the `<InspectorButton>` to [the `<AppBar>` component](./AppBar.md) in order to let users enter the configuration mode and show the configuration editing panel. ```jsx -import { AppBar, InspectorButton } from 'react-admin'; +import { AppBar, TitlePortal, InspectorButton } from 'react-admin'; -const MyAppBar = props => ( - <AppBar {...props}> - <Typography flex="1" variant="h6" id="react-admin-title" /> +const MyAppBar = () => ( + <AppBar> + <TitlePortal /> <InspectorButton /> </AppBar> ); diff --git a/docs/ContainerLayout.md b/docs/ContainerLayout.md index 5b744691c4a..8795e05b843 100644 --- a/docs/ContainerLayout.md +++ b/docs/ContainerLayout.md @@ -174,10 +174,9 @@ const ConfigurationMenu = React.forwardRef((props, ref) => { {...props} to="/configuration" onClick={onClose} - sx={{ color: 'text.secondary' }} > <ListItemIcon> - <SettingsIcon /> + <SettingsIcon fontSize="small" /> </ListItemIcon> <ListItemText>Configuration</ListItemText> </MenuItem> diff --git a/docs/CustomRoutes.md b/docs/CustomRoutes.md index 5ae4b54531b..36730c96be4 100644 --- a/docs/CustomRoutes.md +++ b/docs/CustomRoutes.md @@ -95,7 +95,7 @@ As illustrated above, there can be more than one `<CustomRoutes>` element inside ## Custom Page Title -To define the page title (displayed in the app bar), your custom pages can use the `<Title>` component from react-admin: +To define the page title (displayed in the app bar), your custom pages can use [the `<Title>` component](./Title.md) from react-admin: ```jsx // in src/Settings.js diff --git a/docs/Features.md b/docs/Features.md index 867347a5476..b47d75c72f5 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -1233,13 +1233,13 @@ If you need to translate to a language not yet supported by react-admin, you can If your app needs to support more than one language, you can use the [`<LocalesMenuButton>`](./LocalesMenuButton.md) component to **let users choose their language**: ```jsx -import { LocalesMenuButton } from 'react-admin'; -import { AppBar, Toolbar, Typography } from '@mui/material'; +import { LocalesMenuButton, TitlePortal } from 'react-admin'; +import { AppBar, Toolbar } from '@mui/material'; -export const MyAppBar = (props) => ( - <AppBar {...props}> +export const MyAppBar = () => ( + <AppBar> <Toolbar> - <Typography flex="1" variant="h6" id="react-admin-title"></Typography> + <TitlePortal /> <LocalesMenuButton /> </Toolbar> </AppBar> diff --git a/docs/FilterList.md b/docs/FilterList.md index 4265462d6e7..41aa665a84d 100644 --- a/docs/FilterList.md +++ b/docs/FilterList.md @@ -185,3 +185,67 @@ const CustomerList = props => ( {% endraw %} **Tip**: The `<FilterList>` Sidebar is not a good UI for small screens. You can choose to hide it on small screens (as in the previous example). A good tradeoff is to use `<FilterList>` on large screens, and the Filter Button/Form combo on Mobile. + +## Customize How Filters Are Applied + +Sometimes, you may want to customize how filters are applied. For instance, by allowing users to select multiple items such as selecting multiple categories. The `<FilterListItem>` component accepts two props for this purpose: + +- `isSelected`: accepts a function that receives the item value and the currently applied filters. It must return a boolean. +- `toggleFilter`: accepts a function that receives the item value and the currently applied filters. It is called when user toggles a filter and must return the new filters to apply. + +Here's how you could implement cumulative filters, e.g. allowing users to filter items having one of several categories: + +```jsx +import { FilterList, FilterListItem } from 'react-admin'; +import CategoryIcon from '@mui/icons-material/LocalOffer'; + +export const CategoriesFilter = () => { + const isSelected = (value, filters) => { + const category = filters.category || []; + return category.includes(value.category); + }; + + const toggleFilter = (value, filters) => { + const category = filters.category || []; + return { + ...filters, + category: category.includes(value.category) + // Remove the category if it was already present + ? category.filter(v => v !== value.category) + // Add the category if it wasn't already present + : [...category, value.category], + }; + }; + + return ( + <FilterList label="Categories" icon={<CategoryIcon />}> + <FilterListItem + label="Tests" + value={{ category: 'tests' }} + isSelected={isSelected} + toggleFilter={toggleFilter} + /> + <FilterListItem + label="News" + value={{ category: 'news' }} + isSelected={isSelected} + toggleFilter={toggleFilter} + /> + <FilterListItem + label="Deals" + value={{ category: 'deals' }} + isSelected={isSelected} + toggleFilter={toggleFilter} + /> + <FilterListItem + label="Tutorials" + value={{ category: 'tutorials' }} + isSelected={isSelected} + toggleFilter={toggleFilter} + /> + </FilterList> + ) +} +``` + +![Cumulative filter list items](./img/filter-list-cumulative.gif) diff --git a/docs/Layout.md b/docs/Layout.md index 7542a28df5c..3b3362942bb 100644 --- a/docs/Layout.md +++ b/docs/Layout.md @@ -56,7 +56,7 @@ const App = () => ( React-admin injects more props at runtime based on the `<Admin>` props: * `dashboard`: The dashboard component. Used to enable the dahboard link in the menu -* `title`: The default page tile, enreder in the AppBar +* `title`: The default page tile, rendered in the AppBar for error pages * `children`: The main content of the page Any value set for these props in a custom layout will be ignored. That's why you're supposed to pass down the props when creating a layout based on `<Layout>`: @@ -84,7 +84,7 @@ import { MyAppBar } from './MyAppBar'; export const MyLayout = (props) => <Layout {...props} appBar={MyAppBar} />; ``` -You can use react-admin's `<AppBar>` as a base for your custom app bar, or the component of your choice. +You can use [react-admin's `<AppBar>` component](./AppBar.md) as a base for your custom app bar, or the component of your choice. By default, react-admin's `<AppBar>` displays the page title. You can override this default by passing children to `<AppBar>` - they will replace the default title. And if you still want to include the page title, make sure you include an element with id `react-admin-title` in the top bar (this uses [React Portals](https://reactjs.org/docs/portals.html)). @@ -94,31 +94,17 @@ Here is a custom app bar component extending `<AppBar>` to include a company log ```jsx // in src/MyAppBar.js import * as React from 'react'; -import { AppBar } from 'react-admin'; -import Typography from '@mui/material/Typography'; +import { AppBar, TitlePortal } from 'react-admin'; +import Box from '@mui/material/Box'; import Logo from './Logo'; -export const MyAppBar = (props) => ( - <AppBar - sx={{ - "& .RaAppBar-title": { - flex: 1, - textOverflow: "ellipsis", - whiteSpace: "nowrap", - overflow: "hidden", - }, - }} - {...props} - > - <Typography - variant="h6" - color="inherit" - className={classes.title} - id="react-admin-title" - /> +export const MyAppBar = () => ( + <AppBar color="primary"> + <TitlePortal /> + <Box flex="1" /> <Logo /> - <span className={classes.spacer} /> + <Box flex="1" /> </AppBar> ); ``` @@ -126,14 +112,7 @@ export const MyAppBar = (props) => ( ![custom AppBar](./img/custom_appbar.png) -**Tip**: You can change the color of the `<AppBar>` by setting the `color` prop to `default`, `inherit`, `primary`, `secondary` or `transparent`. The default value is `secondary`. - -When react-admin renders the App BAr, it passes two props: - -* `open`: a boolean indicating if the sidebar is open or not -* `title`: the page title (if set by the `<Admin>` component) - -Your custom AppBar component is free to use these props. +Check out the [`<AppBar>` documentation](./AppBar.md) for more information, and for instructions on building your own AppBar. ## `className` diff --git a/docs/LocalesMenuButton.md b/docs/LocalesMenuButton.md index a91e84261ac..37f7e17c4cd 100644 --- a/docs/LocalesMenuButton.md +++ b/docs/LocalesMenuButton.md @@ -17,13 +17,13 @@ For advanced users who wish to use the customized `<AppBar>` from MUI package or ```jsx // in src/MyAppBar.js -import { LocalesMenuButton } from 'react-admin'; -import { AppBar, Toolbar, Typography } from '@mui/material'; +import { LocalesMenuButton, TitlePortal } from 'react-admin'; +import { AppBar, Toolbar } from '@mui/material'; -export const MyAppBar = (props) => ( - <AppBar {...props}> +export const MyAppBar = () => ( + <AppBar> <Toolbar> - <Typography flex="1" variant="h6" id="react-admin-title"></Typography> + <TitlePortal /> <LocalesMenuButton /> </Toolbar> </AppBar> diff --git a/docs/Menu.md b/docs/Menu.md index 3fb9cec49cc..f59c7f68877 100644 --- a/docs/Menu.md +++ b/docs/Menu.md @@ -135,7 +135,7 @@ This property accepts the following subclasses: | `&.RaMenu-open` | Applied the menu when it's open | | `&.RaMenu-closed` | Applied to the menu when it's closed | -To override the style of `<Layout>` using the [MUI style overrides](https://mui.com/customization/theme-components/), use the `RaMenu` key. +To override the style of `<Menu>` using the [MUI style overrides](https://mui.com/customization/theme-components/), use the `RaMenu` key. ## `<Menu.Item>` diff --git a/docs/Reference.md b/docs/Reference.md index 4b3aba570ec..244209b35ff 100644 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -12,7 +12,7 @@ title: "Index" **- A -** * [`<AccordionForm>`](./AccordionForm.md)<img class="icon" src="./img/premium.svg" /> * [`<Admin>`](./Admin.md) -* [`<AppBar>`](./Theming.md#customizing-the-appbar-content) +* [`<AppBar>`](./AppBar.md) * [`<ArrayField>`](./ArrayField.md) * [`<ArrayInput>`](./ArrayInput.md) * [`<Authenticated>`](./Authenticated.md) @@ -171,6 +171,8 @@ title: "Index" * [`<TextField>`](./TextField.md) * [`<TextInput>`](./TextInput.md) * [`<TimeInput>`](./TimeInput.md) +* [<`Title>`](./Title.md) +* [<`TitlePortal>`](./AppBar.md#children) * [`<ToggleThemeButton>`](./ToggleThemeButton.md) * [`<TourProvider>`](https://marmelab.com/ra-enterprise/modules/ra-tour)<img class="icon" src="./img/premium.svg" /> * [`<TranslatableFields>`](./TranslatableFields.md) @@ -181,7 +183,7 @@ title: "Index" **- U -** * [`<UrlField>`](./UrlField.md) -* [`<UserMenu>`](./Theming.md#usermenu-customization) +* [`<UserMenu>`](./AppBar.md#usermenu) **- W -** * [`<WithPermissions>`](./WithPermissions.md) diff --git a/docs/Search.md b/docs/Search.md index 4d1f7be22ae..ccbf6a40c68 100644 --- a/docs/Search.md +++ b/docs/Search.md @@ -78,31 +78,18 @@ Check [the `ra-search` documentation](https://marmelab.com/ra-enterprise/modules If you're using [the `<Layout` component](./Layout.md), include the `<Search>` component inside a custom `<AppBar>` component: -{% raw %} ```jsx // in src/MyAppBar.jsx -import { AppBar } from "react-admin"; -import { Typography } from "@mui/material"; +import { AppBar, TitlePortal } from "react-admin"; import { Search } from "@react-admin/ra-search"; -export const MyAppbar = (props) => ( - <AppBar {...props}> - <Typography - variant="h6" - color="inherit" - sx={{ - flex: 1, - textOverflow: "ellipsis", - whiteSpace: "nowrap", - overflow: "hidden", - }} - id="react-admin-title" - /> +export const MyAppbar = () => ( + <AppBar> + <TitlePortal /> <Search /> </AppBar> ); ``` -{% endraw %} Include that AppBar in [a custom layout component](./Layout.md): diff --git a/docs/Theming.md b/docs/Theming.md index dfb2dbde649..47bbb2aca06 100644 --- a/docs/Theming.md +++ b/docs/Theming.md @@ -375,22 +375,17 @@ You can add the `<ToggleThemeButton>` to a custom App Bar: ```jsx import * as React from 'react'; -import { defaultTheme, Layout, AppBar, ToggleThemeButton } from 'react-admin'; +import { defaultTheme, Layout, AppBar, ToggleThemeButton, TitlePortal } from 'react-admin'; import { createTheme, Box, Typography } from '@mui/material'; const darkTheme = createTheme({ palette: { mode: 'dark' }, }); -const MyAppBar = props => ( - <AppBar {...props}> - <Box flex="1"> - <Typography variant="h6" id="react-admin-title"></Typography> - </Box> - <ToggleThemeButton - lightTheme={defaultTheme} - darkTheme={darkTheme} - /> +const MyAppBar = () => ( + <AppBar> + <TitlePortal /> + <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> </AppBar> ); @@ -577,7 +572,7 @@ const ConfigurationMenu = React.forwardRef((props, ref) => { to="/configuration" > <ListItemIcon> - <SettingsIcon /> + <SettingsIcon fontSize="small" /> </ListItemIcon> <ListItemText> Configuration @@ -603,8 +598,8 @@ const SwitchLanguage = forwardRef((props, ref) => { onClose(); // Close the menu }} > - <ListItemIcon sx={{ minWidth: 5 }}> - <LanguageIcon /> + <ListItemIcon> + <LanguageIcon fontSize="small" /> </ListItemIcon> <ListItemText> Switch Language @@ -613,15 +608,15 @@ const SwitchLanguage = forwardRef((props, ref) => { ); }); -const MyUserMenu = props => ( - <UserMenu {...props}> +const MyUserMenu = () => ( + <UserMenu> <ConfigurationMenu /> <SwitchLanguage /> <Logout /> </UserMenu> ); -const MyAppBar = props => <AppBar {...props} userMenu={<MyUserMenu />} />; +const MyAppBar = () => <AppBar userMenu={<MyUserMenu />} />; const MyLayout = props => <Layout {...props} appBar={MyAppBar} />; ``` @@ -633,7 +628,7 @@ You can also remove the `<UserMenu>` from the `<AppBar>` by passing `false` to t import * as React from 'react'; import { AppBar } from 'react-admin'; -const MyAppBar = props => <AppBar {...props} userMenu={false} />; +const MyAppBar = () => <AppBar userMenu={false} />; const MyLayout = props => <Layout {...props} appBar={MyAppBar} />; ``` @@ -657,7 +652,7 @@ const MyCustomIcon = () => ( const MyUserMenu = props => (<UserMenu {...props} icon={<MyCustomIcon />} />); -const MyAppBar = props => <AppBar {...props} userMenu={<MyUserMenu />} />; +const MyAppBar = () => <AppBar userMenu={<MyUserMenu />} />; ``` {% endraw %} @@ -715,13 +710,7 @@ import * as React from 'react'; import { useEffect } from 'react'; import PropTypes from 'prop-types'; import { styled } from '@mui/material'; -import { - AppBar, - Menu, - Sidebar, - ComponentPropType, - useSidebarState, -} from 'react-admin'; +import { AppBar, Menu, Sidebar } from 'react-admin'; const Root = styled("div")(({ theme }) => ({ display: "flex", @@ -752,29 +741,21 @@ const Content = styled("div")(({ theme }) => ({ paddingLeft: 5, })); -const MyLayout = ({ - children, - dashboard, - title, -}) => { - const [open] = useSidebarState(); - - return ( - <Root> - <AppFrame> - <AppBar title={title} open={open} /> - <ContentWithSidebar> - <Sidebar> - <Menu hasDashboard={!!dashboard} /> - </Sidebar> - <Content> - {children} - </Content> - </ContentWithSidebar> - </AppFrame> - </Root> - ); -}; +const MyLayout = ({ children, dashboard }) => ( + <Root> + <AppFrame> + <AppBar /> + <ContentWithSidebar> + <Sidebar> + <Menu hasDashboard={!!dashboard} /> + </Sidebar> + <Content> + {children} + </Content> + </ContentWithSidebar> + </AppFrame> + </Root> +); MyLayout.propTypes = { children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), @@ -782,7 +763,6 @@ MyLayout.propTypes = { PropTypes.func, PropTypes.string, ]), - title: PropTypes.string.isRequired, }; export default MyLayout; @@ -799,31 +779,17 @@ Here is an example customization for `<AppBar>` to include a company logo in the ```jsx // in src/MyAppBar.js import * as React from 'react'; -import { AppBar } from 'react-admin'; -import Typography from '@mui/material/Typography'; +import { AppBar, TitlePortal } from 'react-admin'; +import Box from '@mui/material/Box'; import Logo from './Logo'; -const MyAppBar = (props) => ( - <AppBar - sx={{ - "& .RaAppBar-title": { - flex: 1, - textOverflow: "ellipsis", - whiteSpace: "nowrap", - overflow: "hidden", - }, - }} - {...props} - > - <Typography - variant="h6" - color="inherit" - className={classes.title} - id="react-admin-title" - /> +const MyAppBar = () => ( + <AppBar> + <TitlePortal /> + <Box flex={1} /> <Logo /> - <span className={classes.spacer} /> + <Box flex={1} /> </AppBar> ); @@ -863,21 +829,6 @@ const App = () => ( ## Replacing The AppBar -By default, React-admin uses [MUI's `<AppBar>` component](https://mui.com/api/app-bar/) together with a custom container that internally uses a [Slide](https://mui.com/api/slide) to hide the `AppBar` on scroll. Here is an example of how to change this container with any component: - -```jsx -// in src/MyAppBar.js -import * as React from 'react'; -import { Fragment } from 'react'; -import { AppBar } from 'react-admin'; - -const MyAppBar = props => ( - <AppBar {...props} container={Fragment} /> -); - -export default MyAppBar; -``` - For more drastic changes of the top component, you will probably want to create an `<AppBar>` from scratch instead of just passing children to react-admin's `<AppBar>`. Here is an example top bar rebuilt from scratch: ```jsx @@ -887,8 +838,8 @@ import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; -const MyAppBar = props => ( - <AppBar {...props}> +const MyAppBar = () => ( + <AppBar> <Toolbar> <Typography variant="h6" id="react-admin-title" /> </Toolbar> diff --git a/docs/Title.md b/docs/Title.md new file mode 100644 index 00000000000..8f83c19770e --- /dev/null +++ b/docs/Title.md @@ -0,0 +1,123 @@ +--- +layout: default +title: "The Title Component" +--- + +# `<Title>` + +Set the page title (the text displayed in the app bar) from within a react-admin component. + +![Title](./img/Title.png) + +## Usage + +Use `<Title>` from anywhere in the page to set the page title. + +```jsx +import { Title } from 'react-admin'; + +const CustomPage = () => ( + <> + <Title title="My Custom Page" /> + <div>Content</div> + </> +); +``` + +`<Title>` uses a [React Portal](https://reactjs.org/docs/portals.html) to render the title outside of the current component. It works because the default [ `<AppBar>`](./AppBar.md) component contains a placeholder for the title called `<TitlePortal>`. + +CRUD page components ([`<List>`](./List.md), [`<Edit>`](./Edit.md), [`<Create>`](./Create.md), [`<Show>`](./Show.md)) already use a `<Title>` component. To set the page title for these components, use the `title` prop. + +```jsx +import { List } from 'react-admin'; + +const PostList = () => ( + <List title="All posts"> + ... + </List> +); +``` + +## Props + +| Prop | Required | Type | Default | Description | +| ------------------- | -------- | --------------------- | -------- | --------------------------------------------------------------------------- | +| `title` | Optional | `string|ReactElement` | - | What to display in the central part of the app bar | +| `defaultTitle` | Optional | `string` | `''` | What to display in the central part of the app bar when `title` is not set | +| `preferenceKey` | Optional | `string` | ``${pathname}.title`` | The key to use in the user preferences to store a custom title | + +## `title` + +The `title` prop can be a string or a React element. + +If it's a string, it will be passed to [the `translate` function](./useTranslate.md), so you can use a title or a message id. + +```jsx +import { Title } from 'react-admin'; + +const CustomPage = () => ( + <> + <Title title="my.custom.page.title" /> + <div>Content</div> + </> +); +``` + +If it's a React element, it will be rendered as is. If the element contains some text, it's your responsibliity to translate it. + +```jsx +import { Title } from 'react-admin'; +import ArticleIcon from '@mui/icons-material/Article'; + +const ArticlePage = () => ( + <> + <Title title={ + <> + <ArticleIcon /> + My Custom Page + </> + } /> + <div>My Custom Content</div> + </> +); +``` + +## `defaultTitle` + +It often happens that the title is empty while the component fetches the data to display. To avoid a flicker, you can pass a default title to the `<Title>` component. + +```jsx +import { Title, useGetOne } from 'react-admin'; +import ArticleIcon from '@mui/icons-material/Article'; + +const ArticlePage = ({ id }) => { + const { data, loading } = useGetOne('articles', { id }); + return ( + <> + <Title + title={data && <><ArticleIcon />{data.title}</>} + defaultTitle={<ArticleIcon />} + /> + {!loading && <div>{data.body}</div>} + </> + ); +}; +``` + +## `preferenceKey` + +In [Configurable mode](./AppBar.md#configurable), users can customize the page title via the inspector. To avoid conflicts, the `<Title>` component uses a preference key based on the current pathname. For example, the `<Title>` component in the `posts` list page will use the `posts.title` preference key. + +If you want to use a custom preference key, pass it to the `<Title>` component. + +```jsx +import { Title } from 'react-admin'; + +const CustomPage = () => ( + <> + <Title title="My Custom Page" preferenceKey="my.custom.page.title" /> + <div>Content</div> + </> +); +``` + diff --git a/docs/ToggleThemeButton.md b/docs/ToggleThemeButton.md index cafae3f40e4..dd88bc16946 100644 --- a/docs/ToggleThemeButton.md +++ b/docs/ToggleThemeButton.md @@ -13,29 +13,24 @@ The `<ToggleThemeButton>` component lets users switch from light to dark mode, a You can add the `<ToggleThemeButton>` to a custom App Bar: -```tsx -import React from 'react'; -import { ToggleThemeButton, AppBar, defaultTheme } from 'react-admin'; -import { Typography } from '@mui/material'; +```jsx +import { AppBar, TitlePortal, ToggleThemeButton, defaultTheme } from 'react-admin'; const darkTheme = { palette: { mode: 'dark' }, }; -export const MyAppBar = (props) => ( - <AppBar {...props}>- - <Typography flex="1" variant="h6" id="react-admin-title"></Typography> - <ToggleThemeButton - lightTheme={defaultTheme} - darkTheme={darkTheme} - /> - </AppBar> +export const MyAppBar = () => ( + <AppBar> + <TitlePortal /> + <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> + </AppBar>> ); ``` Then, pass the custom App Bar in a custom `<Layout>`, and the `<Layout>` to your `<Admin>`: -```tsx +```jsx import { Admin, Layout } from 'react-admin'; import { MyAppBar } from './MyAppBar'; @@ -94,5 +89,5 @@ React-admin uses the `<Admin theme>` prop as default theme. * [`ToggleThemeButton`] -[`ToggleThemeButton`]: https://github.com/marmelab/react-admin/blob/master/packages/ra-ui-materialui/src/button/ToggleThemeButton.tsx +[`ToggleThemeButton`]: https://github.com/marmelab/react-admin/blob/master/packages/ra-ui-materialui/src/button/ToggleThemeButton.jsx diff --git a/docs/Validation.md b/docs/Validation.md index b35ac9c397e..2654858e176 100644 --- a/docs/Validation.md +++ b/docs/Validation.md @@ -362,52 +362,88 @@ const CustomerCreate = () => ( ## Server-Side Validation -You can use the errors returned by the dataProvider mutation as a source for the validation. In order to display the validation errors, a custom `save` function needs to be used: +Server-side validation is supported out of the box for `pessimistic` mode only. It requires that the dataProvider throws an error with the following shape: -{% raw %} -```jsx -import * as React from 'react'; -import { useCallback } from 'react'; -import { Create, SimpleForm, TextInput, useCreate, useRedirect, useNotify } from 'react-admin'; - -export const UserCreate = () => { - const redirect = useRedirect(); - const notify = useNotify(); - - const [create] = useCreate(); - const save = useCallback( - async values => { - try { - await create( - 'users', - { data: values }, - { returnPromise: true } - ); - notify('ra.notification.created', { - type: 'info', - messageArgs: { smart_count: 1 }, - }); - redirect('list'); - } catch (error) { - if (error.body.errors) { - // The shape of the returned validation errors must match the shape of the form - return error.body.errors; +``` +{ + body: { + errors: { + title: 'An article with this title already exists. The title must be unique.', + date: 'The date is required', + tags: { message: "The tag 'agrriculture' doesn't exist" }, + } + } +} +``` + +**Tip**: The shape of the returned validation errors must match the form shape: each key needs to match a `source` prop. + +**Tip**: The returned validation errors might have any validation format we support (simple strings, translation strings or translation objects with a `message` attribute) for each key. + +**Tip**: If your data provider leverages React Admin's [`httpClient`](https://marmelab.com/react-admin/DataProviderWriting.html#example-rest-implementation), all error response bodies are wrapped and thrown as `HttpError`. This means your API only needs to return an invalid response with a json body containing the `errors` key. + +```js +import { fetchUtils } from "react-admin"; + +const httpClient = fetchUtils.fetchJson; + +const apiUrl = 'https://my.api.com/'; +/* + Example response from the API when there are validation errors: + + { + "errors": { + "title": "An article with this title already exists. The title must be unique.", + "date": "The date is required", + "tags": { "message": "The tag 'agrriculture' doesn't exist" }, + } + } +*/ + +const myDataProvider = { + create: (resource, params) => + httpClient(`${apiUrl}/${resource}`, { + method: 'POST', + body: JSON.stringify(params.data), + }).then(({ json }) => ({ + data: { ...params.data, id: json.id }, + })), +} +``` + +**Tip:** If you are not using React Admin's `httpClient`, you can still wrap errors in an `HttpError` to return them with the correct shape: + +```js +import { HttpError } from 'react-admin' + +const myDataProvider = { + create: async (resource, { data }) => { + const response = await fetch(`${process.env.API_URL}/${resource}`, { + method: 'POST', + body: JSON.stringify(data), + }); + + const body = response.json(); + /* + body should be something like: + { + errors: { + title: "An article with this title already exists. The title must be unique.", + date: "The date is required", + tags: { message: "The tag 'agrriculture' doesn't exist" }, } } - }, - [create, notify, redirect] - ); - - return ( - <Create> - <SimpleForm onSubmit={save}> - <TextInput label="First Name" source="firstName" /> - <TextInput label="Age" source="age" /> - </SimpleForm> - </Create> - ); -}; -``` -{% endraw %} + */ + + if (status < 200 || status >= 300) { + throw new HttpError( + (body && body.message) || status, + status, + body + ); + } -**Tip**: The shape of the returned validation errors must correspond to the form: a key needs to match a `source` prop. + return body; + } +} +``` diff --git a/docs/addRefreshAuthToAuthProvider.md b/docs/addRefreshAuthToAuthProvider.md new file mode 100644 index 00000000000..91e2021df7c --- /dev/null +++ b/docs/addRefreshAuthToAuthProvider.md @@ -0,0 +1,85 @@ +--- +layout: default +title: "addRefreshAuthToAuthProvider" +--- + +# `addRefreshAuthToAuthProvider` + +This helper function wraps an existing [`authProvider`]('./Authentication.md') to support authentication token refreshing mechanisms. + +## Usage + +Use `addRefreshAuthToAuthProvider` to decorate an existing auth provider. In addition to the base provider, this function takes a function responsible for refreshing the authentication token if needed. + +Here is a simple example that refreshes an expired JWT token when needed: + +```jsx +// in src/refreshAuth.js +import { getAuthTokensFromLocalStorage } from './getAuthTokensFromLocalStorage'; +import { refreshAuthTokens } from './refreshAuthTokens'; + +export const refreshAuth = () => { + const { accessToken, refreshToken } = getAuthTokensFromLocalStorage(); + if (accessToken.exp < Date.now().getTime() / 1000) { + // This function will fetch the new tokens from the authentication service and update them in localStorage + return refreshAuthTokens(refreshToken); + } + return Promise.resolve(); +} + +// in src/authProvider.js +import { addRefreshAuthToAuthProvider } from 'react-admin'; +import { refreshAuth } from 'refreshAuth'; + +const myAuthProvider = { + // ...Usual AuthProvider methods +}; + +export const authProvider = addRefreshAuthToAuthProvider(myAuthProvider, refreshAuth); +``` + +Then, pass the decorated provider to the `<Admin>` component + +```jsx +// in src/App.js +import { Admin } from 'react-admin'; +import { dataProvider } from './dataProvider'; +import { authProvider } from './authProvider'; + +export const App = () => ( + <Admin dataProvider={dataProvider} authProvider={authProvider}> + {/* ... */} + </Admin> +) +``` + +**Tip:** We usually wrap the data provider's methods in the same way. You can use the [`addRefreshAuthToDataProvider`](./addRefreshAuthToDataProvider.md) helper function to do so. + +## `provider` + +The first argument must be a valid `authProvider` object - for instance, [any third-party auth provider](./AuthProviderList.md). + +```jsx +// in src/authProvider.js +import { addRefreshAuthToAuthProvider } from 'react-admin'; + +const myAuthProvider = { + // ...Usual AuthProvider methods +}; + +export const authProvider = addRefreshAuthToAuthProvider(myAuthProvider, [ /* refreshAuth function */ ]); +``` + +## `refreshAuth` + +The second argument is a function responsible for refreshing the authentication tokens if needed. It must return a promise. + +```jsx +import { refreshAuth } from "./refreshAuth"; + +export const authProvider = addRefreshAuthToAuthProvider(myAuthProvider, refreshAuth); +``` + +## See Also + +- [`addRefreshAuthToDataProvider`](./addRefreshAuthToDataProvider.md) diff --git a/docs/addRefreshAuthToDataProvider.md b/docs/addRefreshAuthToDataProvider.md new file mode 100644 index 00000000000..d4050bb10c1 --- /dev/null +++ b/docs/addRefreshAuthToDataProvider.md @@ -0,0 +1,83 @@ +--- +layout: default +title: "addRefreshAuthToDataProvider" +--- + +# `addRefreshAuthToDataProvider` + +This helper function wraps an existing [`dataProvider`](./DataProviderIntroduction.md) to support authentication token refreshing mechanisms. + +## Usage + +Use `addRefreshAuthToDataProvider` to decorate an existing data provider. In addition to the base provider, this function takes a function responsible for refreshing the authentication token if needed. + +Here is a simple example that refreshes an expired JWT token when needed: + +```jsx +// in src/refreshAuth.js +import { getAuthTokensFromLocalStorage } from './getAuthTokensFromLocalStorage'; +import { refreshAuthTokens } from './refreshAuthTokens'; + +export const refreshAuth = () => { + const { accessToken, refreshToken } = getAuthTokensFromLocalStorage(); + if (accessToken.exp < Date.now().getTime() / 1000) { + // This function will fetch the new tokens from the authentication service and update them in localStorage + return refreshAuthTokens(refreshToken); + } + return Promise.resolve(); +} + +// in src/dataProvider.js +import { addRefreshAuthToDataProvider } from 'react-admin'; +import simpleRestProvider from 'ra-data-simple-rest'; +import { refreshAuth } from 'refreshAuth'; + +const baseDataProvider = simpleRestProvider('http://path.to.my.api/'); + +export const dataProvider = addRefreshAuthToDataProvider(baseDataProvider, refreshAuth); +``` + +Then, pass the decorated provider to the `<Admin>` component + +```jsx +// in src/App.js +import { Admin } from 'react-admin'; +import { dataProvider } from './dataProvider'; + +export const App = () => ( + <Admin dataProvider={dataProvider}> + {/* ... */} + </Admin> +) +``` + +**Tip:** We usually wrap the auth provider's methods in the same way. You can use the [`addRefreshAuthToAuthProvider`](./addRefreshAuthToAuthProvider.md) helper function to do so. + +## `provider` + +The first argument must be a valid `dataProvider` object - for instance, [any third-party data provider](./DataProviderList.md). + +```jsx +// in src/dataProvider.js +import { addRefreshAuthToDataProvider } from 'react-admin'; +import simpleRestProvider from 'ra-data-simple-rest'; + +const baseDataProvider = simpleRestProvider('http://path.to.my.api/'); + +export const dataProvider = addRefreshAuthToDataProvider(baseDataProvider, [ /* refreshAuth function */ ]); +``` + +## `refreshAuth` + +The second argument is a function responsible for refreshing the authentication tokens if needed. It must return a promise. + +```jsx +import jsonServerProvider from "ra-data-json-server"; +import { refreshAuth } from "./refreshAuth"; + +export const dataProvider = addRefreshAuthToDataProvider(baseDataProvider, refreshAuth); +``` + +## See Also + +- [`addRefreshAuthToAuthProvider`](./addRefreshAuthToAuthProvider.md) diff --git a/docs/img/AppBar-children.png b/docs/img/AppBar-children.png new file mode 100644 index 00000000000..77ebbdbdd4d Binary files /dev/null and b/docs/img/AppBar-children.png differ diff --git a/docs/img/AppBar-color.png b/docs/img/AppBar-color.png new file mode 100644 index 00000000000..a7475467a32 Binary files /dev/null and b/docs/img/AppBar-color.png differ diff --git a/docs/img/AppBar-user-menu.webm b/docs/img/AppBar-user-menu.webm new file mode 100644 index 00000000000..84e39aeb9a2 Binary files /dev/null and b/docs/img/AppBar-user-menu.webm differ diff --git a/docs/img/AppBar.webm b/docs/img/AppBar.webm new file mode 100644 index 00000000000..9430e83f0da Binary files /dev/null and b/docs/img/AppBar.webm differ diff --git a/docs/img/Title.png b/docs/img/Title.png new file mode 100644 index 00000000000..3320b810b4a Binary files /dev/null and b/docs/img/Title.png differ diff --git a/docs/img/TitleConfigurable.webm b/docs/img/TitleConfigurable.webm new file mode 100644 index 00000000000..c6db8c6b12d Binary files /dev/null and b/docs/img/TitleConfigurable.webm differ diff --git a/docs/img/filter-list-cumulative.gif b/docs/img/filter-list-cumulative.gif new file mode 100644 index 00000000000..16494f1b34f Binary files /dev/null and b/docs/img/filter-list-cumulative.gif differ diff --git a/docs/navigation.html b/docs/navigation.html index db19c0e76f1..17fa8b18a44 100644 --- a/docs/navigation.html +++ b/docs/navigation.html @@ -56,6 +56,8 @@ <li {% if page.path == 'usePermissions.md' %} class="active" {% endif %}><a class="nav-link" href="./usePermissions.html"><code>usePermissions</code></a></li> <li {% if page.path == 'useCanAccess.md' %} class="active" {% endif %}><a class="nav-link" href="./useCanAccess.html"><code>useCanAccess</code><img class="premium" src="./img/premium.svg" /></a></li> <li {% if page.path == 'canAccess.md' %} class="active" {% endif %}><a class="nav-link" href="./canAccess.html"><code>canAccess</code><img class="premium" src="./img/premium.svg" /></a></li> + <li {% if page.path == 'addRefreshAuthToAuthProvider.md' %} class="active" {% endif %}><a class="nav-link" href="./addRefreshAuthToAuthProvider.html"><code>addRefreshAuthToAuthProvider</code></a></li> + <li {% if page.path == 'addRefreshAuthToDataProvider.md' %} class="active" {% endif %}><a class="nav-link" href="./addRefreshAuthToDataProvider.html"><code>addRefreshAuthToDataProvider</code></a></li> </ul> <ul><div>List Page</div> @@ -218,10 +220,12 @@ <ul><div>Other UI components</div> <li {% if page.path == 'Layout.md' %} class="active" {% endif %}><a class="nav-link" href="./Layout.html"><code><Layout></code></a></li> <li {% if page.path == 'ContainerLayout.md' %} class="active" {% endif %}><a class="nav-link" href="./ContainerLayout.html"><code><ContainerLayout></code><img class="premium" src="./img/premium.svg" /></a></li> + <li {% if page.path == 'AppBar.md' %} class="active" {% endif %}><a class="nav-link" href="./AppBar.html"><code><AppBar></code></a></li> <li {% if page.path == 'Menu.md' %} class="active" {% endif %}><a class="nav-link" href="./Menu.html"><code><Menu></code></a></li> <li {% if page.path == 'MultiLevelMenu.md' %} class="active" {% endif %}><a class="nav-link" href="./MultiLevelMenu.html"><code><MultiLevelMenu></code><img class="premium" src="./img/premium.svg" /></a></li> <li {% if page.path == 'IconMenu.md' %} class="active" {% endif %}><a class="nav-link" href="./IconMenu.html"><code><IconMenu></code><img class="premium" src="./img/premium.svg" /></a></li> <li {% if page.path == 'HorizontalMenu.md' %} class="active" {% endif %}><a class="nav-link" href="./HorizontalMenu.html"><code><HorizontalMenu></code><img class="premium" src="./img/premium.svg" /></a></li> + <li {% if page.path == 'Title.md' %} class="active" {% endif %}><a class="nav-link" href="./Title.html"><code><Title></code></a></li> <li {% if page.path == 'Breadcrumb.md' %} class="active" {% endif %}><a class="nav-link" href="./Breadcrumb.html"><code><Breadcrumb></code><img class="premium" src="./img/premium.svg" /></a></li> <li {% if page.path == 'Search.md' %} class="active" {% endif %}><a class="nav-link" href="./Search.html"><code><Search></code><img class="premium" src="./img/premium.svg" /></a></li> <li {% if page.path == 'Buttons.md' %} class="active" {% endif %}><a class="nav-link" href="./Buttons.html">Buttons</a></li> diff --git a/docs/useLogout.md b/docs/useLogout.md index 118743a307b..481a6427309 100644 --- a/docs/useLogout.md +++ b/docs/useLogout.md @@ -34,9 +34,7 @@ const MyUserMenu = () => ( </UserMenu> ); -const MyAppBar = () => ( - <AppBar userMenu={<UserMenu />} /> -); +const MyAppBar = () => <AppBar userMenu={<UserMenu />} />; const MyLayout = (props) => ( <Layout {...props} appBar={MyAppBar} /> diff --git a/examples/demo/src/layout/AppBar.tsx b/examples/demo/src/layout/AppBar.tsx index 290080ea166..e5d5a6505d6 100644 --- a/examples/demo/src/layout/AppBar.tsx +++ b/examples/demo/src/layout/AppBar.tsx @@ -1,12 +1,17 @@ import * as React from 'react'; -import { AppBar, Logout, UserMenu, useTranslate } from 'react-admin'; +import { + AppBar, + TitlePortal, + Logout, + UserMenu, + useTranslate, +} from 'react-admin'; import { Link } from 'react-router-dom'; import { Box, MenuItem, ListItemIcon, ListItemText, - Typography, useMediaQuery, Theme, } from '@mui/material'; @@ -25,7 +30,7 @@ const ConfigurationMenu = React.forwardRef((props, ref) => { to="/configuration" > <ListItemIcon> - <SettingsIcon /> + <SettingsIcon fontSize="small" /> </ListItemIcon> <ListItemText>{translate('pos.configuration')}</ListItemText> </MenuItem> @@ -38,28 +43,13 @@ const CustomUserMenu = () => ( </UserMenu> ); -const CustomAppBar = (props: any) => { +const CustomAppBar = () => { const isLargeEnough = useMediaQuery<Theme>(theme => theme.breakpoints.up('sm') ); return ( - <AppBar - {...props} - color="secondary" - elevation={1} - userMenu={<CustomUserMenu />} - > - <Typography - variant="h6" - color="inherit" - sx={{ - flex: 1, - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden', - }} - id="react-admin-title" - /> + <AppBar color="secondary" elevation={1} userMenu={<CustomUserMenu />}> + <TitlePortal /> {isLargeEnough && <Logo />} {isLargeEnough && <Box component="span" sx={{ flex: 1 }} />} </AppBar> diff --git a/examples/simple/src/Layout.tsx b/examples/simple/src/Layout.tsx index 2cdd50fd6cb..dc0c902ee43 100644 --- a/examples/simple/src/Layout.tsx +++ b/examples/simple/src/Layout.tsx @@ -1,15 +1,13 @@ import * as React from 'react'; -import { memo } from 'react'; import { ReactQueryDevtools } from 'react-query/devtools'; -import { AppBar, Layout, InspectorButton } from 'react-admin'; -import { Typography } from '@mui/material'; +import { AppBar, Layout, InspectorButton, TitlePortal } from 'react-admin'; -const MyAppBar = memo(props => ( - <AppBar {...props}> - <Typography flex="1" variant="h6" id="react-admin-title" /> +const MyAppBar = () => ( + <AppBar> + <TitlePortal /> <InspectorButton /> </AppBar> -)); +); export default props => ( <> diff --git a/examples/simple/src/dataProvider.tsx b/examples/simple/src/dataProvider.tsx index 4df85d30d03..9acb10101a7 100644 --- a/examples/simple/src/dataProvider.tsx +++ b/examples/simple/src/dataProvider.tsx @@ -1,5 +1,5 @@ import fakeRestProvider from 'ra-data-fakerest'; -import { DataProvider, withLifecycleCallbacks } from 'react-admin'; +import { DataProvider, HttpError, withLifecycleCallbacks } from 'react-admin'; import get from 'lodash/get'; import data from './data'; import addUploadFeature from './addUploadFeature'; @@ -96,7 +96,13 @@ const sometimesFailsDataProvider = new Proxy(uploadCapableDataProvider, { params.data && params.data.title === 'f00bar' ) { - return Promise.reject(new Error('this title cannot be used')); + return Promise.reject( + new HttpError('The form is invalid', 400, { + errors: { + title: 'this title cannot be used', + }, + }) + ); } return uploadCapableDataProvider[name](resource, params); }, diff --git a/packages/ra-core/src/auth/addRefreshAuthToAuthProvider.ts b/packages/ra-core/src/auth/addRefreshAuthToAuthProvider.ts new file mode 100644 index 00000000000..0ce72fd8a06 --- /dev/null +++ b/packages/ra-core/src/auth/addRefreshAuthToAuthProvider.ts @@ -0,0 +1,51 @@ +import { AuthProvider } from '../types'; + +/** + * A higher-order function which wraps an authProvider to handle refreshing authentication. + * This is useful when the authentication service supports a refresh token mechanism. + * The wrapped provider will call the refreshAuth function before + * calling the authProvider checkAuth, getIdentity and getPermissions methods. + * + * The refreshAuth function should return a Promise that resolves when the authentication token has been refreshed. + * It might throw an error if the refresh failed. In this case, react-admin will handle the error as usual. + * + * @param provider An authProvider + * @param refreshAuth A function that refreshes the authentication token if needed and returns a Promise. + * @returns A wrapped authProvider. + * + * @example + * import { addRefreshAuthToAuthProvider } from 'react-admin'; + * import { authProvider } from './authProvider'; + * import { refreshAuth } from './refreshAuth'; + * + * const authProvider = addRefreshAuthToAuthProvider(authProvider, refreshAuth); + */ +export const addRefreshAuthToAuthProvider = ( + provider: AuthProvider, + refreshAuth: () => Promise<void> +): AuthProvider => { + const proxy = new Proxy(provider, { + get(_, name) { + const shouldIntercept = AuthProviderInterceptedMethods.includes( + name.toString() + ); + + if (shouldIntercept) { + return async (...args: any[]) => { + await refreshAuth(); + return provider[name.toString()](...args); + }; + } + + return provider[name.toString()]; + }, + }); + + return proxy; +}; + +const AuthProviderInterceptedMethods = [ + 'checkAuth', + 'getIdentity', + 'getPermissions', +]; diff --git a/packages/ra-core/src/auth/addRefreshAuthToDataProvider.ts b/packages/ra-core/src/auth/addRefreshAuthToDataProvider.ts new file mode 100644 index 00000000000..100f9d7e723 --- /dev/null +++ b/packages/ra-core/src/auth/addRefreshAuthToDataProvider.ts @@ -0,0 +1,36 @@ +import { DataProvider } from '../types'; + +/** + * A higher-order function which wraps a dataProvider to handle refreshing authentication. + * This is useful when the authentication service supports a refresh token mechanism. + * The wrapped provider will call the refreshAuth function before calling any dataProvider methods. + * + * The refreshAuth function should return a Promise that resolves when the authentication token has been refreshed. + * It might throw an error if the refresh failed. In this case, react-admin will handle the error as usual. + * + * @param provider A dataProvider + * @param refreshAuth A function that refreshes the authentication token if needed and returns a Promise. + * @returns A wrapped dataProvider. + * + * @example + * import { addRefreshAuthToDataProvider } from 'react-admin'; + * import { jsonServerProvider } from 'ra-data-json-server'; + * import { refreshAuth } from './refreshAuth'; + * + * const dataProvider = addRefreshAuthToDataProvider(jsonServerProvider('http://localhost:3000'), refreshAuth); + */ +export const addRefreshAuthToDataProvider = ( + provider: DataProvider, + refreshAuth: () => Promise<void> +): DataProvider => { + const proxy = new Proxy(provider, { + get(_, name) { + return async (...args: any[]) => { + await refreshAuth(); + return provider[name.toString()](...args); + }; + }, + }); + + return proxy; +}; diff --git a/packages/ra-core/src/auth/index.ts b/packages/ra-core/src/auth/index.ts index 64f4fa8e8b5..43dd7178e4f 100644 --- a/packages/ra-core/src/auth/index.ts +++ b/packages/ra-core/src/auth/index.ts @@ -16,6 +16,8 @@ export * from './useAuthenticated'; export * from './useCheckAuth'; export * from './useGetIdentity'; export * from './useHandleAuthCallback'; +export * from './addRefreshAuthToAuthProvider'; +export * from './addRefreshAuthToDataProvider'; export { AuthContext, diff --git a/packages/ra-core/src/controller/create/useCreateController.spec.tsx b/packages/ra-core/src/controller/create/useCreateController.spec.tsx index 1f812966c07..35205fa3ab3 100644 --- a/packages/ra-core/src/controller/create/useCreateController.spec.tsx +++ b/packages/ra-core/src/controller/create/useCreateController.spec.tsx @@ -13,6 +13,7 @@ import { SaveContextProvider, useRegisterMutationMiddleware, } from '../saveContext'; +import { DataProvider } from '../..'; describe('useCreateController', () => { describe('getRecordFromLocation', () => { @@ -502,4 +503,33 @@ describe('useCreateController', () => { expect.any(Function) ); }); + + it('should return errors from the create call', async () => { + const create = jest.fn().mockImplementationOnce(() => { + return Promise.reject({ body: { errors: { foo: 'invalid' } } }); + }); + const dataProvider = ({ + create, + } as unknown) as DataProvider; + let saveCallback; + render( + <CoreAdminContext dataProvider={dataProvider}> + <CreateController {...defaultProps}> + {({ save, record }) => { + saveCallback = save; + return <div />; + }} + </CreateController> + </CoreAdminContext> + ); + await new Promise(resolve => setTimeout(resolve, 10)); + let errors; + await act(async () => { + errors = await saveCallback({ foo: 'bar' }); + }); + expect(errors).toEqual({ foo: 'invalid' }); + expect(create).toHaveBeenCalledWith('posts', { + data: { foo: 'bar' }, + }); + }); }); diff --git a/packages/ra-core/src/controller/create/useCreateController.ts b/packages/ra-core/src/controller/create/useCreateController.ts index f32a25766cc..1e1f15db8ba 100644 --- a/packages/ra-core/src/controller/create/useCreateController.ts +++ b/packages/ra-core/src/controller/create/useCreateController.ts @@ -6,7 +6,11 @@ import { Location } from 'history'; import { UseMutationOptions } from 'react-query'; import { useAuthenticated } from '../../auth'; -import { useCreate, UseCreateMutateParams } from '../../dataProvider'; +import { + HttpError, + useCreate, + UseCreateMutateParams, +} from '../../dataProvider'; import { useRedirect, RedirectionSideEffect } from '../../routing'; import { useNotify } from '../../notification'; import { SaveContextValue, useMutationMiddlewares } from '../saveContext'; @@ -74,7 +78,7 @@ export const useCreateController = < const [create, { isLoading: saving }] = useCreate< RecordType, MutationOptionsError - >(resource, undefined, otherMutationOptions); + >(resource, undefined, { ...otherMutationOptions, returnPromise: true }); const save = useCallback( ( @@ -91,55 +95,67 @@ export const useCreateController = < : transform ? transform(data) : data - ).then((data: Partial<RecordType>) => { + ).then(async (data: Partial<RecordType>) => { const mutate = getMutateWithMiddlewares(create); - mutate( - resource, - { data, meta }, - { - onSuccess: async (data, variables, context) => { - if (onSuccessFromSave) { - return onSuccessFromSave( - data, - variables, - context - ); - } - if (onSuccess) { - return onSuccess(data, variables, context); - } + try { + await mutate( + resource, + { data, meta }, + { + onSuccess: async (data, variables, context) => { + if (onSuccessFromSave) { + return onSuccessFromSave( + data, + variables, + context + ); + } + if (onSuccess) { + return onSuccess(data, variables, context); + } - notify('ra.notification.created', { - type: 'info', - messageArgs: { smart_count: 1 }, - }); - redirect(finalRedirectTo, resource, data.id, data); - }, - onError: onErrorFromSave - ? onErrorFromSave - : onError - ? onError - : (error: Error | string) => { - notify( - typeof error === 'string' - ? error - : error.message || - 'ra.notification.http_error', - { - type: 'error', - messageArgs: { - _: - typeof error === 'string' - ? error - : error && error.message - ? error.message - : undefined, - }, - } - ); - }, + notify('ra.notification.created', { + type: 'info', + messageArgs: { smart_count: 1 }, + }); + redirect( + finalRedirectTo, + resource, + data.id, + data + ); + }, + onError: onErrorFromSave + ? onErrorFromSave + : onError + ? onError + : (error: Error | string) => { + notify( + typeof error === 'string' + ? error + : error.message || + 'ra.notification.http_error', + { + type: 'error', + messageArgs: { + _: + typeof error === 'string' + ? error + : error && + error.message + ? error.message + : undefined, + }, + } + ); + }, + } + ); + } catch (error) { + if ((error as HttpError).body?.errors != null) { + return (error as HttpError).body.errors; } - ); + } }), [ create, diff --git a/packages/ra-core/src/controller/edit/useEditController.spec.tsx b/packages/ra-core/src/controller/edit/useEditController.spec.tsx index a4a1b57b27e..5f7e7d98804 100644 --- a/packages/ra-core/src/controller/edit/useEditController.spec.tsx +++ b/packages/ra-core/src/controller/edit/useEditController.spec.tsx @@ -906,4 +906,38 @@ describe('useEditController', () => { expect.any(Function) ); }); + + it('should return errors from the update call in pessimistic mode', async () => { + let post = { id: 12 }; + const update = jest.fn().mockImplementationOnce(() => { + return Promise.reject({ body: { errors: { foo: 'invalid' } } }); + }); + const dataProvider = ({ + getOne: () => Promise.resolve({ data: post }), + update, + } as unknown) as DataProvider; + let saveCallback; + render( + <CoreAdminContext dataProvider={dataProvider}> + <EditController {...defaultProps} mutationMode="pessimistic"> + {({ save, record }) => { + saveCallback = save; + return <>{JSON.stringify(record)}</>; + }} + </EditController> + </CoreAdminContext> + ); + await screen.findByText('{"id":12}'); + let errors; + await act(async () => { + errors = await saveCallback({ foo: 'bar' }); + }); + expect(errors).toEqual({ foo: 'invalid' }); + screen.getByText('{"id":12}'); + expect(update).toHaveBeenCalledWith('posts', { + id: 12, + data: { foo: 'bar' }, + previousData: { id: 12 }, + }); + }); }); diff --git a/packages/ra-core/src/controller/edit/useEditController.ts b/packages/ra-core/src/controller/edit/useEditController.ts index caad57059b1..d873c62bd41 100644 --- a/packages/ra-core/src/controller/edit/useEditController.ts +++ b/packages/ra-core/src/controller/edit/useEditController.ts @@ -12,6 +12,7 @@ import { useRefresh, UseGetOneHookValue, UseUpdateMutateParams, + HttpError, } from '../../dataProvider'; import { useTranslate } from '../../i18n'; import { @@ -123,7 +124,11 @@ export const useEditController = < const [update, { isLoading: saving }] = useUpdate< RecordType, MutationOptionsError - >(resource, recordCached, { ...otherMutationOptions, mutationMode }); + >(resource, recordCached, { + ...otherMutationOptions, + mutationMode, + returnPromise: mutationMode === 'pessimistic', + }); const save = useCallback( ( @@ -144,57 +149,65 @@ export const useEditController = < previousData: recordCached.previousData, }) : data - ).then((data: Partial<RecordType>) => { + ).then(async (data: Partial<RecordType>) => { const mutate = getMutateWithMiddlewares(update); - return mutate( - resource, - { id, data, meta: mutationMeta }, - { - onSuccess: async (data, variables, context) => { - if (onSuccessFromSave) { - return onSuccessFromSave( - data, - variables, - context - ); - } - if (onSuccess) { - return onSuccess(data, variables, context); - } + try { + await mutate( + resource, + { id, data, meta: mutationMeta }, + { + onSuccess: async (data, variables, context) => { + if (onSuccessFromSave) { + return onSuccessFromSave( + data, + variables, + context + ); + } + + if (onSuccess) { + return onSuccess(data, variables, context); + } - notify('ra.notification.updated', { - type: 'info', - messageArgs: { smart_count: 1 }, - undoable: mutationMode === 'undoable', - }); - redirect(redirectTo, resource, data.id, data); - }, - onError: onErrorFromSave - ? onErrorFromSave - : onError - ? onError - : (error: Error | string) => { - notify( - typeof error === 'string' - ? error - : error.message || - 'ra.notification.http_error', - { - type: 'error', - messageArgs: { - _: - typeof error === 'string' - ? error - : error && error.message - ? error.message - : undefined, - }, - } - ); - }, + notify('ra.notification.updated', { + type: 'info', + messageArgs: { smart_count: 1 }, + undoable: mutationMode === 'undoable', + }); + redirect(redirectTo, resource, data.id, data); + }, + onError: onErrorFromSave + ? onErrorFromSave + : onError + ? onError + : (error: Error | string) => { + notify( + typeof error === 'string' + ? error + : error.message || + 'ra.notification.http_error', + { + type: 'error', + messageArgs: { + _: + typeof error === 'string' + ? error + : error && + error.message + ? error.message + : undefined, + }, + } + ); + }, + } + ); + } catch (error) { + if ((error as HttpError).body?.errors != null) { + return (error as HttpError).body.errors; } - ); + } }), [ id, diff --git a/packages/ra-core/src/controller/saveContext/SaveContext.ts b/packages/ra-core/src/controller/saveContext/SaveContext.ts index 8b9797de353..540d3df6fec 100644 --- a/packages/ra-core/src/controller/saveContext/SaveContext.ts +++ b/packages/ra-core/src/controller/saveContext/SaveContext.ts @@ -13,6 +13,9 @@ export interface SaveContextValue< MutateFunc extends (...args: any[]) => any = (...args: any[]) => any > { save?: SaveHandler<RecordType>; + /** + * @deprecated. Rely on the form isSubmitting value instead + */ saving?: boolean; mutationMode?: MutationMode; registerMutationMiddleware?: (callback: Middleware<MutateFunc>) => void; diff --git a/packages/ra-core/src/dataProvider/useCreate.ts b/packages/ra-core/src/dataProvider/useCreate.ts index f2490be18e2..c1304e51ccb 100644 --- a/packages/ra-core/src/dataProvider/useCreate.ts +++ b/packages/ra-core/src/dataProvider/useCreate.ts @@ -130,7 +130,10 @@ export const useCreate = < unknown > & { returnPromise?: boolean } = {} ) => { - const { returnPromise, ...reactCreateOptions } = createOptions; + const { + returnPromise = options.returnPromise, + ...reactCreateOptions + } = createOptions; if (returnPromise) { return mutation.mutateAsync( { resource: callTimeResource, ...callTimeParams }, @@ -159,7 +162,7 @@ export type UseCreateOptions< RecordType, MutationError, Partial<UseCreateMutateParams<RecordType>> ->; +> & { returnPromise?: boolean }; export type UseCreateResult< RecordType extends RaRecord = any, diff --git a/packages/ra-core/src/dataProvider/useUpdate.ts b/packages/ra-core/src/dataProvider/useUpdate.ts index 31011573752..62f70c718ec 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.ts +++ b/packages/ra-core/src/dataProvider/useUpdate.ts @@ -278,7 +278,7 @@ export const useUpdate = < ) => { const { mutationMode, - returnPromise, + returnPromise = reactMutationOptions.returnPromise, onSuccess, onSettled, onError, @@ -439,7 +439,7 @@ export type UseUpdateOptions< RecordType, MutationError, Partial<UseUpdateMutateParams<RecordType>> -> & { mutationMode?: MutationMode }; +> & { mutationMode?: MutationMode; returnPromise?: boolean }; export type UseUpdateResult< RecordType extends RaRecord = any, diff --git a/packages/ra-ui-materialui/src/auth/Logout.tsx b/packages/ra-ui-materialui/src/auth/Logout.tsx index 6257c1bf939..b6006122c38 100644 --- a/packages/ra-ui-materialui/src/auth/Logout.tsx +++ b/packages/ra-ui-materialui/src/auth/Logout.tsx @@ -48,9 +48,11 @@ export const Logout: FunctionComponent< {...rest} > <ListItemIcon className={LogoutClasses.icon}> - {icon ? icon : <ExitIcon />} + {icon ? icon : <ExitIcon fontSize="small" />} </ListItemIcon> - <ListItemText>{translate('ra.auth.logout')}</ListItemText> + <ListItemText> + {translate('ra.auth.logout', { _: 'Logout' })} + </ListItemText> </StyledMenuItem> ); }); @@ -71,9 +73,7 @@ const StyledMenuItem = styled(MenuItem, { name: PREFIX, overridesResolver: (props, styles) => styles.root, })(({ theme }) => ({ - color: theme.palette.text.secondary, - - [`& .${LogoutClasses.icon}`]: { minWidth: theme.spacing(5) }, + [`& .${LogoutClasses.icon}`]: {}, })); export interface LogoutProps { diff --git a/packages/ra-ui-materialui/src/button/LocalesMenuButton.stories.tsx b/packages/ra-ui-materialui/src/button/LocalesMenuButton.stories.tsx index df5a20293c1..41bf92da6c7 100644 --- a/packages/ra-ui-materialui/src/button/LocalesMenuButton.stories.tsx +++ b/packages/ra-ui-materialui/src/button/LocalesMenuButton.stories.tsx @@ -12,7 +12,7 @@ import { AdminContext } from '../AdminContext'; import { AdminUI } from '../AdminUI'; import { List, Datagrid } from '../list'; import { TextField } from '../field'; -import { AppBar, Layout } from '../layout'; +import { AppBar, Layout, TitlePortal } from '../layout'; export default { title: 'ra-ui-materialui/button/LocalesMenuButton' }; @@ -142,9 +142,9 @@ const BookList = () => { ); }; -const MyAppBar = props => ( - <AppBar {...props}> - <Typography flex="1" variant="h6" id="react-admin-title"></Typography> +const MyAppBar = () => ( + <AppBar> + <TitlePortal /> <LocalesMenuButton languages={[ { locale: 'en', name: 'English' }, diff --git a/packages/ra-ui-materialui/src/button/LocalesMenuButton.tsx b/packages/ra-ui-materialui/src/button/LocalesMenuButton.tsx index bd53e18d5a4..8112a364f02 100644 --- a/packages/ra-ui-materialui/src/button/LocalesMenuButton.tsx +++ b/packages/ra-ui-materialui/src/button/LocalesMenuButton.tsx @@ -9,21 +9,17 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; * Language selector. Changes the locale in the app and persists it in * preferences so that the app opens with the right locale in the future. * + * Uses i18nProvider.getLocales() to get the list of available locales. + * * @example + * import { AppBar, TitlePortal, LocalesMenuButton } from 'react-admin'; * - * const MyAppBar: FC = props => ( - * <AppBar {...props}> - * <Box flex="1"> - * <Typography variant="h6" id="react-admin-title"></Typography> - * </Box> - * <LocalesMenuButton - * languages={[ - * { locale: 'en', name: 'English' }, - * { locale: 'fr', name: 'Français' }, - * ]} - * /> - * </AppBar> - * ); + * const MyAppBar = () => ( + * <AppBar> + * <TitlePortal /> + * <LocalesMenuButton /> + * </AppBar> + * ); */ export const LocalesMenuButton = (props: LocalesMenuButtonProps) => { const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); diff --git a/packages/ra-ui-materialui/src/button/RefreshIconButton.tsx b/packages/ra-ui-materialui/src/button/RefreshIconButton.tsx index b084e6b138c..6356bf4dddc 100644 --- a/packages/ra-ui-materialui/src/button/RefreshIconButton.tsx +++ b/packages/ra-ui-materialui/src/button/RefreshIconButton.tsx @@ -28,9 +28,9 @@ export const RefreshIconButton = (props: RefreshIconButtonProps) => { ); return ( - <Tooltip title={label && translate(label, { _: label })}> + <Tooltip title={label && translate(label, { _: 'Refresh' })}> <IconButton - aria-label={label && translate(label, { _: label })} + aria-label={label && translate(label, { _: 'Refresh' })} className={className} color="inherit" onClick={handleClick} diff --git a/packages/ra-ui-materialui/src/button/SaveButton.tsx b/packages/ra-ui-materialui/src/button/SaveButton.tsx index 64f3f51317e..08870b6a3b1 100644 --- a/packages/ra-ui-materialui/src/button/SaveButton.tsx +++ b/packages/ra-ui-materialui/src/button/SaveButton.tsx @@ -54,7 +54,6 @@ export const SaveButton = <RecordType extends RaRecord = any>( label = 'ra.action.save', onClick, mutationOptions, - saving, disabled: disabledProp, type = 'submit', transform, @@ -74,11 +73,7 @@ export const SaveButton = <RecordType extends RaRecord = any>( alwaysEnable === false || alwaysEnable === undefined ? undefined : !alwaysEnable, - disabledProp || - !isDirty || - isValidating || - saveContext?.saving || - isSubmitting + disabledProp || !isDirty || isValidating || isSubmitting ); warning( @@ -124,10 +119,6 @@ export const SaveButton = <RecordType extends RaRecord = any>( ); const displayedLabel = label && translate(label, { _: label }); - const finalSaving = - typeof saving !== 'undefined' - ? saving - : saveContext?.saving || isSubmitting; return ( <StyledButton @@ -140,7 +131,7 @@ export const SaveButton = <RecordType extends RaRecord = any>( // TODO: find a way to display the loading state (LoadingButton from mui Lab?) {...rest} > - {finalSaving ? <CircularProgress size={18} thickness={2} /> : icon} + {isSubmitting ? <CircularProgress size={18} thickness={2} /> : icon} {displayedLabel} </StyledButton> ); @@ -163,7 +154,6 @@ interface Props< CreateParams<RecordType> | UpdateParams<RecordType> >; transform?: TransformData; - saving?: boolean; variant?: string; } @@ -178,7 +168,6 @@ SaveButton.propTypes = { className: PropTypes.string, invalid: PropTypes.bool, label: PropTypes.string, - saving: PropTypes.bool, variant: PropTypes.oneOf(['text', 'outlined', 'contained']), icon: PropTypes.element, alwaysEnable: PropTypes.bool, diff --git a/packages/ra-ui-materialui/src/button/ToggleThemeButton.stories.tsx b/packages/ra-ui-materialui/src/button/ToggleThemeButton.stories.tsx index 0d677868f8a..6d144a0a83f 100644 --- a/packages/ra-ui-materialui/src/button/ToggleThemeButton.stories.tsx +++ b/packages/ra-ui-materialui/src/button/ToggleThemeButton.stories.tsx @@ -7,7 +7,7 @@ import { Typography } from '@mui/material'; import { List, Datagrid } from '../list'; import { TextField } from '../field'; -import { AppBar, Layout } from '../layout'; +import { AppBar, Layout, TitlePortal } from '../layout'; import { ToggleThemeButton } from './ToggleThemeButton'; export default { title: 'ra-ui-materialui/button/ToggleThemeButton' }; @@ -99,14 +99,10 @@ const BookList = () => { ); }; -const MyAppBar = props => ( - <AppBar {...props}> - <Typography flex="1" variant="h6" id="react-admin-title"></Typography> - <ToggleThemeButton - darkTheme={{ - palette: { mode: 'dark' }, - }} - /> +const MyAppBar = () => ( + <AppBar> + <TitlePortal /> + <ToggleThemeButton darkTheme={{ palette: { mode: 'dark' } }} /> </AppBar> ); const MyLayout = props => <Layout {...props} appBar={MyAppBar} />; diff --git a/packages/ra-ui-materialui/src/button/ToggleThemeButton.tsx b/packages/ra-ui-materialui/src/button/ToggleThemeButton.tsx index 4ffab7afebd..6d4eff00a21 100644 --- a/packages/ra-ui-materialui/src/button/ToggleThemeButton.tsx +++ b/packages/ra-ui-materialui/src/button/ToggleThemeButton.tsx @@ -10,12 +10,11 @@ import { RaThemeOptions } from '..'; * Button toggling the theme (light or dark). * * @example + * import { AppBar, TitlePortal, ToggleThemeButton } from 'react-admin'; * - * const MyAppBar = props => ( - * <AppBar {...props}> - * <Box flex="1"> - * <Typography variant="h6" id="react-admin-title"></Typography> - * </Box> + * const MyAppBar = () => ( + * <AppBar> + * <TitlePortal /> * <ToggleThemeButton lightTheme={lightTheme} darkTheme={darkTheme} /> * </AppBar> * ); diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx index 803afef6431..7b4080e1e42 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx @@ -127,3 +127,50 @@ export const DifferentIdTypes = () => { </AdminContext> ); }; + +export const DifferentSizes = () => { + const fakeData = { + bands: [{ id: 1, name: 'band_1', members: [1, '2'] }], + artists: [ + { id: 1, name: 'artist_1' }, + { id: 2, name: 'artist_2' }, + { id: 3, name: 'artist_3' }, + ], + }; + const dataProvider = fakeRestProvider(fakeData, false); + return ( + <AdminContext dataProvider={dataProvider}> + <Edit resource="bands" id={1} sx={{ width: 600 }}> + <SimpleForm> + <TextInput source="name" fullWidth /> + <SelectArrayInput + fullWidth + source="members" + choices={fakeData.artists} + size="small" + /> + <SelectArrayInput + fullWidth + source="members" + choices={fakeData.artists} + size="medium" + /> + <SelectArrayInput + fullWidth + source="members" + choices={fakeData.artists} + size="small" + variant="outlined" + /> + <SelectArrayInput + fullWidth + source="members" + choices={fakeData.artists} + size="medium" + variant="outlined" + /> + </SimpleForm> + </Edit> + </AdminContext> + ); +}; diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx index b32838a51c7..8064fac1aae 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx @@ -11,6 +11,7 @@ import { FormHelperText, FormControl, Chip, + OutlinedInput, } from '@mui/material'; import { ChoicesProps, @@ -108,6 +109,7 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => { optionValue, parse, resource: resourceProp, + size = 'small', source: sourceProp, translateChoice, validate, @@ -254,6 +256,25 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => { ? [field.value] : []; + const outlinedInputProps = + variant === 'outlined' + ? { + input: ( + <OutlinedInput + id="select-multiple-chip" + label={ + <FieldTitle + label={label} + source={source} + resource={resource} + isRequired={isRequired} + /> + } + /> + ), + } + : {}; + return ( <> <StyledFormControl @@ -299,11 +320,12 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => { </div> )} data-testid="selectArray" - size="small" + size={size} {...field} {...options} onChange={handleChangeWithCreateSupport} value={finalValue} + {...outlinedInputProps} > {finalChoices.map(renderMenuItem)} </Select> diff --git a/packages/ra-ui-materialui/src/layout/AppBar.stories.tsx b/packages/ra-ui-materialui/src/layout/AppBar.stories.tsx new file mode 100644 index 00000000000..ddb4d5fb63c --- /dev/null +++ b/packages/ra-ui-materialui/src/layout/AppBar.stories.tsx @@ -0,0 +1,300 @@ +import * as React from 'react'; +import { + Box, + createTheme, + ThemeProvider, + MenuItem, + ListItemIcon, + ListItemText, + TextField, + Skeleton, +} from '@mui/material'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { QueryClientProvider, QueryClient } from 'react-query'; +import { MemoryRouter } from 'react-router'; +import { I18nContextProvider, AuthContext } from 'ra-core'; + +import { AppBar } from './AppBar'; +import { Title } from './Title'; +import { TitlePortal } from './TitlePortal'; +import { UserMenu } from './UserMenu'; +import { useUserMenu } from './useUserMenu'; +import { defaultTheme } from '../defaultTheme'; +import { + ToggleThemeButton, + RefreshIconButton, + LocalesMenuButton, +} from '../button'; +import { Logout } from '../auth'; + +export default { + title: 'ra-ui-materialui/layout/AppBar', +}; + +const Content = () => ( + <Box mt={7}> + <Skeleton + variant="text" + width="auto" + sx={{ fontSize: '2rem', mx: 2 }} + animation={false} + /> + <Skeleton + variant="rectangular" + width="auto" + height={1500} + sx={{ mx: 2 }} + animation={false} + /> + </Box> +); + +const Wrapper = ({ children, theme = createTheme(defaultTheme) }) => ( + <MemoryRouter> + <QueryClientProvider client={new QueryClient()}> + <ThemeProvider theme={theme}> + <AuthContext.Provider value={undefined as any}> + {children} + </AuthContext.Provider> + <Content /> + </ThemeProvider> + </QueryClientProvider> + </MemoryRouter> +); + +export const Basic = () => ( + <Wrapper> + <AppBar /> + </Wrapper> +); + +export const Color = () => ( + <Wrapper> + <AppBar color="primary" /> + </Wrapper> +); + +export const AlwaysOn = () => ( + <Wrapper> + <AppBar alwaysOn /> + </Wrapper> +); + +export const Position = () => ( + <Wrapper> + <AppBar position="sticky" /> + </Wrapper> +); + +export const DarkMode = () => ( + <Wrapper theme={createTheme({ palette: { mode: 'dark' } })}> + <AppBar /> + </Wrapper> +); + +export const CustomTitle = () => ( + <Wrapper> + <AppBar /> + <Title title="Custom title" /> + </Wrapper> +); + +export const TitleOverflow = () => ( + <Wrapper> + <Box maxWidth={300}> + <AppBar position="relative" /> + <Title title="Lorem ipsum sic dolor amet" /> + </Box> + </Wrapper> +); + +export const WithLocales = () => ( + <I18nContextProvider + value={{ + getLocale: () => 'en', + getLocales: () => [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'Français' }, + ], + translate: x => x, + changeLocale: () => Promise.resolve(), + }} + > + <Wrapper> + <AppBar /> + </Wrapper> + </I18nContextProvider> +); + +const defaultIdentity = { id: '' }; + +const authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkAuth: () => Promise.resolve(), + checkError: () => Promise.resolve(), + getPermissions: () => Promise.resolve(), + getIdentity: () => Promise.resolve(defaultIdentity), +}; + +export const WithAuth = () => ( + <Wrapper> + <AuthContext.Provider value={authProvider}> + <AppBar /> + </AuthContext.Provider> + </Wrapper> +); + +export const WithAuthIdentity = () => ( + <Wrapper> + <AuthContext.Provider + value={{ + ...authProvider, + getIdentity: () => + Promise.resolve({ + id: '123', + fullName: 'Jane Doe', + avatar: + 'data:image/jpeg;base64,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', + }), + }} + > + <AppBar /> + </AuthContext.Provider> + </Wrapper> +); + +export const Toolbar = () => ( + <Wrapper> + <AppBar + toolbar={ + <Box + display="flex" + justifyContent="space-between" + alignItems="center" + > + <Box mr={1}>Custom toolbar</Box> + <Box mr={1}>with</Box> + <Box mr={1}>multiple</Box> + <Box mr={1}>elements</Box> + </Box> + } + /> + </Wrapper> +); + +export const UserMenuFalse = () => ( + <Wrapper> + <AuthContext.Provider value={authProvider}> + <AppBar userMenu={false} /> + </AuthContext.Provider> + </Wrapper> +); + +export const UserMenuCustom = () => ( + <Wrapper> + <AppBar userMenu={<Box>User menu</Box>} /> + </Wrapper> +); + +const SettingsMenuItem = () => { + const { onClose } = useUserMenu(); + return ( + <MenuItem onClick={onClose}> + <ListItemIcon> + <SettingsIcon fontSize="small" /> + </ListItemIcon> + <ListItemText>Customize</ListItemText> + </MenuItem> + ); +}; + +export const UserMenuElements = () => ( + <Wrapper> + <AppBar + userMenu={ + <UserMenu> + <SettingsMenuItem /> + <Logout /> + </UserMenu> + } + /> + </Wrapper> +); + +export const Complete = () => ( + <Wrapper> + <AuthContext.Provider + value={{ + ...authProvider, + getIdentity: () => + Promise.resolve({ + id: '123', + fullName: 'Jane Doe', + avatar: + 'data:image/jpeg;base64,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', + }), + }} + > + <I18nContextProvider + value={{ + getLocale: () => 'en', + getLocales: () => [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'Français' }, + ], + translate: (x, options) => options?._ ?? x, + changeLocale: () => Promise.resolve(), + }} + > + <AppBar + userMenu={ + <UserMenu> + <SettingsMenuItem /> + <Logout /> + </UserMenu> + } + toolbar={ + <> + <LocalesMenuButton /> + <ToggleThemeButton + darkTheme={{ palette: { mode: 'dark' } }} + lightTheme={{ palette: { mode: 'light' } }} + /> + <RefreshIconButton /> + </> + } + /> + <Title title='Post "Lorem Ipsum Sic Dolor amet"' /> + </I18nContextProvider> + </AuthContext.Provider> + </Wrapper> +); + +export const WithSearch = () => ( + <Wrapper> + <AppBar> + <TitlePortal /> + <TextField + name="search" + variant="outlined" + size="small" + placeholder="Search" + sx={{ '& .MuiInputBase-root': { color: 'inherit' } }} + /> + </AppBar> + </Wrapper> +); + +export const Children = () => ( + <Wrapper> + <AppBar> + <TitlePortal /> + <ToggleThemeButton + darkTheme={{ palette: { mode: 'dark' } }} + lightTheme={{ palette: { mode: 'light' } }} + /> + <Title title="Custom title" /> + </AppBar> + </Wrapper> +); diff --git a/packages/ra-ui-materialui/src/layout/AppBar.tsx b/packages/ra-ui-materialui/src/layout/AppBar.tsx index 67581f228ab..063db0d638a 100644 --- a/packages/ra-ui-materialui/src/layout/AppBar.tsx +++ b/packages/ra-ui-materialui/src/layout/AppBar.tsx @@ -7,7 +7,6 @@ import { AppBar as MuiAppBar, AppBarProps as MuiAppBarProps, Toolbar, - Typography, useMediaQuery, Theme, } from '@mui/material'; @@ -17,6 +16,7 @@ import { SidebarToggleButton } from './SidebarToggleButton'; import { LoadingIndicator } from './LoadingIndicator'; import { UserMenu } from './UserMenu'; import { HideOnScroll } from './HideOnScroll'; +import { TitlePortal } from './TitlePortal'; import { LocalesMenuButton } from '../button'; /** @@ -26,47 +26,35 @@ import { LocalesMenuButton } from '../button'; * @param {ReactNode} props.children React node/s to be rendered as children of the AppBar * @param {string} props.className CSS class applied to the MuiAppBar component * @param {string} props.color The color of the AppBar - * @param {boolean} props.open State of the <Admin/> Sidebar * @param {Element | boolean} props.userMenu A custom user menu component for the AppBar. <UserMenu/> component by default. Pass false to disable. * - * @example + * @example // add a custom button to the AppBar * - * const MyAppBar = props => { - - * return ( - * <AppBar {...props}> - * <Typography - * variant="h6" - * color="inherit" - * className={classes.title} - * id="react-admin-title" - * /> - * </AppBar> - * ); - *}; + * const MyAppBar = () => ( + * <AppBar> + * <TitlePortal /> + * <MyCustomButton /> + * </AppBar> + * ); * - * @example Without a user menu + * @example // without a user menu * - * const MyAppBar = props => { - - * return ( - * <AppBar {...props} userMenu={false} /> - * ); - *}; + * const MyAppBar = () => <AppBar userMenu={false} />; */ export const AppBar: FC<AppBarProps> = memo(props => { const { + alwaysOn, children, className, color = 'secondary', open, title, + toolbar = defaultToolbarElement, userMenu = DefaultUserMenu, - container: Container = HideOnScroll, + container: Container = alwaysOn ? 'div' : HideOnScroll, ...rest } = props; - const locales = useLocales(); const isXSmall = useMediaQuery<Theme>(theme => theme.breakpoints.down('sm') ); @@ -85,19 +73,11 @@ export const AppBar: FC<AppBarProps> = memo(props => { > <SidebarToggleButton className={AppBarClasses.menuButton} /> {Children.count(children) === 0 ? ( - <Typography - variant="h6" - color="inherit" - className={AppBarClasses.title} - id="react-admin-title" - /> + <TitlePortal className={AppBarClasses.title} /> ) : ( children )} - {locales && locales.length > 1 ? ( - <LocalesMenuButton /> - ) : null} - <LoadingIndicator /> + {toolbar} {typeof userMenu === 'boolean' ? ( userMenu === true ? ( <UserMenu /> @@ -111,7 +91,20 @@ export const AppBar: FC<AppBarProps> = memo(props => { ); }); +const DefaultToolbar = () => { + const locales = useLocales(); + return ( + <> + {locales && locales.length > 1 ? <LocalesMenuButton /> : null} + <LoadingIndicator /> + </> + ); +}; + +const defaultToolbarElement = <DefaultToolbar />; + AppBar.propTypes = { + alwaysOn: PropTypes.bool, children: PropTypes.node, className: PropTypes.string, color: PropTypes.oneOf([ @@ -122,18 +115,28 @@ AppBar.propTypes = { 'transparent', ]), container: ComponentPropType, - // @deprecated + /** + * @deprecated + */ open: PropTypes.bool, + toolbar: PropTypes.element, userMenu: PropTypes.oneOfType([PropTypes.element, PropTypes.bool]), }; const DefaultUserMenu = <UserMenu />; export interface AppBarProps extends Omit<MuiAppBarProps, 'title'> { + alwaysOn?: boolean; container?: React.ElementType<any>; - // @deprecated + /** + * @deprecated injected by Layout but not used by this AppBar + */ open?: boolean; + /** + * @deprecated injected by Layout but not used by this AppBar + */ title?: string | JSX.Element; + toolbar?: JSX.Element; userMenu?: JSX.Element | boolean; } @@ -161,10 +164,5 @@ const StyledAppBar = styled(MuiAppBar, { [`& .${AppBarClasses.menuButton}`]: { marginRight: '0.2em', }, - [`& .${AppBarClasses.title}`]: { - flex: 1, - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden', - }, + [`& .${AppBarClasses.title}`]: {}, })); diff --git a/packages/ra-ui-materialui/src/layout/Menu.stories.tsx b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx index a084f4703c2..f06569e27d2 100644 --- a/packages/ra-ui-materialui/src/layout/Menu.stories.tsx +++ b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx @@ -11,16 +11,12 @@ export default { title: 'ra-ui-materialui/layout/Menu' }; const resources = ['Posts', 'Comments', 'Tags', 'Users', 'Orders', 'Reviews']; -const DemoAppBar = props => { +const DemoAppBar = () => { const darkTheme = createTheme({ palette: { mode: 'dark' }, }); return ( - <AppBar - {...props} - elevation={1} - sx={{ flexDirection: 'row', flexWrap: 'nowrap' }} - > + <AppBar elevation={1} sx={{ flexDirection: 'row', flexWrap: 'nowrap' }}> <Box sx={{ flex: '1 1 100%' }}> <SidebarToggleButton /> </Box> diff --git a/packages/ra-ui-materialui/src/layout/PageTitleConfigurable.tsx b/packages/ra-ui-materialui/src/layout/PageTitleConfigurable.tsx index 1f04422d613..547df01ab7d 100644 --- a/packages/ra-ui-materialui/src/layout/PageTitleConfigurable.tsx +++ b/packages/ra-ui-materialui/src/layout/PageTitleConfigurable.tsx @@ -28,6 +28,11 @@ export const PageTitleConfigurable = ({ preferenceKey, ...props }) => { <Configurable editor={<PageTitleEditor />} preferenceKey={preferenceKey || `${pathname}.title`} + sx={{ + '&.RaConfigurable-editMode': { + margin: '2px', + }, + }} > <PageTitle {...props} /> </Configurable> diff --git a/packages/ra-ui-materialui/src/layout/Title.tsx b/packages/ra-ui-materialui/src/layout/Title.tsx index f281a55f810..5bcdd899bc7 100644 --- a/packages/ra-ui-materialui/src/layout/Title.tsx +++ b/packages/ra-ui-materialui/src/layout/Title.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useEffect, useState } from 'react'; import { ReactElement } from 'react'; import { createPortal } from 'react-dom'; import PropTypes from 'prop-types'; @@ -8,10 +9,21 @@ import { PageTitleConfigurable } from './PageTitleConfigurable'; export const Title = (props: TitleProps) => { const { defaultTitle, title, preferenceKey, ...rest } = props; - const container = + const [container, setContainer] = useState(() => typeof document !== 'undefined' ? document.getElementById('react-admin-title') - : null; + : null + ); + + // on first mount, we don't have the container yet, so we wait for it + useEffect(() => { + setContainer(container => { + if (container) return container; + return typeof document !== 'undefined' + ? document.getElementById('react-admin-title') + : null; + }); + }, []); if (!container) return null; diff --git a/packages/ra-ui-materialui/src/layout/TitlePortal.tsx b/packages/ra-ui-materialui/src/layout/TitlePortal.tsx new file mode 100644 index 00000000000..0db820155b6 --- /dev/null +++ b/packages/ra-ui-materialui/src/layout/TitlePortal.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { Typography } from '@mui/material'; + +export const TitlePortal = ({ className }: { className?: string }) => ( + <Typography + flex="1" + textOverflow="ellipsis" + whiteSpace="nowrap" + overflow="hidden" + variant="h6" + color="inherit" + id="react-admin-title" + className={className} + /> +); diff --git a/packages/ra-ui-materialui/src/layout/UserMenu.tsx b/packages/ra-ui-materialui/src/layout/UserMenu.tsx index 5dd6f0e4bb4..b247a388564 100644 --- a/packages/ra-ui-materialui/src/layout/UserMenu.tsx +++ b/packages/ra-ui-materialui/src/layout/UserMenu.tsx @@ -103,9 +103,9 @@ export const UserMenu = (props: UserMenuProps) => { {identity.fullName} </Button> ) : ( - <Tooltip title={label && translate(label, { _: label })}> + <Tooltip title={label && translate(label, { _: 'Profile' })}> <IconButton - aria-label={label && translate(label, { _: label })} + aria-label={label && translate(label, { _: 'Profile' })} aria-owns={open ? 'menu-appbar' : null} aria-haspopup={true} color="inherit" @@ -167,6 +167,7 @@ const Root = styled('div', { })(({ theme }) => ({ [`& .${UserMenuClasses.userButton}`]: { textTransform: 'none', + marginInlineStart: theme.spacing(0.5), }, [`& .${UserMenuClasses.avatar}`]: { diff --git a/packages/ra-ui-materialui/src/layout/index.ts b/packages/ra-ui-materialui/src/layout/index.ts index e3bb8892507..14daa147cca 100644 --- a/packages/ra-ui-materialui/src/layout/index.ts +++ b/packages/ra-ui-materialui/src/layout/index.ts @@ -20,6 +20,7 @@ export * from './Sidebar'; export * from './SidebarToggleButton'; export * from './Theme'; export * from './Title'; +export * from './TitlePortal'; export * from './TopToolbar'; export * from './UserMenu'; export * from './UserMenuContext'; diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx index cd366ff589b..84617501e3c 100644 --- a/packages/ra-ui-materialui/src/list/List.tsx +++ b/packages/ra-ui-materialui/src/list/List.tsx @@ -119,9 +119,6 @@ List.propTypes = { // the props managed by react-admin disableSyncWithLocation: PropTypes.bool, hasCreate: PropTypes.bool, - hasEdit: PropTypes.bool, - hasList: PropTypes.bool, - hasShow: PropTypes.bool, resource: PropTypes.string, }; diff --git a/packages/ra-ui-materialui/src/list/filter/FilterList.stories.tsx b/packages/ra-ui-materialui/src/list/filter/FilterList.stories.tsx index 480628e0d7b..300278167ec 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterList.stories.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterList.stories.tsx @@ -77,12 +77,83 @@ export const Basic = () => { ); }; +export const Cumulative = () => { + const listContext = useList({ + data: [ + { id: 1, title: 'Article test', category: 'tests' }, + { id: 2, title: 'Article news', category: 'news' }, + { id: 3, title: 'Article deals', category: 'deals' }, + { id: 4, title: 'Article tutorials', category: 'tutorials' }, + ], + filter: { + category: ['tutorials', 'news'], + }, + }); + const isSelected = (value, filters) => { + const category = filters.category || []; + return category.includes(value.category); + }; + + const toggleFilter = (value, filters) => { + const category = filters.category || []; + return { + ...filters, + category: category.includes(value.category) + ? category.filter(v => v !== value.category) + : [...category, value.category], + }; + }; + return ( + <ListContextProvider value={listContext}> + <Card + sx={{ + width: '17em', + margin: '1em', + }} + > + <CardContent> + <FilterList label="Categories" icon={<CategoryIcon />}> + <FilterListItem + label="Tests" + value={{ category: 'tests' }} + isSelected={isSelected} + toggleFilter={toggleFilter} + /> + <FilterListItem + label="News" + value={{ category: 'news' }} + isSelected={isSelected} + toggleFilter={toggleFilter} + /> + <FilterListItem + label="Deals" + value={{ category: 'deals' }} + isSelected={isSelected} + toggleFilter={toggleFilter} + /> + <FilterListItem + label="Tutorials" + value={{ category: 'tutorials' }} + isSelected={isSelected} + toggleFilter={toggleFilter} + /> + </FilterList> + </CardContent> + </Card> + <FilterValue /> + </ListContextProvider> + ); +}; + const FilterValue = () => { const { filterValues } = useListContext(); return ( <Box sx={{ margin: '1em' }}> <Typography>Filter values:</Typography> <pre>{JSON.stringify(filterValues, null, 2)}</pre> + <pre style={{ display: 'none' }}> + {JSON.stringify(filterValues)} + </pre> </Box> ); }; diff --git a/packages/ra-ui-materialui/src/list/filter/FilterListItem.spec.tsx b/packages/ra-ui-materialui/src/list/filter/FilterListItem.spec.tsx index 85ddf678061..632008eb3e1 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterListItem.spec.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterListItem.spec.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import expect from 'expect'; -import { render, cleanup } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { ListContextProvider, ListControllerResult } from 'ra-core'; import { FilterListItem } from './FilterListItem'; +import { Cumulative } from './FilterList.stories'; const defaultListContext: ListControllerResult = { data: [], @@ -32,19 +33,17 @@ const defaultListContext: ListControllerResult = { }; describe('<FilterListItem/>', () => { - afterEach(cleanup); - it("should display the item label when it's a string", () => { - const { queryByText } = render( + render( <ListContextProvider value={defaultListContext}> <FilterListItem label="Foo" value={{ foo: 'bar' }} /> </ListContextProvider> ); - expect(queryByText('Foo')).not.toBeNull(); + expect(screen.queryByText('Foo')).not.toBeNull(); }); it("should display the item label when it's an element", () => { - const { queryByTestId } = render( + render( <ListContextProvider value={defaultListContext}> <FilterListItem label={<span data-testid="123">Foo</span>} @@ -52,42 +51,48 @@ describe('<FilterListItem/>', () => { /> </ListContextProvider> ); - expect(queryByTestId('123')).not.toBeNull(); + expect(screen.queryByTestId('123')).not.toBeNull(); }); it('should not appear selected if filterValues is empty', () => { - const { getByText } = render( + render( <ListContextProvider value={defaultListContext}> <FilterListItem label="Foo" value={{ foo: 'bar' }} /> </ListContextProvider> ); - expect(getByText('Foo').parentElement?.dataset.selected).toBe('false'); + expect(screen.getByText('Foo').parentElement?.dataset.selected).toBe( + 'false' + ); }); it('should not appear selected if filterValues does not contain value', () => { - const { getByText } = render( + render( <ListContextProvider value={{ ...defaultListContext, filterValues: { bar: 'baz' } }} > <FilterListItem label="Foo" value={{ foo: 'bar' }} /> </ListContextProvider> ); - expect(getByText('Foo').parentElement?.dataset.selected).toBe('false'); + expect(screen.getByText('Foo').parentElement?.dataset.selected).toBe( + 'false' + ); }); it('should appear selected if filterValues is equal to value', () => { - const { getByText } = render( + render( <ListContextProvider value={{ ...defaultListContext, filterValues: { foo: 'bar' } }} > <FilterListItem label="Foo" value={{ foo: 'bar' }} /> </ListContextProvider> ); - expect(getByText('Foo').parentElement?.dataset.selected).toBe('true'); + expect(screen.getByText('Foo').parentElement?.dataset.selected).toBe( + 'true' + ); }); it('should appear selected if filterValues is equal to value for nested filters', () => { - const { getByText } = render( + render( <ListContextProvider value={{ ...defaultListContext, @@ -126,11 +131,13 @@ describe('<FilterListItem/>', () => { /> </ListContextProvider> ); - expect(getByText('Foo').parentElement?.dataset.selected).toBe('true'); + expect(screen.getByText('Foo').parentElement?.dataset.selected).toBe( + 'true' + ); }); it('should appear selected if filterValues contains value', () => { - const { getByText } = render( + render( <ListContextProvider value={{ ...defaultListContext, @@ -140,6 +147,38 @@ describe('<FilterListItem/>', () => { <FilterListItem label="Foo" value={{ foo: 'bar' }} /> </ListContextProvider> ); - expect(getByText('Foo').parentElement?.dataset.selected).toBe('true'); + expect(screen.getByText('Foo').parentElement?.dataset.selected).toBe( + 'true' + ); + }); + + it('should allow to customize isSelected and toggleFilter', () => { + const { container } = render(<Cumulative />); + + expect(getSelectedItemsLabels(container)).toEqual([ + 'News', + 'Tutorials', + ]); + screen.getByText(JSON.stringify({ category: ['tutorials', 'news'] })); + + screen.getByText('News').click(); + + expect(getSelectedItemsLabels(container)).toEqual(['Tutorials']); + screen.getByText(JSON.stringify({ category: ['tutorials'] })); + + screen.getByText('Tutorials').click(); + + expect(getSelectedItemsLabels(container)).toEqual([]); + expect(screen.getAllByText(JSON.stringify({})).length).toBe(2); + + screen.getByText('Tests').click(); + + expect(getSelectedItemsLabels(container)).toEqual(['Tests']); + screen.getByText(JSON.stringify({ category: ['tests'] })); }); }); + +const getSelectedItemsLabels = (container: HTMLElement) => + Array.from( + container.querySelectorAll<HTMLElement>('[data-selected="true"]') + ).map(item => item.textContent); diff --git a/packages/ra-ui-materialui/src/list/filter/FilterListItem.tsx b/packages/ra-ui-materialui/src/list/filter/FilterListItem.tsx index 482ab6cc877..f5e9f6ff7bc 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterListItem.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterListItem.tsx @@ -10,7 +10,12 @@ import { ListItemSecondaryAction, } from '@mui/material'; import CancelIcon from '@mui/icons-material/CancelOutlined'; -import { useTranslate, useListFilterContext, shallowEqual } from 'ra-core'; +import { + useTranslate, + useListFilterContext, + shallowEqual, + useEvent, +} from 'ra-core'; import matches from 'lodash/matches'; import pickBy from 'lodash/pickBy'; @@ -141,36 +146,26 @@ const arePropsEqual = (prevProps, nextProps) => * ); */ export const FilterListItem = memo((props: FilterListItemProps) => { - const { label, value, ...rest } = props; + const { + label, + value, + isSelected: getIsSelected = DefaultIsSelected, + toggleFilter: userToggleFilter = DefaultToggleFilter, + ...rest + } = props; const { filterValues, setFilters } = useListFilterContext(); const translate = useTranslate(); + const toggleFilter = useEvent(userToggleFilter); - const isSelected = matches( - pickBy(value, val => typeof val !== 'undefined') - )(filterValues); + // We can't wrap this function with useEvent as it is called in the render phase + const isSelected = getIsSelected(value, filterValues); - const addFilter = () => { - setFilters({ ...filterValues, ...value }, null, false); - }; - - const removeFilter = () => { - const keysToRemove = Object.keys(value); - const filters = Object.keys(filterValues).reduce( - (acc, key) => - keysToRemove.includes(key) - ? acc - : { ...acc, [key]: filterValues[key] }, - {} - ); - - setFilters(filters, null, false); - }; - - const toggleFilter = () => (isSelected ? removeFilter() : addFilter()); + const handleClick = () => + setFilters(toggleFilter(value, filterValues), null, false); return ( <StyledListItem - onClick={toggleFilter} + onClick={handleClick} selected={isSelected} disablePadding {...rest} @@ -192,7 +187,7 @@ export const FilterListItem = memo((props: FilterListItemProps) => { <ListItemSecondaryAction onClick={event => { event.stopPropagation(); - toggleFilter(); + handleClick(); }} > <IconButton size="small"> @@ -205,6 +200,28 @@ export const FilterListItem = memo((props: FilterListItemProps) => { ); }, arePropsEqual); +const DefaultIsSelected = (value, filters) => + matches(pickBy(value, val => typeof val !== 'undefined'))(filters); + +const DefaultToggleFilter = (value, filters) => { + const isSelected = matches( + pickBy(value, val => typeof val !== 'undefined') + )(filters); + + if (isSelected) { + const keysToRemove = Object.keys(value); + return Object.keys(filters).reduce( + (acc, key) => + keysToRemove.includes(key) + ? acc + : { ...acc, [key]: filters[key] }, + {} + ); + } + + return { ...filters, ...value }; +}; + const PREFIX = 'RaFilterListItem'; export const FilterListItemClasses = { @@ -228,4 +245,6 @@ const StyledListItem = styled(ListItem, { export interface FilterListItemProps extends Omit<ListItemProps, 'value'> { label: string | ReactElement; value: any; + toggleFilter?: (value: any, filters: any) => any; + isSelected?: (value: any, filters: any) => boolean; } diff --git a/packages/ra-ui-materialui/src/list/filter/SavedQueriesList.stories.tsx b/packages/ra-ui-materialui/src/list/filter/SavedQueriesList.stories.tsx index 6ab1906d544..c088d8c7e71 100644 --- a/packages/ra-ui-materialui/src/list/filter/SavedQueriesList.stories.tsx +++ b/packages/ra-ui-materialui/src/list/filter/SavedQueriesList.stories.tsx @@ -4,7 +4,6 @@ import merge from 'lodash/merge'; import { Admin, AppBar, - AppBarProps, defaultTheme, Layout, LayoutProps, @@ -12,12 +11,13 @@ import { Datagrid, List, TextField, + TitlePortal, NumberField, DateField, FilterList, FilterListItem, } from 'react-admin'; -import { Card, CardContent, Box, Typography, styled } from '@mui/material'; +import { Card, CardContent, styled } from '@mui/material'; import BusinessIcon from '@mui/icons-material/Business'; import DateRangeIcon from '@mui/icons-material/DateRange'; import polyglotI18nProvider from 'ra-i18n-polyglot'; @@ -200,11 +200,9 @@ const darkTheme: RaThemeOptions = { }, }; -const MyAppBar = (props: AppBarProps) => ( - <AppBar {...props}> - <Box flex="1"> - <Typography variant="h6" id="react-admin-title"></Typography> - </Box> +const MyAppBar = () => ( + <AppBar> + <TitlePortal /> <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> <LocalesMenuButton languages={[ diff --git a/packages/ra-ui-materialui/src/preferences/Configurable.stories.tsx b/packages/ra-ui-materialui/src/preferences/Configurable.stories.tsx index 1daf37730eb..1be6f6d2f31 100644 --- a/packages/ra-ui-materialui/src/preferences/Configurable.stories.tsx +++ b/packages/ra-ui-materialui/src/preferences/Configurable.stories.tsx @@ -49,7 +49,7 @@ const TextBlock = ({ ); }; -const TextBlocWithPreferences = props => { +const TextBlockWithPreferences = props => { const [color] = usePreference('color', '#ffffff'); return <TextBlock color={color} {...props} />; }; @@ -75,7 +75,7 @@ const ConfigurableTextBlock = ({ ...props }: any) => ( <Configurable editor={<TextBlockEditor />} preferenceKey={preferenceKey}> - <TextBlocWithPreferences {...props} /> + <TextBlockWithPreferences {...props} /> </Configurable> );