diff --git a/docs/Admin.md b/docs/Admin.md index a259e44a3e1..8ddf8404445 100644 --- a/docs/Admin.md +++ b/docs/Admin.md @@ -3,14 +3,20 @@ layout: default title: "The Admin Component" --- -# The `` Component +# `` -The `` component creates an application with its own state, routing, and controller logic. `` requires only a `dataProvider` prop, and at least one child `` to work: +The `` component is the root component of a react-admin app. It allows to configure the application adapters and UI. + +`` creates a series of context providers to allow its children to access the app configuration. It renders the main routes and layout. It delegates the rendering of the content area to its `` children. + +![Admin Component](./img/dense.webp) + +## Usage + +`` requires only a `dataProvider` prop, and at least one child `` to work. Here is the most basic example: ```jsx // in src/App.js -import * as React from "react"; - import { Admin, Resource } from 'react-admin'; import simpleRestProvider from 'ra-data-simple-rest'; @@ -27,31 +33,156 @@ export default App; `` children can be [``](./Resource.md) and [``](./CustomRoutes.md) elements. +In most apps, you need to pass more props to ``. Here is a more complete example taken from [the e-commerce demo](https://marmelab.com/react-admin-demo/): + +{% raw %} +```jsx +// in src/App.js +import { Admin, Resource, CustomRoutes } from 'react-admin'; +import { Route } from "react-router-dom"; + +import { dataProvider, authProvider, i18nProvider } from './providers'; +import { Layout } from './layout'; +import { Dashboard } from './dashboard'; +import { Login } from './login'; +import { lightTheme, darkTheme } from './themes'; +import { CustomerList, CustomerEdit } from './customers'; +import { OrderList, OrderEdit } from './orders'; +import { InvoiceList, InvoiceEdit } from './invoices'; +import { ProductList, ProductEdit, ProductCreate } from './products'; +import { CategoryList, CategoryEdit, CategoryCreate } from './categories'; +import { ReviewList } from './reviews'; +import { Segments } from './segments'; + +const App = () => ( + + + + + + + + + } /> + + +); +``` +{% endraw %} + +To make the main app component more concise, a good practice is to move the resources props to separate files. For instance, the previous example can be rewritten as: + +```jsx +// in src/App.js +import { Admin, Resource, CustomRoutes } from 'react-admin'; +import { Route } from "react-router-dom"; + +import { dataProvider, authProvider, i18nProvider } from './providers'; +import { Layout } from './layout'; +import { Dashboard } from './dashboard'; +import { Login } from './login'; +import { lightTheme, darkTheme } from './themes'; +import customers from './customers'; +import orders from './orders'; +import invoices from './invoices'; +import products from './products'; +import categories from './categories'; +import reviews from './reviews'; +import { Segments } from './segments'; + + +const App = () => ( + + + + + + + + + } /> + + +); +``` + +## Props + +Three main props lets you configure the core features of the `` component: + +- [`dataProvider`](#dataprovider) for data fetching +- [`authProvider`](#authprovider) for security and permissions +- [`i18nProvider`](#i18nprovider) for translations and internationalization + Here are all the props accepted by the component: -- [`dataProvider`](#dataprovider) -- [`authProvider`](#authprovider) -- [`i18nProvider`](#i18nprovider) -- [`queryClient`](#queryclient) -- [`title`](#title) -- [`dashboard`](#dashboard) -- [`disableTelemetry`](#disabletelemetry) -- [`catchAll`](#catchall) -- [`menu`](#menu) -- [`theme`](#theme) -- [`layout`](#layout) -- [`loginPage`](#loginpage) -- [`authCallbackPage`](#authcallbackpage) -- [`history`](#history) -- [`basename`](#basename) -- [`ready`](#ready) -- [`requireAuth`](#requireauth) -- [`store`](#store) -- [`notification`](#notification) +| Prop | Required | Type | Default | Description | +|------------------- |----------|----------------|----------------|----------------------------------------------------------| +| `dataProvider` | Required | `DataProvider` | - | The data provider for fetching resources | +| `children` | Required | `ReactNode` | - | The routes to render | +| `authCallbackPage` | Optional | `Component` | `AuthCallback` | The content of the authentication callback page | +| `authProvider` | Optional | `AuthProvider` | - | The authentication provider for security and permissions | +| `basename` | Optional | `string` | - | The base path for all URLs | +| `catchAll` | Optional | `Component` | `NotFound` | The fallback component for unknown routes | +| `dashboard` | Optional | `Component` | - | The content of the dashboard page | +| `darkTheme` | Optional | `object` | - | The dark theme configuration | +| `defaultTheme` | Optional | `boolean` | `false` | Flag to default to the light theme | +| `disableTelemetry` | Optional | `boolean` | `false` | Set to `true` to disable telemetry collection | +| `i18nProvider` | Optional | `I18NProvider` | - | The internationalization provider for translations | +| `layout` | Optional | `Component` | `Layout` | The content of the layout | +| `loginPage` | Optional | `Component` | `LoginPage` | The content of the login page | +| `notification` | Optional | `Component` | `Notification` | The notification component | +| `queryClient` | Optional | `QueryClient` | - | The react-query client | +| `ready` | Optional | `Component` | `Ready` | The content of the ready page | +| `requireAuth` | Optional | `boolean` | `false` | Flag to require authentication for all routes | +| `store` | Optional | `Store` | - | The Store for managing user preferences | +| `theme` | Optional | `object` | - | The main (light) theme configuration | +| `title` | Optional | `string` | - | The error page title | + ## `dataProvider` -The only required prop, it must be an object with the following methods returning a promise: +`dataProvider` is the only required prop. It must be an object allowing to communicate with the API. React-admin uses the data provider everywhere it needs to fetch or save data. + +In many cases, you won't have to write a data provider, as one of the [50+ existing data providers](./DataProviderList.md) will probably fit your needs. For instance, if your API is REST-based, you can use the [Simple REST Data Provider](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-simple-rest) as follows: + +```jsx +// in src/App.js +import simpleRestProvider from 'ra-data-simple-rest'; +import { Admin, Resource } from 'react-admin'; + +import { PostList } from './posts'; + +const dataProvider = simpleRestProvider('http://path.to.my.api/'); + +const App = () => ( + + + +); +``` + +If you need to write your own, the data provider must have the following methods, all returning a promise: ```jsx const dataProvider = { @@ -67,13 +198,116 @@ const dataProvider = { } ``` -Check [the Data Provider documentation](./DataProviderIntroduction.md) for more details. +Check the [Writing a Data Provider](./DataProviderWriting.md) chapter for detailed instructions on how to write a data provider for your API. + +The `dataProvider` is also the ideal place to add custom HTTP headers, handle file uploads, map resource names to API endpoints, pass credentials to the API, put business logic, reformat API errors, etc. Check [the Data Provider documentation](./DataProviderIntroduction.md) for more details. + +## `children` + +The `` component expects to receive [``](./Resource.md) and [``](./CustomRoutes.md) elements as children. They define the routes of the application. -The `dataProvider` is also the ideal place to add custom HTTP headers, authentication, etc. The [Data Providers Backends ](./DataProviderList.md) chapters lists available data providers, and you can learn how to build your own in the [Writing a Data Provider](./DataProviderWriting.md) chapter. +For instance: + +{% raw %} +```jsx +const App = () => ( + + + + + + + + + } /> + + +); +``` +{% endraw %} + +With these children, the `` component will generate the following routes: + +- `/`: the dashboard +- `/customers`: the customer list +- `/customers/:id`: the customer edit page +- `/orders`: the order list +- `/orders/:id`: the order edit page +- `/invoices`: the invoice list +- `/products`: the product list +- `/products/create`: the product creation page +- `/products/:id`: the product edit page +- `/categories`: the category list +- `/categories/create`: the category creation page +- `/categories/:id`: the category edit page +- `/reviews`: the review list +- `/segments`: the segments page + +## `authCallbackPage` + +React-admin apps contain a special route called `/auth-callback` to let external authentication providers (like Auth0, Cognito, OIDC servers) redirect users after login. This route renders the `AuthCallback` component by default, which in turn calls `authProvider.handleCallback()`. + +If you need a different behavior for this route, you can render a custom component by passing it as the `authCallbackPage` prop. + +```jsx +import MyAuthCallbackPage from './MyAuthCallbackPage'; + +const App = () => ( + + ... + +); +``` + +**Note**: You should seldom use this option, even when using an external authentication provider. Since you can already define the `/auth-callback` route controller via `authProvider.handleCallback()`, the `authCallbackPage` prop is only useful when you need the user's feedback after they logged in. + +You can also disable the `/auth-callback` route altogether by passing `authCallbackPage={false}`. + +See The [Authentication documentation](./Authentication.md#using-external-authentication-providers) for more details. ## `authProvider` -The `authProvider` prop expect an object with 6 methods, each returning a Promise, to control the authentication strategy: +The `authProvider` is responsible for managing authentication and permissions, usually based on an authentication backend. React-admin uses it to check for authentication status, redirect to the login page when the user is not authenticated, check for permissions, display the user identity, and more. + +If you use a standard authentication strategy, you can use one of the [existing auth providers](./AuthProviderList.md). For instance, to use [Auth0](https://auth0.com/), you can use [`ra-auth-auth0`](https://github.com/marmelab/ra-auth-auth0): + +```jsx +// in src/App.tsx +import React, { useEffect, useRef, useState } from 'react'; +import { Admin, Resource } from 'react-admin'; +import { Auth0AuthProvider } from 'ra-auth-auth0'; +import { Auth0Client } from '@auth0/auth0-spa-js'; +import dataProvider from './dataProvider'; +import posts from './posts'; + +const auth0 = new Auth0Client({ + domain: import.meta.env.VITE_AUTH0_DOMAIN, + clientId: import.meta.env.VITE_AUTH0_CLIENT_ID, + cacheLocation: 'localstorage', + authorizationParams: { + audience: import.meta.env.VITE_AUTH0_AUDIENCE, + }, +}); + +const authProvider = Auth0AuthProvider(auth0, { + loginRedirectUri: import.meta.env.VITE_LOGIN_REDIRECT_URL, + logoutRedirectUri: import.meta.env.VITE_LOGOUT_REDIRECT_URL, +}); + +const App = () => { + return ( + + + + ); +}; +export default App; +``` + +If your authentication backend isn't supported, you'll have to [write your own `authProvider`](./AuthProviderWriting.md). It's an object with 6 methods, each returning a Promise: ```jsx const authProvider = { @@ -92,121 +326,68 @@ const App = () => ( ); ``` -The [Auth Provider documentation](./Authentication.md) explains how to implement these functions in detail. +The Auth Provider also lets you configure redirections after login/logout, anonymous access, refresh tokens, roles and user groups. The [Auth Provider documentation](./Authentication.md) explains how to implement these functions in detail. -## `i18nProvider` +## `basename` -The `i18nProvider` props let you translate the GUI. For instance, to switch the UI to French instead of the default English: +Use this prop to make all routes and links in your Admin relative to a "base" portion of the URL pathname that they all share. This is required when using the [`BrowserHistory`](https://github.com/remix-run/history/blob/main/docs/api-reference.md#createbrowserhistory) to serve the application under a sub-path of your domain (for example https://marmelab.com/ra-enterprise-demo), or when embedding react-admin inside a single-page app with its own routing. ```jsx -// in src/i18nProvider.js -import polyglotI18nProvider from 'ra-i18n-polyglot'; -import fr from 'ra-language-french'; - -export const i18nProvider = polyglotI18nProvider(() => fr, 'fr'); - -// in src/App.js -import { i18nProvider } from './i18nProvider'; +import { Admin } from 'react-admin'; +import { createBrowserHistory } from 'history'; +const history = createBrowserHistory(); const App = () => ( - - {/* ... */} + + ... ); ``` -The [Translation Documentation](./Translation.md) details this process. +See [Using React-Admin In A Sub Path](./Routing.md#using-react-admin-in-a-sub-path) for more usage examples. -## `queryClient` +## `catchAll` -React-admin uses [react-query](https://react-query-v3.tanstack.com/) to fetch, cache and update data. Internally, the `` component creates a react-query [`QueryClient`](https://react-query-v3.tanstack.com/reference/QueryClient) on mount, using [react-query's "aggressive but sane" defaults](https://react-query-v3.tanstack.com/guides/important-defaults): +When users type URLs that don't match any of the children `` components, they see a default "Not Found" page. -* Queries consider cached data as stale -* Stale queries are refetched automatically in the background when: - * New instances of the query mount - * The window is refocused - * The network is reconnected - * The query is optionally configured with a refetch interval -* Query results that are no longer used in the current page are labeled as "inactive" and remain in the cache in case they are used again at a later time. -* By default, "inactive" queries are garbage collected after 5 minutes. -* Queries that fail are silently retried 3 times, with exponential backoff delay before capturing and displaying an error to the UI. -* Query results by default are structurally shared to detect if data has actually changed and if not, the data reference remains unchanged to better help with value stabilization with regards to `useMemo` and `useCallback`. +![Not Found](./img/not-found.png) -If you want to override the react-query default query and mutation default options, or use a specific client or mutation cache, you can create your own `QueryClient` instance and pass it to the `` prop: +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 Material UI's `` component, and [react-admin's `` component](./Title.md): ```jsx -import { Admin } from 'react-admin'; -import { QueryClient } from 'react-query'; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - structuralSharing: false, - }, - mutations: { - retryDelay: 10000, - }, - }, -}); +// in src/NotFound.js +import * as React from "react"; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import { Title } from 'react-admin'; -const App = () => ( - <Admin queryClient={queryClient} dataProvider={...}> - ... - </Admin> +export default () => ( + <Card> + <Title title="Not Found" /> + <CardContent> + <h1>404: Page not found</h1> + </CardContent> + </Card> ); ``` -To know which options you can pass to the `QueryClient` constructor, check the [react-query documentation](https://react-query-v3.tanstack.com/reference/QueryClient) and the [query options](https://react-query-v3.tanstack.com/reference/useQuery) and [mutation options](https://react-query-v3.tanstack.com/reference/useMutation) sections. - -The common settings that react-admin developers often overwrite are: - ```jsx -import { QueryClient } from 'react-query'; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - /** - * The time in milliseconds after data is considered stale. - * If set to `Infinity`, the data will never be considered stale. - */ - staleTime: 10000, - /** - * If `false`, failed queries will not retry by default. - * If `true`, failed queries will retry infinitely., failureCount: num - * If set to an integer number, e.g. 3, failed queries will retry until the failed query count meets that number. - * If set to a function `(failureCount, error) => boolean` failed queries will retry until the function returns false. - */ - retry: false, - /** - * If set to `true`, the query will refetch on window focus if the data is stale. - * If set to `false`, the query will not refetch on window focus. - * If set to `'always'`, the query will always refetch on window focus. - * If set to a function, the function will be executed with the latest data and query to compute the value. - * Defaults to `true`. - */ - refetchOnWindowFocus: false, - }, - }, -}); -``` - -## `title` +// in src/App.js +import * as React from "react"; +import { Admin } from 'react-admin'; +import simpleRestProvider from 'ra-data-simple-rest'; -On error pages, the header of an admin app uses 'React Admin' as the main app title. Use the `title` to customize it. +import NotFound from './NotFound'; -```jsx const App = () => ( - <Admin title="My Custom Admin" dataProvider={simpleRestProvider('http://path.to.my.api')}> + <Admin catchAll={NotFound} dataProvider={simpleRestProvider('http://path.to.my.api')}> // ... </Admin> ); ``` +**Tip**: If your custom `catchAll` component contains react-router `<Route>` components, this allows you to register new routes displayed within the react-admin layout easily. Note that these routes will match *after* all the react-admin resource routes have been tested. To add custom routes *before* the react-admin ones, and therefore override the default resource routes, see the [`custom pages`](./CustomRoutes.md) section instead. + ## `dashboard` 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 Material UI's `<Card>` component, and [react-admin's `<Title>` component](./Title.md) to set the title in the AppBar: @@ -242,147 +423,101 @@ const App = () => ( ![Custom home page](./img/dashboard.png) -## `disableTelemetry` - -In production, react-admin applications send an anonymous request on mount to a telemetry server operated by marmelab. You can see this request by looking at the Network tab of your browser DevTools: - -`https://react-admin-telemetry.marmelab.com/react-admin-telemetry` +## `darkTheme` -The only data sent to the telemetry server is the admin domain (e.g. "example.com") - no personal data is ever sent, and no cookie is included in the response. The react-admin team uses these domains to track the usage of the framework. +If you want to support both light and dark mode, you can provide a `darkTheme` in addition to the `theme` prop. The app will use the `darkTheme` by default for users who prefer the dark mode at the OS level, and users will be able to switch from light to dark mode using a new app bar button leveraging [the `<ToggleThemeButton>` component](./ToggleThemeButton.md). -You can opt out of telemetry by simply adding `disableTelemetry` to the `<Admin>` component: +<video controls autoplay muted loop> + <source src="./img/ToggleThemeButton.webm" type="video/webm"/> + Your browser does not support the video tag. +</video> ```jsx -// in src/App.js -import * as React from "react"; import { Admin } from 'react-admin'; +import { darkTheme, lightTheme } from './themes'; const App = () => ( - <Admin disableTelemetry> - // ... + <Admin + dataProvider={dataProvider} + theme={lightTheme} + darkTheme={darkTheme} + > + ... </Admin> ); ``` -## `catchAll` +**Tip**: To disable OS preference detection and always use one theme by default, see the [`defaultTheme`](#defaulttheme) prop. -When users type URLs that don't match any of the children `<Resource>` components, they see a default "Not Found" page. +## `defaultTheme` -![Not Found](./img/not-found.png) +If you provide both a `lightTheme` and a `darkTheme`, react-admin will choose the default theme to use for each user based on their OS preference. This means that users using dark mode will see the dark theme by default. Users can then switch to the other theme using [the `<ToggleThemeButton>` component](./ToggleThemeButton.md). -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 Material UI's `<Card>` component, and [react-admin's `<Title>` component](./Title.md): +If you prefer to always default to the light or the dark theme regardless of the user's OS preference, you can set the `defaultTheme` prop to either `light` or `dark`: ```jsx -// in src/NotFound.js -import * as React from "react"; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import { Title } from 'react-admin'; - -export default () => ( - <Card> - <Title title="Not Found" /> - <CardContent> - <h1>404: Page not found</h1> - </CardContent> - </Card> -); -``` - -```jsx -// in src/App.js -import * as React from "react"; import { Admin } from 'react-admin'; -import simpleRestProvider from 'ra-data-simple-rest'; - -import NotFound from './NotFound'; const App = () => ( - <Admin catchAll={NotFound} dataProvider={simpleRestProvider('http://path.to.my.api')}> - // ... + <Admin + dataProvider={dataProvider} + theme={lightTheme} + darkTheme={darkTheme} + defaultTheme="light" + > + ... </Admin> ); ``` -**Tip**: If your custom `catchAll` component contains react-router `<Route>` components, this allows you to register new routes displayed within the react-admin layout easily. Note that these routes will match *after* all the react-admin resource routes have been tested. To add custom routes *before* the react-admin ones, and therefore override the default resource routes, see the [`custom pages`](./CustomRoutes.md) section instead. - -## ~~`menu`~~ - -**Tip**: This prop is deprecated. To override the menu component, use a [custom layout](#layout) instead. - -React-admin uses the list of `<Resource>` components passed as children of `<Admin>` to build a menu to each resource with a `<List>` component. +## `disableTelemetry` -If you want to add or remove menu items, for instance to link to non-resources pages, you can create your own menu component: +In production, react-admin applications send an anonymous request on mount to a telemetry server operated by marmelab. You can see this request by looking at the Network tab of your browser DevTools: -```jsx -// in src/MyMenu.js -import * as React from 'react'; -import { createElement } from 'react'; -import { useMediaQuery } from '@mui/material'; -import { Menu, useResourceDefinitions } from 'react-admin'; -import LabelIcon from '@mui/icons-material/Label'; - -export const MyMenu = () => { - const isXSmall = useMediaQuery(theme => theme.breakpoints.down('xs')); - const resources = useResourceDefinitions(); - - return ( - <Menu> - {Object.keys(resources).map(name => ( - <Menu.Item - key={name} - to={`/${name}`} - primaryText={resources[name].options && resources[name].options.label || name} - leftIcon={createElement(resources[name].icon)} - /> - ))} - <Menu.Item to="/custom-route" primaryText="Miscellaneous" leftIcon={<LabelIcon />} /> - </Menu> - ); -}; -``` +`https://react-admin-telemetry.marmelab.com/react-admin-telemetry` -**Tip**: `<Menu.Item>` must be used to avoid unwanted side effects in mobile views. It supports a custom text and icon (which must be a Material UI `<SvgIcon>`). +The only data sent to the telemetry server is the admin domain (e.g. "example.com") - no personal data is ever sent, and no cookie is included in the response. The react-admin team uses these domains to track the usage of the framework. -Then, pass it to the `<Admin>` component as the `menu` prop: +You can opt out of telemetry by simply adding `disableTelemetry` to the `<Admin>` component: ```jsx // in src/App.js -import { MyMenu } from './Menu'; +import * as React from "react"; +import { Admin } from 'react-admin'; const App = () => ( - <Admin menu={MyMenu} dataProvider={simpleRestProvider('http://path.to.my.api')}> + <Admin disableTelemetry> // ... </Admin> ); ``` -See the [Theming documentation](./Theming.md#using-a-custom-menu) for more details. -## `theme` +## `i18nProvider` -Material UI supports [theming](https://mui.com/material-ui/customization/theming/). This lets you customize the look and feel of an admin by overriding fonts, colors, and spacing. You can provide a custom Material UI theme by using the `theme` prop: +The `i18nProvider` props let you translate the GUI. For instance, to switch the UI to French instead of the default English: ```jsx -import { defaultTheme } from 'react-admin'; +// in src/i18nProvider.js +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import fr from 'ra-language-french'; -const theme = { - ...defaultTheme, - palette: { - mode: 'dark', // Switching the dark mode on is a single property value change. - }, -}; +export const i18nProvider = polyglotI18nProvider(() => fr, 'fr'); + +// in src/App.js +import { i18nProvider } from './i18nProvider'; const App = () => ( - <Admin theme={theme} dataProvider={simpleRestProvider('http://path.to.my.api')}> - // ... + <Admin + dataProvider={dataProvider} + i18nProvider={i18nProvider} + > + {/* ... */} </Admin> ); ``` -![Dark theme](./img/dark-theme.png) - -For more details on predefined themes and custom themes, refer to [the Theming chapter](./Theming.md#global-theme-overrides) of the react-admin documentation. +The [Translation Documentation](./Translation.md) details this process. ## `layout` @@ -452,67 +587,108 @@ See The [Authentication documentation](./Authentication.md#customizing-the-login **Tip**: Before considering writing your own login page component, please take a look at how to change the default [background image](./Theming.md#using-a-custom-login-page) or the [Material UI theme](#theme). See the [Authentication documentation](./Authentication.md#customizing-the-login-component) for more details. -## `authCallbackPage` +## `notification` -React-admin apps contain a special route called `/auth-callback` to let external authentication providers (like Auth0, Cognito, OIDC servers) redirect users after login. This route renders the `AuthCallback` component by default, which in turn calls `authProvider.handleCallback`. +You can override the notification component, for instance to change the notification duration. A common use case is to change the `autoHideDuration`, and force the notification to remain on screen longer than the default 4 seconds. For instance, to create a custom Notification component with a 5 seconds default: -If you need a different behavior for this route, you can render a custom component by passing it as the `authCallbackPage` prop. +```jsx +// in src/MyNotification.js +import { Notification } from 'react-admin'; + +const MyNotification = () => <Notification autoHideDuration={5000} />; + +export default MyNotification; +``` + +To use this custom notification component, pass it to the `<Admin>` component as the `notification` prop: ```jsx -import MyAuthCallbackPage from './MyAuthCallbackPage'; +// in src/App.js +import MyNotification from './MyNotification'; +import dataProvider from './dataProvider'; const App = () => ( - <Admin authCallbackPage={MyAuthCallbackPage}> - ... + <Admin notification={MyNotification} dataProvider={dataProvider}> + // ... </Admin> ); ``` -**Note**: You should seldom use this option, even when using an external authentication provider. Since you can already define the `/auth-callback` route controller via `authProvider.handleCallback`, the `authCallbackPage` prop is only useful when you need the user's feedback after they logged in. - -You can also disable the `/auth-callback` route altogether by passing `authCallbackPage={false}`. - -See The [Authentication documentation](./Authentication.md#using-external-authentication-providers) for more details. - -## ~~`history`~~ +## `queryClient` -**Note**: This prop is deprecated. Check [the Routing chapter](./Routing.md) to see how to use a different router. +React-admin uses [react-query](https://react-query-v3.tanstack.com/) to fetch, cache and update data. Internally, the `<Admin>` component creates a react-query [`QueryClient`](https://react-query-v3.tanstack.com/reference/QueryClient) on mount, using [react-query's "aggressive but sane" defaults](https://react-query-v3.tanstack.com/guides/important-defaults): -By default, react-admin creates URLs using a hash sign (e.g. "myadmin.acme.com/#/posts/123"). The hash portion of the URL (i.e. `#/posts/123` in the example) contains the main application route. This strategy has the benefit of working without a server, and with legacy web browsers. But you may want to use another routing strategy, e.g. to allow server-side rendering. +* Queries consider cached data as stale +* Stale queries are refetched automatically in the background when: + * New instances of the query mount + * The window is refocused + * The network is reconnected + * The query is optionally configured with a refetch interval +* Query results that are no longer used in the current page are labeled as "inactive" and remain in the cache in case they are used again at a later time. +* By default, "inactive" queries are garbage collected after 5 minutes. +* Queries that fail are silently retried 3 times, with exponential backoff delay before capturing and displaying an error to the UI. +* Query results by default are structurally shared to detect if data has actually changed and if not, the data reference remains unchanged to better help with value stabilization with regards to `useMemo` and `useCallback`. -You can create your own `history` function (compatible with [the `history` npm package](https://github.com/reacttraining/history)), and pass it to the `<Admin>` component to override the default history strategy. For instance, to use `browserHistory`: +If you want to override the react-query default query and mutation default options, or use a specific client or mutation cache, you can create your own `QueryClient` instance and pass it to the `<Admin queryClient>` prop: ```jsx -import * as React from "react"; -import { createBrowserHistory as createHistory } from 'history'; +import { Admin } from 'react-admin'; +import { QueryClient } from 'react-query'; -const history = createHistory(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + structuralSharing: false, + }, + mutations: { + retryDelay: 10000, + }, + }, +}); const App = () => ( - <Admin history={history}> + <Admin queryClient={queryClient} dataProvider={...}> ... </Admin> ); ``` -## `basename` +To know which options you can pass to the `QueryClient` constructor, check the [react-query documentation](https://react-query-v3.tanstack.com/reference/QueryClient) and the [query options](https://react-query-v3.tanstack.com/reference/useQuery) and [mutation options](https://react-query-v3.tanstack.com/reference/useMutation) sections. -Use this prop to make all routes and links in your Admin relative to a "base" portion of the URL pathname that they all share. This is required when using the [`BrowserHistory`](https://github.com/remix-run/history/blob/main/docs/api-reference.md#createbrowserhistory) to serve the application under a sub-path of your domain (for example https://marmelab.com/ra-enterprise-demo), or when embedding react-admin inside a single-page app with its own routing. +The common settings that react-admin developers often overwrite are: ```jsx -import { Admin } from 'react-admin'; -import { createBrowserHistory } from 'history'; +import { QueryClient } from 'react-query'; -const history = createBrowserHistory(); -const App = () => ( - <Admin basename="/admin" history={history}> - ... - </Admin> -); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + /** + * The time in milliseconds after data is considered stale. + * If set to `Infinity`, the data will never be considered stale. + */ + staleTime: 10000, + /** + * If `false`, failed queries will not retry by default. + * If `true`, failed queries will retry infinitely., failureCount: num + * If set to an integer number, e.g. 3, failed queries will retry until the failed query count meets that number. + * If set to a function `(failureCount, error) => boolean` failed queries will retry until the function returns false. + */ + retry: false, + /** + * If set to `true`, the query will refetch on window focus if the data is stale. + * If set to `false`, the query will not refetch on window focus. + * If set to `'always'`, the query will always refetch on window focus. + * If set to a function, the function will be executed with the latest data and query to compute the value. + * Defaults to `true`. + */ + refetchOnWindowFocus: false, + }, + }, +}); ``` -See [Using React-Admin In A Sub Path](./Routing.md#using-react-admin-in-a-sub-path) for more usage examples. - ## `ready` When you run an `<Admin>` with no child `<Resource>` nor `<CustomRoutes>`, react-admin displays a "ready" screen: @@ -555,7 +731,9 @@ const App = () => ( ## `store` -The `<Admin>` component initializes a [Store](./Store.md) using `localStorage` as the storage engine. You can override this by passing a custom `store` prop: +The `<Admin>` component initializes a [Store](./Store.md) for user preferences using `localStorage` as the storage engine. You can override this by passing a custom `store` prop. + +For instance, you can store the data in memory instead of `localStorage`. This is useful e.g. for tests, or for apps that should not persist user data between sessions: ```jsx import { Admin, Resource, memoryStore } from 'react-admin'; @@ -567,28 +745,42 @@ const App = () => ( ); ``` -## `notification` +Check the [Preferences documentation](./Store.md) for more details. -You can override the notification component, for instance to change the notification duration. A common use case is to change the `autoHideDuration`, and force the notification to remain on screen longer than the default 4 seconds. For instance, to create a custom Notification component with a 5 seconds default: +## `theme` + +Material UI supports [theming](https://mui.com/material-ui/customization/theming/). This lets you customize the look and feel of an admin by overriding fonts, colors, and spacing. You can provide a custom Material UI theme by using the `theme` prop. + +For instance, to use a dark theme by default: ```jsx -// in src/MyNotification.js -import { Notification } from 'react-admin'; +import { defaultTheme } from 'react-admin'; -const MyNotification = () => <Notification autoHideDuration={5000} />; +const theme = { + ...defaultTheme, + palette: { mode: 'dark' }, +}; -export default MyNotification; +const App = () => ( + <Admin theme={theme} dataProvider={simpleRestProvider('http://path.to.my.api')}> + // ... + </Admin> +); ``` -To use this custom notification component, pass it to the `<Admin>` component as the `notification` prop: +![Dark theme](./img/dark-theme.png) -```jsx -// in src/App.js -import MyNotification from './MyNotification'; -import dataProvider from './dataProvider'; +If you want to support both a light and a dark theme, check out [the `<Admin darkTheme>` prop](#darktheme). +For more details on predefined themes and custom themes, refer to [the Theming chapter](./Theming.md#global-theme-overrides) of the react-admin documentation. + +## `title` + +On error pages, the header of an admin app uses 'React Admin' as the main app title. Use the `title` to customize it. + +```jsx const App = () => ( - <Admin notification={MyNotification} dataProvider={dataProvider}> + <Admin title="My Custom Admin" dataProvider={simpleRestProvider('http://path.to.my.api')}> // ... </Admin> ); diff --git a/docs/AppBar.md b/docs/AppBar.md index 9a935ada1f4..c5eeb684108 100644 --- a/docs/AppBar.md +++ b/docs/AppBar.md @@ -17,6 +17,7 @@ By default, the `<AppBar>` component displays: - a hamburger icon to toggle the sidebar width, - the page title, - a button to change locales (if the application uses [i18n](./Translation.md)), +- a button to change the theme (if the application uses a [dark theme](./Admin.md#darktheme)), - a loading indicator, - a button to display the user menu. @@ -74,28 +75,29 @@ Additional props are passed to [the underlying Material UI `<AppBar>` element](h ## `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. +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 settings button. ```jsx // in src/MyAppBar.js -import { - AppBar, - TitlePortal, - ToggleThemeButton, - defaultTheme, -} from 'react-admin'; +import { AppBar, TitlePortal } from 'react-admin'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { IconButton } from '@mui/material'; -const darkTheme = { palette: { mode: 'dark' } }; +const SettingsButton = () => ( + <IconButton color="inherit"> + <SettingsIcon /> + </IconButton> +); export const MyAppBar = () => ( <AppBar> <TitlePortal /> - <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> + <SettingsButton /> </AppBar> ); ``` -![App bar with a toggle theme button](./img/AppBar-children.png) +![App bar with a settings 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. @@ -166,7 +168,11 @@ To override the style of `<AppBar>` using the [Material UI style overrides](http ## `toolbar` -By default, the `<AppBar>` renders two buttons in addition to the user menu: the language menu and the refresh button. +By default, the `<AppBar>` renders three buttons in addition to the user menu: + +- the [language menu button](./LocalesMenuButton.md), +- the [theme toggle button](./ToggleThemeButton.md), +- and [the refresh button](./Buttons.md#refreshbutton). If you want to reorder or remove these buttons, you can customize the toolbar by passing a `toolbar` prop. @@ -177,36 +183,37 @@ import { LocalesMenuButton, RefreshIconButton, ToggleThemeButton, - defaultTheme, } from 'react-admin'; -const darkTheme = { - palette: { mode: 'dark' }, -}; - export const MyAppBar = () => ( <AppBar toolbar={ <> <LocalesMenuButton /> - <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> + <ToggleThemeButton /> <RefreshIconButton /> </> } /> ); ``` -**Tip**: If you only need to *add* buttons to the toolbar, you can pass them as children instead of overriding the entire toolbar. +**Tip**: If you only need to *add* buttons to the toolbar, you can pass them as [children](#children) instead of overriding the entire toolbar. ```jsx // in src/MyAppBar.js -import { AppBar, TitlePortal, ToggleThemeButton, defaultTheme } from 'react-admin'; +import { AppBar, TitlePortal } from 'react-admin'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { IconButton } from '@mui/material'; -const darkTheme = { palette: { mode: 'dark' } }; +const SettingsButton = () => ( + <IconButton color="inherit"> + <SettingsIcon /> + </IconButton> +); export const MyAppBar = () => ( <AppBar> <TitlePortal /> - <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> + <SettingsButton /> </AppBar> ); ``` @@ -346,23 +353,24 @@ export const i18nProvider = { To add buttons to the app bar, you can use the `<AppBar>` [`children` prop](#children). -For instance, to add `<ToggleThemeButton>`: +For instance, to add a settings button: ```jsx // in src/MyAppBar.js -import { - AppBar, - TitlePortal, - ToggleThemeButton, - defaultTheme, -} from 'react-admin'; +import { AppBar, TitlePortal } from 'react-admin'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { IconButton } from '@mui/material'; -const darkTheme = { palette: { mode: 'dark' } }; +const SettingsButton = () => ( + <IconButton color="inherit"> + <SettingsIcon /> + </IconButton> +); export const MyAppBar = () => ( <AppBar> <TitlePortal /> - <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> + <SettingsButton /> </AppBar> ); ``` diff --git a/docs/AuthProviderList.md b/docs/AuthProviderList.md index 90b65b3ce63..dbcd5dc2f57 100644 --- a/docs/AuthProviderList.md +++ b/docs/AuthProviderList.md @@ -12,7 +12,7 @@ It's very common that your auth logic is so specific that you'll need to write y - **[AWS Cognito](https://docs.aws.amazon.com/cognito/latest/developerguide/setting-up-the-javascript-sdk.html)**: [ra-auth-cognito](https://github.com/marmelab/ra-auth-cognito) - **[Directus](https://directus.io/)**: [marmelab/ra-directus](https://github.com/marmelab/ra-directus) - **[Firebase Auth (Google, Facebook, GitHub, etc.)](https://firebase.google.com/docs/auth/web/firebaseui)**: [benwinding/react-admin-firebase](https://github.com/benwinding/react-admin-firebase#auth-provider) -- **[Postgrest](https://postgrest.org/): [raphiniert-com/ra-data-postgrest](https://github.com/raphiniert-com/ra-data-postgrest) +- **[Postgrest](https://postgrest.org/)**: [raphiniert-com/ra-data-postgrest](https://github.com/raphiniert-com/ra-data-postgrest) - **[Supabase](https://supabase.io/)**: [marmelab/ra-supabase](https://github.com/marmelab/ra-supabase) - **[Keycloak](https://www.keycloak.org/)**: [marmelab/ra-keycloak](https://github.com/marmelab/ra-keycloak) - **[Azure Active Directory (using MSAL)](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-browser)**: [marmelab/ra-auth-msal](https://github.com/marmelab/ra-auth-msal) diff --git a/docs/Theming.md b/docs/Theming.md index 85560938482..c433d112844 100644 --- a/docs/Theming.md +++ b/docs/Theming.md @@ -243,7 +243,7 @@ Note that you don't need to call `createTheme` yourself. React-admin will do it Again, to guess the name of the subclass to use (like `.RaDatagrid-headerCell` above) for customizing a component, you can use the developer tools of your browser, or check the react-admin documentation for individual components (e.g. the [Datagrid CSS documentation](./Datagrid.md#sx-css-api)). -You can use this technique to override not only styles, but also default for components. That's how react-admin applies the `filled` variant to all `TextField` components. So for instance, to change the variant to `outlined`, create a custom theme as follows: +You can use this technique to override not only styles, but also defaults for components. That's how react-admin applies the `filled` variant to all `TextField` components. So for instance, to change the variant to `outlined`, create a custom theme as follows: ```jsx import { defaultTheme } from 'react-admin'; @@ -367,7 +367,7 @@ const App = () => ( ## Letting Users Choose The Theme -The `<ToggleThemeButton>` component lets users switch from light to dark mode, and persists that choice by leveraging the [store](./Store.md). +It's a common practice to support both a light theme and a dark theme in an application, and let users choose which one they prefer. <video controls autoplay muted loop> <source src="./img/ToggleThemeButton.webm" type="video/webm"/> @@ -375,50 +375,43 @@ The `<ToggleThemeButton>` component lets users switch from light to dark mode, a </video> -You can add the `<ToggleThemeButton>` to a custom App Bar: +React-admin's `<Admin>` component accepts a `darkTheme` prop in addition to the `theme` prop. ```jsx -import * as React from 'react'; -import { defaultTheme, Layout, AppBar, ToggleThemeButton, TitlePortal } from 'react-admin'; -import { createTheme, Box, Typography } from '@mui/material'; +import { Admin, defaultTheme } from 'react-admin'; -const darkTheme = createTheme({ - palette: { mode: 'dark' }, -}); +const lightTheme = defaultTheme; +const darkTheme = { ...defaultTheme, palette: { mode: 'dark' } }; -const MyAppBar = () => ( - <AppBar> - <TitlePortal /> - <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> - </AppBar> +const App = () => ( + <Admin + dataProvider={...} + theme={lightTheme} + darkTheme={darkTheme} + > + // ... + </Admin> ); - -const MyLayout = props => <Layout {...props} appBar={MyAppBar} />; ``` +With this setup, the default application theme depends on the user's system settings. If the user has chosen a dark mode in their OS, react-admin will use the dark theme. Otherwise, it will use the light theme. + +In addition, users can switch from one theme to the other using [the `<ToggleThemeButton>` component](./ToggleThemeButton.md), which appears in the AppBar as soon as you define a `darkTheme` prop. + ## Changing the Theme Programmatically -React-admin provides the `useTheme` hook to read and update the theme programmatically. It uses the same syntax as `useState`. -Its used internally by `ToggleThemeButton` component. +React-admin provides the `useTheme` hook to read and update the theme programmatically. It uses the same syntax as `useState`. Its used internally by [the `<ToggleThemeButton>` component](./ToggleThemeButton.md). ```jsx import { defaultTheme, useTheme } from 'react-admin'; import { Button } from '@mui/material'; -const lightTheme = defaultTheme; -const darkTheme = { - ...defaultTheme, - palette: { - mode: 'dark', - }, -}; - const ThemeToggler = () => { const [theme, setTheme] = useTheme(); return ( - <Button onClick={() => setTheme(theme.palette.mode === 'dark' ? lightTheme : darkTheme)}> - {theme.palette.mode === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'} + <Button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}> + {theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'} </Button> ); } diff --git a/docs/ToggleThemeButton.md b/docs/ToggleThemeButton.md index e0e698fa609..7db5b7ab861 100644 --- a/docs/ToggleThemeButton.md +++ b/docs/ToggleThemeButton.md @@ -12,28 +12,24 @@ The `<ToggleThemeButton>` component lets users switch from light to dark mode, a Your browser does not support the video tag. </video> +It is enabled by default in the `<AppBar>` as soon as you define a dark theme via [the `<Admin darkTheme>` prop](./Admin.md#darktheme). ## Usage -You can add the `<ToggleThemeButton>` to a custom App Bar: +You can add the `<ToggleThemeButton>` to a custom [`<AppBar toolbar>`](./AppBar.md#toolbar): ```jsx -import { AppBar, TitlePortal, ToggleThemeButton, defaultTheme } from 'react-admin'; - -const darkTheme = { - palette: { mode: 'dark' }, -}; +// in src/MyAppBar.js +import { AppBar, ToggleThemeButton } from 'react-admin'; export const MyAppBar = () => ( - <AppBar> - <TitlePortal /> - <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> - </AppBar>> + <AppBar toolbar={<ToggleThemeButton />} /> ); ``` -Then, pass the custom App Bar in a custom `<Layout>`, and the `<Layout>` to your `<Admin>`: +Then, pass the custom App Bar in a custom `<Layout>`, and the `<Layout>` to your `<Admin>`. The `<Admin>` must define a `darkTheme` prop for the button to work: +{% raw %} ```jsx import { Admin, Layout } from 'react-admin'; @@ -42,56 +38,63 @@ import { MyAppBar } from './MyAppBar'; const MyLayout = (props) => <Layout {...props} appBar={MyAppBar} />; const App = () => ( - <Admin dataProvider={dataProvider} layout={MyLayout}> + <Admin + dataProvider={dataProvider} + layout={MyLayout} + darkTheme={{ palette: { mode: 'dark' } }} + > ... </Admin> ); ``` +{% endraw %} -## `darkTheme` +## Removing The Button From The AppBar -Required: A theme object to use when the user chooses the dark mode. It must be serializable to JSON. +The `<ToggleThemeButton>` appears by default in the `<AppBar>` if the `<Admin darkTheme>` prop is defined. If you want to remove it, you need to set a custom [`<AppBar toolbar>` prop](./AppBar.md#toolbar): ```jsx -const darkTheme = { - palette: { mode: 'dark' }, -}; +// in src/MyAppBar.js +import { AppBar, LocalesMenuButton, RefreshIconButton } from 'react-admin'; -<ToggleThemeButton darkTheme={darkTheme} /> +export const MyAppBar = () => ( + <AppBar toolbar={ + <> + <LocalesMenuButton /> + {/* no ToggleThemeButton here */} + <RefreshIconButton /> + </> + } /> +); ``` -**Tip**: React-admin calls Material UI's `createTheme()` on this object. +## Creating A Dark Theme -## `lightTheme` +For this button to work, you must provide a dark theme to the `<Admin>` component. The `darkTheme` should be a JSON object that follows the [Material UI theme specification](https://material-ui.com/customization/theming/). -A theme object to use when the user chooses the light mode. It must be serializable to JSON. +You can create such a theme from scratch: ```jsx const darkTheme = { palette: { mode: 'dark' }, }; -const lightTheme = { +``` + +Of you can override react-admin's default dark theme: + +```jsx +import { defaultDarkTheme } from 'react-admin'; + +const darkTheme = { + ...defaultDarkTheme, palette: { - type: 'light', + ...defaultDarkTheme.palette, primary: { - main: '#3f51b5', - }, - secondary: { - main: '#f50057', + main: '#90caf9', }, }, }; - -<ToggleThemeButton lightTheme={lightTheme} darkTheme={darkTheme} /> ``` -React-admin uses the `<Admin theme>` prop as default theme. - -**Tip**: React-admin calls Material UI's `createTheme()` on this object. - -## API - -* [`ToggleThemeButton`] - -[`ToggleThemeButton`]: https://github.com/marmelab/react-admin/blob/master/packages/ra-ui-materialui/src/button/ToggleThemeButton.jsx +**Tip**: React-admin calls Material UI's `createTheme()` on the `<Admin darkTheme>` prop - don't call it yourself. diff --git a/docs/img/AppBar-children.png b/docs/img/AppBar-children.png index 77ebbdbdd4d..0ec82e71b7e 100644 Binary files a/docs/img/AppBar-children.png and b/docs/img/AppBar-children.png differ diff --git a/docs/img/dark-theme.png b/docs/img/dark-theme.png index 73ab22e5802..1d1e7a8bd79 100644 Binary files a/docs/img/dark-theme.png and b/docs/img/dark-theme.png differ diff --git a/docs/img/not-found.png b/docs/img/not-found.png index c2149730d62..c934e9dbd47 100644 Binary files a/docs/img/not-found.png and b/docs/img/not-found.png differ diff --git a/examples/demo/src/App.tsx b/examples/demo/src/App.tsx index b0f11fcd5bb..09e072f6809 100644 --- a/examples/demo/src/App.tsx +++ b/examples/demo/src/App.tsx @@ -7,7 +7,7 @@ import authProvider from './authProvider'; import { Login, Layout } from './layout'; import { Dashboard } from './dashboard'; import englishMessages from './i18n/en'; -import { lightTheme } from './layout/themes'; +import { lightTheme, darkTheme } from './layout/themes'; import visitors from './visitors'; import orders from './orders'; @@ -16,17 +16,23 @@ import invoices from './invoices'; import categories from './categories'; import reviews from './reviews'; import dataProviderFactory from './dataProvider'; -import Configuration from './configuration/Configuration'; import Segments from './segments/Segments'; -const i18nProvider = polyglotI18nProvider(locale => { - if (locale === 'fr') { - return import('./i18n/fr').then(messages => messages.default); - } +const i18nProvider = polyglotI18nProvider( + locale => { + if (locale === 'fr') { + return import('./i18n/fr').then(messages => messages.default); + } - // Always fallback on english - return englishMessages; -}, 'en'); + // Always fallback on english + return englishMessages; + }, + 'en', + [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'Français' }, + ] +); const App = () => ( <Admin @@ -41,9 +47,10 @@ const App = () => ( i18nProvider={i18nProvider} disableTelemetry theme={lightTheme} + darkTheme={darkTheme} + defaultTheme="light" > <CustomRoutes> - <Route path="/configuration" element={<Configuration />} /> <Route path="/segments" element={<Segments />} /> </CustomRoutes> <Resource name="customers" {...visitors} /> diff --git a/examples/demo/src/authProvider.ts b/examples/demo/src/authProvider.ts index d10dc16d96b..4ee6f7d9bb5 100644 --- a/examples/demo/src/authProvider.ts +++ b/examples/demo/src/authProvider.ts @@ -13,7 +13,7 @@ const authProvider: AuthProvider = { checkError: () => Promise.resolve(), checkAuth: () => localStorage.getItem('username') ? Promise.resolve() : Promise.reject(), - getPermissions: () => Promise.reject('Unknown method'), + getPermissions: () => Promise.resolve(), getIdentity: () => Promise.resolve({ id: 'user', diff --git a/examples/demo/src/configuration/Configuration.tsx b/examples/demo/src/configuration/Configuration.tsx deleted file mode 100644 index 968a9e2db5e..00000000000 --- a/examples/demo/src/configuration/Configuration.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import * as React from 'react'; -import Card from '@mui/material/Card'; -import Box from '@mui/material/Box'; -import CardContent from '@mui/material/CardContent'; -import Button from '@mui/material/Button'; -import { useTranslate, useLocaleState, useTheme, Title } from 'react-admin'; - -import { darkTheme, lightTheme } from '../layout/themes'; - -const Configuration = () => { - const translate = useTranslate(); - const [locale, setLocale] = useLocaleState(); - const [theme, setTheme] = useTheme(); - - return ( - <Card> - <Title title={translate('pos.configuration')} /> - <CardContent> - <Box sx={{ width: '10em', display: 'inline-block' }}> - {translate('pos.theme.name')} - </Box> - <Button - variant="contained" - sx={{ margin: '1em' }} - color={ - theme?.palette?.mode === 'light' - ? 'primary' - : 'secondary' - } - onClick={() => setTheme(lightTheme)} - > - {translate('pos.theme.light')} - </Button> - <Button - variant="contained" - sx={{ margin: '1em' }} - color={ - theme?.palette?.mode === 'dark' - ? 'primary' - : 'secondary' - } - onClick={() => setTheme(darkTheme)} - > - {translate('pos.theme.dark')} - </Button> - </CardContent> - <CardContent> - <Box sx={{ width: '10em', display: 'inline-block' }}> - {translate('pos.language')} - </Box> - <Button - variant="contained" - sx={{ margin: '1em' }} - color={locale === 'en' ? 'primary' : 'secondary'} - onClick={() => setLocale('en')} - > - en - </Button> - <Button - variant="contained" - sx={{ margin: '1em' }} - color={locale === 'fr' ? 'primary' : 'secondary'} - onClick={() => setLocale('fr')} - > - fr - </Button> - </CardContent> - </Card> - ); -}; - -export default Configuration; diff --git a/examples/demo/src/layout/AppBar.tsx b/examples/demo/src/layout/AppBar.tsx index f6e09a8316b..735a7defd8d 100644 --- a/examples/demo/src/layout/AppBar.tsx +++ b/examples/demo/src/layout/AppBar.tsx @@ -1,58 +1,15 @@ import * as React from 'react'; -import { - AppBar, - TitlePortal, - Logout, - UserMenu, - useTranslate, - useUserMenu, -} from 'react-admin'; -import { Link } from 'react-router-dom'; -import { - Box, - MenuItem, - ListItemIcon, - ListItemText, - useMediaQuery, - Theme, -} from '@mui/material'; -import SettingsIcon from '@mui/icons-material/Settings'; +import { AppBar, TitlePortal } from 'react-admin'; +import { Box, useMediaQuery, Theme } from '@mui/material'; import Logo from './Logo'; -const ConfigurationMenu = React.forwardRef((props, ref) => { - const translate = useTranslate(); - const { onClose } = useUserMenu(); - - return ( - <MenuItem - component={Link} - // @ts-ignore - ref={ref} - {...props} - to="/configuration" - onClick={onClose} - > - <ListItemIcon> - <SettingsIcon fontSize="small" /> - </ListItemIcon> - <ListItemText>{translate('pos.configuration')}</ListItemText> - </MenuItem> - ); -}); -const CustomUserMenu = () => ( - <UserMenu> - <ConfigurationMenu /> - <Logout /> - </UserMenu> -); - const CustomAppBar = () => { const isLargeEnough = useMediaQuery<Theme>(theme => theme.breakpoints.up('sm') ); return ( - <AppBar color="secondary" elevation={1} userMenu={<CustomUserMenu />}> + <AppBar color="secondary" elevation={1}> <TitlePortal /> {isLargeEnough && <Logo />} {isLargeEnough && <Box component="span" sx={{ flex: 1 }} />} diff --git a/packages/ra-input-rich-text/src/buttons/ColorButtons.tsx b/packages/ra-input-rich-text/src/buttons/ColorButtons.tsx index 2141190ff90..f8322df1cf9 100644 --- a/packages/ra-input-rich-text/src/buttons/ColorButtons.tsx +++ b/packages/ra-input-rich-text/src/buttons/ColorButtons.tsx @@ -5,11 +5,11 @@ import { ToggleButton, ToggleButtonGroup, ToggleButtonProps, + useTheme, } from '@mui/material'; import FormatColorTextIcon from '@mui/icons-material/FormatColorText'; import FontDownloadIcon from '@mui/icons-material/FontDownload'; import { useTranslate } from 'ra-core'; -import { useTheme } from 'ra-ui-materialui'; import { useTiptapEditor } from '../useTiptapEditor'; import { grey, @@ -92,7 +92,7 @@ const ColorChoiceDialog = ({ close, colorType, }: ColorChoiceDialogProps) => { - const [theme] = useTheme(); + const theme = useTheme(); const colors = [grey, red, orange, yellow, green, blue, purple]; const shades = [900, 700, 500, 300, 100]; diff --git a/packages/ra-ui-materialui/src/AdminContext.spec.tsx b/packages/ra-ui-materialui/src/AdminContext.spec.tsx new file mode 100644 index 00000000000..bbfea0f82fe --- /dev/null +++ b/packages/ra-ui-materialui/src/AdminContext.spec.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Typography, ThemeOptions } from '@mui/material'; +import expect from 'expect'; +import { memoryStore } from 'ra-core'; + +import { AdminContext } from './AdminContext'; +import { ThemeTestWrapper } from './layout/ThemeTestWrapper'; + +const lightTheme: ThemeOptions = {}; +const darkTheme: ThemeOptions = { palette: { mode: 'dark' } }; + +const LIGHT_MODE_TEXT_COLOR = 'rgb(25, 118, 210)'; // text is dark blue in light mode +const DARK_MODE_TEXT_COLOR = 'rgb(144, 202, 249)'; // text is light blue in dark mode + +describe('AdminContext', () => { + it('should default to light theme', () => { + render( + <AdminContext theme={lightTheme} darkTheme={darkTheme}> + <Typography color="primary">Test</Typography> + </AdminContext> + ); + const text = screen.getByText('Test'); + expect(getComputedStyle(text).color).toBe(LIGHT_MODE_TEXT_COLOR); + }); + it('should default to dark theme when the browser detects a dark mode preference', () => { + render( + <ThemeTestWrapper mode="dark"> + <AdminContext theme={lightTheme} darkTheme={darkTheme}> + <Typography color="primary">Test</Typography> + </AdminContext> + </ThemeTestWrapper> + ); + const text = screen.getByText('Test'); + expect(getComputedStyle(text).color).toBe(DARK_MODE_TEXT_COLOR); + }); + it('should default to light theme when the browser detects a dark mode preference', () => { + render( + <ThemeTestWrapper mode="light"> + <AdminContext theme={lightTheme} darkTheme={darkTheme}> + <Typography color="primary">Test</Typography> + </AdminContext> + </ThemeTestWrapper> + ); + const text = screen.getByText('Test'); + expect(getComputedStyle(text).color).toBe(LIGHT_MODE_TEXT_COLOR); + }); + it('should default to dark theme when user preference is dark', () => { + render( + <AdminContext + theme={lightTheme} + darkTheme={darkTheme} + store={memoryStore({ theme: 'dark' })} + > + <Typography color="primary">Test</Typography> + </AdminContext> + ); + const text = screen.getByText('Test'); + expect(getComputedStyle(text).color).toBe(DARK_MODE_TEXT_COLOR); + }); + it('should default to light theme when user preference is light', () => { + render( + <ThemeTestWrapper mode="dark"> + <AdminContext + theme={lightTheme} + darkTheme={darkTheme} + store={memoryStore({ theme: 'light' })} + > + <Typography color="primary">Test</Typography> + </AdminContext> + </ThemeTestWrapper> + ); + const text = screen.getByText('Test'); + expect(getComputedStyle(text).color).toBe(LIGHT_MODE_TEXT_COLOR); + }); +}); diff --git a/packages/ra-ui-materialui/src/AdminContext.tsx b/packages/ra-ui-materialui/src/AdminContext.tsx index 281db865b6f..fddc20645d5 100644 --- a/packages/ra-ui-materialui/src/AdminContext.tsx +++ b/packages/ra-ui-materialui/src/AdminContext.tsx @@ -1,16 +1,37 @@ import * as React from 'react'; import { CoreAdminContext, CoreAdminContextProps } from 'ra-core'; -import { defaultTheme } from './defaultTheme'; -import { ThemeProvider } from './layout/Theme'; +import { defaultLightTheme } from './defaultTheme'; +import { ThemeProvider, ThemesContext, RaThemeOptions } from './layout/Theme'; -export const AdminContext = (props: CoreAdminContextProps) => { - const { theme = defaultTheme, children, ...rest } = props; +export const AdminContext = (props: AdminContextProps) => { + const { + theme, + lightTheme = defaultLightTheme, + darkTheme, + defaultTheme, + children, + ...rest + } = props; return ( <CoreAdminContext {...rest}> - <ThemeProvider theme={theme}>{children}</ThemeProvider> + <ThemesContext.Provider + value={{ + lightTheme: theme || lightTheme, + darkTheme, + defaultTheme, + }} + > + <ThemeProvider>{children}</ThemeProvider> + </ThemesContext.Provider> </CoreAdminContext> ); }; +export interface AdminContextProps extends CoreAdminContextProps { + lightTheme?: RaThemeOptions; + darkTheme?: RaThemeOptions; + defaultTheme?: 'light' | 'dark'; +} + AdminContext.displayName = 'AdminContext'; diff --git a/packages/ra-ui-materialui/src/button/Button.stories.tsx b/packages/ra-ui-materialui/src/button/Button.stories.tsx index 551befdc2ae..57e42daae70 100644 --- a/packages/ra-ui-materialui/src/button/Button.stories.tsx +++ b/packages/ra-ui-materialui/src/button/Button.stories.tsx @@ -85,10 +85,10 @@ const theme = createTheme({ declare module '@mui/material/styles' { interface Palette { - userDefined: PaletteColor; + userDefined?: PaletteColor; } interface PaletteOptions { - userDefined: PaletteColor; + userDefined?: PaletteColor; } } diff --git a/packages/ra-ui-materialui/src/button/ToggleThemeButton.spec.tsx b/packages/ra-ui-materialui/src/button/ToggleThemeButton.spec.tsx new file mode 100644 index 00000000000..c2490bf06e7 --- /dev/null +++ b/packages/ra-ui-materialui/src/button/ToggleThemeButton.spec.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import expect from 'expect'; + +import { Basic } from './ToggleThemeButton.stories'; + +describe('ToggleThemeButton', () => { + it('should display a button', () => { + render(<Basic />); + screen.getByLabelText('Toggle Theme'); + }); + it('should allow to change the theme between light and dark', () => { + const { container } = render(<Basic />); + const root = container.querySelector<HTMLDivElement>( + '.MuiScopedCssBaseline-root' + ); + if (!root) { + throw new Error('No root element found'); + } + expect(getComputedStyle(root).colorScheme).toBe('light'); + screen.getByLabelText('Toggle Theme').click(); + expect(getComputedStyle(root).colorScheme).toBe('dark'); + screen.getByLabelText('Toggle Theme').click(); + expect(getComputedStyle(root).colorScheme).toBe('light'); + }); +}); diff --git a/packages/ra-ui-materialui/src/button/ToggleThemeButton.stories.tsx b/packages/ra-ui-materialui/src/button/ToggleThemeButton.stories.tsx index ae355d3ba5d..24c93fe3c34 100644 --- a/packages/ra-ui-materialui/src/button/ToggleThemeButton.stories.tsx +++ b/packages/ra-ui-materialui/src/button/ToggleThemeButton.stories.tsx @@ -85,18 +85,26 @@ const dataProvider = fakeRestDataProvider({ const history = createMemoryHistory({ initialEntries: ['/books'] }); -const BookList = () => { - return ( - <List> - <Datagrid> - <TextField source="id" /> - <TextField source="title" /> - <TextField source="author" /> - <TextField source="year" /> - </Datagrid> - </List> - ); -}; +const BookList = () => ( + <List> + <Datagrid> + <TextField source="id" /> + <TextField source="title" /> + <TextField source="author" /> + <TextField source="year" /> + </Datagrid> + </List> +); + +export const Basic = () => ( + <Admin + dataProvider={dataProvider} + history={history} + darkTheme={{ palette: { mode: 'dark' } }} + > + <Resource name="books" list={BookList} /> + </Admin> +); const MyAppBar = () => ( <AppBar> @@ -106,7 +114,7 @@ const MyAppBar = () => ( ); const MyLayout = props => <Layout {...props} appBar={MyAppBar} />; -export const Basic = () => ( +export const Legacy = () => ( <Admin dataProvider={dataProvider} history={history} layout={MyLayout}> <Resource name="books" list={BookList} /> </Admin> diff --git a/packages/ra-ui-materialui/src/button/ToggleThemeButton.tsx b/packages/ra-ui-materialui/src/button/ToggleThemeButton.tsx index 6d4eff00a21..916aa91eef8 100644 --- a/packages/ra-ui-materialui/src/button/ToggleThemeButton.tsx +++ b/packages/ra-ui-materialui/src/button/ToggleThemeButton.tsx @@ -1,33 +1,48 @@ import React from 'react'; -import { Tooltip, IconButton } from '@mui/material'; +import { Tooltip, IconButton, useMediaQuery } from '@mui/material'; import Brightness4Icon from '@mui/icons-material/Brightness4'; import Brightness7Icon from '@mui/icons-material/Brightness7'; import { useTranslate } from 'ra-core'; -import { useTheme } from '../layout'; -import { RaThemeOptions } from '..'; + +import { ToggleThemeLegacyButton } from './ToggleThemeLegacyButton'; +import { RaThemeOptions, useThemesContext, useTheme } from '../layout'; /** * Button toggling the theme (light or dark). * + * Enabled by default in the <AppBar> when the <Admin> component has a darkMode. + * * @example - * import { AppBar, TitlePortal, ToggleThemeButton } from 'react-admin'; + * import { AppBar, ToggleThemeButton } from 'react-admin'; * * const MyAppBar = () => ( - * <AppBar> - * <TitlePortal /> - * <ToggleThemeButton lightTheme={lightTheme} darkTheme={darkTheme} /> - * </AppBar> + * <AppBar toolbar={<ToggleThemeButton />} /> * ); * * const MyLayout = props => <Layout {...props} appBar={MyAppBar} />; */ export const ToggleThemeButton = (props: ToggleThemeButtonProps) => { const translate = useTranslate(); - const { darkTheme, lightTheme } = props; - const [theme, setTheme] = useTheme(); + const { darkTheme, defaultTheme } = useThemesContext(props); + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)', { + noSsr: true, + }); + const [theme, setTheme] = useTheme( + defaultTheme || (prefersDarkMode && darkTheme ? 'dark' : 'light') + ); + + // FIXME: remove in v5 + if (props.darkTheme) { + return ( + <ToggleThemeLegacyButton + darkTheme={props.darkTheme} + lightTheme={props.lightTheme} + /> + ); + } const handleTogglePaletteType = (): void => { - setTheme(theme?.palette.mode === 'dark' ? lightTheme : darkTheme); + setTheme(theme === 'dark' ? 'light' : 'dark'); }; const toggleThemeTitle = translate('ra.action.toggle_theme', { _: 'Toggle Theme', @@ -40,17 +55,19 @@ export const ToggleThemeButton = (props: ToggleThemeButtonProps) => { onClick={handleTogglePaletteType} aria-label={toggleThemeTitle} > - {theme?.palette.mode === 'dark' ? ( - <Brightness7Icon /> - ) : ( - <Brightness4Icon /> - )} + {theme === 'dark' ? <Brightness7Icon /> : <Brightness4Icon />} </IconButton> </Tooltip> ); }; export interface ToggleThemeButtonProps { - darkTheme: RaThemeOptions; + /** + * @deprecated Set the lightTheme in the <Admin> component instead. + */ lightTheme?: RaThemeOptions; + /** + * @deprecated Set the darkTheme in the <Admin> component instead. + */ + darkTheme?: RaThemeOptions; } diff --git a/packages/ra-ui-materialui/src/button/ToggleThemeLegacyButton.tsx b/packages/ra-ui-materialui/src/button/ToggleThemeLegacyButton.tsx new file mode 100644 index 00000000000..63fe6884d91 --- /dev/null +++ b/packages/ra-ui-materialui/src/button/ToggleThemeLegacyButton.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Tooltip, IconButton } from '@mui/material'; +import Brightness4Icon from '@mui/icons-material/Brightness4'; +import Brightness7Icon from '@mui/icons-material/Brightness7'; +import { useTranslate } from 'ra-core'; +import { useTheme } from '../layout'; +import { RaThemeOptions } from '..'; + +/** + * Button toggling the theme (light or dark). + * + * @deprecated Set the lightTheme and darkTheme props in the <Admin> component. + * + * @example + * import { AppBar, TitlePortal, ToggleThemeLegacyButton } from 'react-admin'; + * + * const MyAppBar = () => ( + * <AppBar> + * <TitlePortal /> + * <ToggleThemeButton lightTheme={lightTheme} darkTheme={darkTheme} /> + * </AppBar> + * ); + * + * const MyLayout = props => <Layout {...props} appBar={MyAppBar} />; + */ +export const ToggleThemeLegacyButton = ( + props: ToggleThemeLegacyButtonProps +) => { + const translate = useTranslate(); + const { darkTheme, lightTheme } = props; + const [theme, setTheme] = useTheme(); + // @ts-ignore + const isDark = theme === 'dark' || theme?.palette.mode === 'dark'; + + const handleTogglePaletteType = (): void => { + setTheme(isDark ? lightTheme : darkTheme); + }; + const toggleThemeTitle = translate('ra.action.toggle_theme', { + _: 'Toggle Theme', + }); + + return ( + <Tooltip title={toggleThemeTitle} enterDelay={300}> + <IconButton + color="inherit" + onClick={handleTogglePaletteType} + aria-label={toggleThemeTitle} + > + {isDark ? <Brightness7Icon /> : <Brightness4Icon />} + </IconButton> + </Tooltip> + ); +}; + +export interface ToggleThemeLegacyButtonProps { + darkTheme: RaThemeOptions; + lightTheme?: RaThemeOptions; +} diff --git a/packages/ra-ui-materialui/src/defaultTheme.ts b/packages/ra-ui-materialui/src/defaultTheme.ts index f4b657c96ce..e2a3ac0f95d 100644 --- a/packages/ra-ui-materialui/src/defaultTheme.ts +++ b/packages/ra-ui-materialui/src/defaultTheme.ts @@ -1,17 +1,6 @@ -import { ThemeOptions } from '@mui/material'; +import { RaThemeOptions } from './layout/Theme'; -export const defaultTheme = { - palette: { - background: { - default: '#fafafb', - }, - secondary: { - light: '#6ec6ff', - main: '#2196f3', - dark: '#0069c0', - contrastText: '#fff', - }, - }, +const defaultThemeInvariants = { typography: { h6: { fontWeight: 400, @@ -22,16 +11,6 @@ export const defaultTheme = { closedWidth: 50, }, components: { - MuiFilledInput: { - styleOverrides: { - root: { - backgroundColor: 'rgba(0, 0, 0, 0.04)', - '&$disabled': { - backgroundColor: 'rgba(0, 0, 0, 0.04)', - }, - }, - }, - }, MuiTextField: { defaultProps: { variant: 'filled' as const, @@ -49,9 +28,45 @@ export const defaultTheme = { }, }; -export interface RaThemeOptions extends ThemeOptions { - sidebar?: { - width?: number; - closedWidth?: number; - }; -} +export const defaultLightTheme: RaThemeOptions = { + palette: { + background: { + default: '#fafafb', + }, + secondary: { + light: '#6ec6ff', + main: '#2196f3', + dark: '#0069c0', + contrastText: '#fff', + }, + }, + ...defaultThemeInvariants, + components: { + ...defaultThemeInvariants.components, + MuiFilledInput: { + styleOverrides: { + root: { + backgroundColor: 'rgba(0, 0, 0, 0.04)', + '&$disabled': { + backgroundColor: 'rgba(0, 0, 0, 0.04)', + }, + }, + }, + }, + }, +}; + +export const defaultDarkTheme: RaThemeOptions = { + palette: { + mode: 'dark', + primary: { + main: '#90caf9', + }, + background: { + default: '#313131', + }, + }, + ...defaultThemeInvariants, +}; + +export const defaultTheme = defaultLightTheme; diff --git a/packages/ra-ui-materialui/src/layout/AppBar.stories.tsx b/packages/ra-ui-materialui/src/layout/AppBar.stories.tsx index 33814f9b4fb..90082bdc662 100644 --- a/packages/ra-ui-materialui/src/layout/AppBar.stories.tsx +++ b/packages/ra-ui-materialui/src/layout/AppBar.stories.tsx @@ -2,13 +2,14 @@ import * as React from 'react'; import { Box, createTheme, - ThemeProvider, + ThemeProvider as MuiThemeProvider, MenuItem, ListItemIcon, ListItemText, TextField, Skeleton, MenuItemProps, + IconButton, } from '@mui/material'; import SettingsIcon from '@mui/icons-material/Settings'; import { QueryClientProvider, QueryClient } from 'react-query'; @@ -21,11 +22,7 @@ import { TitlePortal } from './TitlePortal'; import { UserMenu } from './UserMenu'; import { useUserMenu } from './useUserMenu'; import { defaultTheme } from '../defaultTheme'; -import { - ToggleThemeButton, - RefreshIconButton, - LocalesMenuButton, -} from '../button'; +import { ThemesContext, ThemeProvider } from './Theme'; import { Logout } from '../auth'; export default { @@ -53,12 +50,12 @@ const Content = () => ( const Wrapper = ({ children, theme = createTheme(defaultTheme) }) => ( <MemoryRouter> <QueryClientProvider client={new QueryClient()}> - <ThemeProvider theme={theme}> + <MuiThemeProvider theme={theme}> <AuthContext.Provider value={undefined as any}> {children} </AuthContext.Provider> <Content /> - </ThemeProvider> + </MuiThemeProvider> </QueryClientProvider> </MemoryRouter> ); @@ -159,6 +156,21 @@ export const WithAuthIdentity = () => ( </Wrapper> ); +export const WithThemes = () => ( + <Wrapper> + <ThemesContext.Provider + value={{ + darkTheme: { palette: { mode: 'dark' } }, + lightTheme: { palette: { mode: 'light' } }, + }} + > + <ThemeProvider> + <AppBar /> + </ThemeProvider> + </ThemesContext.Provider> + </Wrapper> +); + export const Toolbar = () => ( <Wrapper> <AppBar @@ -251,25 +263,24 @@ export const Complete = () => ( 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"' /> + <ThemesContext.Provider + value={{ + darkTheme: { palette: { mode: 'dark' } }, + lightTheme: { palette: { mode: 'light' } }, + }} + > + <ThemeProvider> + <AppBar + userMenu={ + <UserMenu> + <SettingsMenuItem /> + <Logout /> + </UserMenu> + } + /> + <Title title='Post "Lorem Ipsum Sic Dolor amet"' /> + </ThemeProvider> + </ThemesContext.Provider> </I18nContextProvider> </AuthContext.Provider> </Wrapper> @@ -290,14 +301,17 @@ export const WithSearch = () => ( </Wrapper> ); +const SettingsIconButton = () => ( + <IconButton color="inherit"> + <SettingsIcon /> + </IconButton> +); + export const Children = () => ( <Wrapper> <AppBar> <TitlePortal /> - <ToggleThemeButton - darkTheme={{ palette: { mode: 'dark' } }} - lightTheme={{ palette: { mode: 'light' } }} - /> + <SettingsIconButton /> <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 58fbd6ce63f..bc3b8ff42d8 100644 --- a/packages/ra-ui-materialui/src/layout/AppBar.tsx +++ b/packages/ra-ui-materialui/src/layout/AppBar.tsx @@ -18,6 +18,8 @@ import { UserMenu } from './UserMenu'; import { HideOnScroll } from './HideOnScroll'; import { TitlePortal } from './TitlePortal'; import { LocalesMenuButton } from '../button'; +import { useThemesContext } from './Theme/useThemesContext'; +import { ToggleThemeButton } from '../button/ToggleThemeButton'; /** * The AppBar component renders a custom MuiAppBar. @@ -93,9 +95,11 @@ export const AppBar: FC<AppBarProps> = memo(props => { const DefaultToolbar = () => { const locales = useLocales(); + const { darkTheme } = useThemesContext(); return ( <> {locales && locales.length > 1 ? <LocalesMenuButton /> : null} + {darkTheme && <ToggleThemeButton />} <LoadingIndicator /> </> ); diff --git a/packages/ra-ui-materialui/src/layout/Menu.stories.tsx b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx index f06569e27d2..cea7e07c307 100644 --- a/packages/ra-ui-materialui/src/layout/Menu.stories.tsx +++ b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx @@ -2,33 +2,14 @@ import * as React from 'react'; import { Resource, testDataProvider } from 'ra-core'; import { defaultTheme, Admin } from 'react-admin'; -import { AppBar, Typography, Box } from '@mui/material'; -import { createTheme } from '@mui/material/styles'; -import { ToggleThemeButton } from '../button'; -import { Layout, Menu, SidebarToggleButton, Title } from '.'; +import { Typography, ThemeOptions } from '@mui/material'; +import { Layout, Menu, Title } from '.'; export default { title: 'ra-ui-materialui/layout/Menu' }; const resources = ['Posts', 'Comments', 'Tags', 'Users', 'Orders', 'Reviews']; -const DemoAppBar = () => { - const darkTheme = createTheme({ - palette: { mode: 'dark' }, - }); - return ( - <AppBar elevation={1} sx={{ flexDirection: 'row', flexWrap: 'nowrap' }}> - <Box sx={{ flex: '1 1 100%' }}> - <SidebarToggleButton /> - </Box> - <Box sx={{ flex: '0 0 auto' }}> - <ToggleThemeButton - lightTheme={defaultTheme} - darkTheme={darkTheme} - /> - </Box> - </AppBar> - ); -}; +const darkTheme: ThemeOptions = { ...defaultTheme, palette: { mode: 'dark' } }; const DemoList = ({ name }) => ( <> @@ -38,47 +19,43 @@ const DemoList = ({ name }) => ( ); export const Default = () => { - const DefaultLayout = props => ( - <Layout {...props} menu={MenuDefault} appBar={DemoAppBar} /> - ); - const MenuDefault = () => { - return <Menu hasDashboard={true} dense={false} />; - }; + const MenuDefault = () => <Menu hasDashboard={true} dense={false} />; + const DefaultLayout = props => <Layout {...props} menu={MenuDefault} />; return ( - <Admin dataProvider={testDataProvider()} layout={DefaultLayout}> - {resources.map((resource, index) => { - return ( - <Resource - name={resource} - key={`resource_${index}`} - list={<DemoList name={resource} />} - /> - ); - })} + <Admin + dataProvider={testDataProvider()} + layout={DefaultLayout} + darkTheme={darkTheme} + > + {resources.map((resource, index) => ( + <Resource + name={resource} + key={`resource_${index}`} + list={<DemoList name={resource} />} + /> + ))} </Admin> ); }; export const Dense = () => { - const LayoutDense = props => ( - <Layout {...props} menu={MenuDense} appBar={DemoAppBar} /> - ); - const MenuDense = props => { - return <Menu {...props} hasDashboard={true} dense={true} />; - }; + const MenuDense = props => <Menu hasDashboard={true} dense={true} />; + const LayoutDense = props => <Layout {...props} menu={MenuDense} />; return ( - <Admin dataProvider={testDataProvider()} layout={LayoutDense}> - {resources.map((resource, index) => { - return ( - <Resource - name={resource} - key={`resource_${index}`} - list={<DemoList name={resource} />} - /> - ); - })} + <Admin + dataProvider={testDataProvider()} + layout={LayoutDense} + darkTheme={darkTheme} + > + {resources.map((resource, index) => ( + <Resource + name={resource} + key={`resource_${index}`} + list={<DemoList name={resource} />} + /> + ))} </Admin> ); }; diff --git a/packages/ra-ui-materialui/src/layout/Theme/ThemeProvider.spec.tsx b/packages/ra-ui-materialui/src/layout/Theme/ThemeProvider.spec.tsx new file mode 100644 index 00000000000..27795700cf9 --- /dev/null +++ b/packages/ra-ui-materialui/src/layout/Theme/ThemeProvider.spec.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import expect from 'expect'; +import { StoreContextProvider, memoryStore } from 'ra-core'; +import { Button, ThemeOptions } from '@mui/material'; + +import { ThemeProvider } from './ThemeProvider'; +import { ThemesContext } from './ThemesContext'; +import { ThemeTestWrapper } from '../ThemeTestWrapper'; + +const lightTheme: ThemeOptions = {}; +const darkTheme: ThemeOptions = { palette: { mode: 'dark' } }; + +const LIGHT_MODE_TEXT_COLOR = 'rgb(25, 118, 210)'; // text is dark blue in light mode +const DARK_MODE_TEXT_COLOR = 'rgb(144, 202, 249)'; // text is light blue in dark mode + +describe('ThemeProvider', () => { + it('should create a material-ui theme context based on the ThemesContext and theme preference light', () => { + render( + <StoreContextProvider value={memoryStore({ theme: 'light' })}> + <ThemesContext.Provider value={{ lightTheme, darkTheme }}> + <ThemeProvider> + <Button>Test</Button> + </ThemeProvider> + </ThemesContext.Provider> + </StoreContextProvider> + ); + const button = screen.getByText('Test'); + expect(getComputedStyle(button).color).toBe(LIGHT_MODE_TEXT_COLOR); + }); + + it('should create a material-ui theme context based on the ThemesContext and theme preference dark', () => { + render( + <StoreContextProvider value={memoryStore({ theme: 'dark' })}> + <ThemesContext.Provider value={{ lightTheme, darkTheme }}> + <ThemeProvider> + <Button>Test</Button> + </ThemeProvider> + </ThemesContext.Provider> + </StoreContextProvider> + ); + const button = screen.getByText('Test'); + expect(getComputedStyle(button).color).toBe(DARK_MODE_TEXT_COLOR); + }); + + it('should default to a light theme when no theme preference is set', () => { + render( + <ThemesContext.Provider value={{ lightTheme, darkTheme }}> + <ThemeProvider> + <Button>Test</Button> + </ThemeProvider> + </ThemesContext.Provider> + ); + const button = screen.getByText('Test'); + expect(getComputedStyle(button).color).toBe(LIGHT_MODE_TEXT_COLOR); + }); + + it('should default to light theme when the browser detects a light mode preference', () => { + render( + <ThemeTestWrapper mode="light"> + <ThemesContext.Provider value={{ lightTheme, darkTheme }}> + <ThemeProvider> + <Button>Test</Button> + </ThemeProvider> + </ThemesContext.Provider> + </ThemeTestWrapper> + ); + const button = screen.getByText('Test'); + expect(getComputedStyle(button).color).toBe(LIGHT_MODE_TEXT_COLOR); + }); + + it('should default to dark theme when the browser detects a dark mode preference', () => { + render( + <ThemeTestWrapper mode="dark"> + <ThemesContext.Provider value={{ lightTheme, darkTheme }}> + <ThemeProvider> + <Button>Test</Button> + </ThemeProvider> + </ThemesContext.Provider> + </ThemeTestWrapper> + ); + const button = screen.getByText('Test'); + expect(getComputedStyle(button).color).toBe(DARK_MODE_TEXT_COLOR); + }); + + it('should fallback to using theme prop when used outside of a ThemesContext (for backwards compatibility)', () => { + render( + <ThemeProvider theme={darkTheme}> + <Button>Test</Button> + </ThemeProvider> + ); + const button = screen.getByText('Test'); + expect(getComputedStyle(button).color).toBe(DARK_MODE_TEXT_COLOR); + }); +}); diff --git a/packages/ra-ui-materialui/src/layout/Theme/ThemeProvider.tsx b/packages/ra-ui-materialui/src/layout/Theme/ThemeProvider.tsx index f4df0c16c1e..243f8023a3f 100644 --- a/packages/ra-ui-materialui/src/layout/Theme/ThemeProvider.tsx +++ b/packages/ra-ui-materialui/src/layout/Theme/ThemeProvider.tsx @@ -4,35 +4,66 @@ import { ThemeProvider as MuiThemeProvider, createTheme, } from '@mui/material/styles'; -import { ThemeOptions } from '@mui/material'; +import { useMediaQuery } from '@mui/material'; +import { RaThemeOptions } from './types'; import { useTheme } from './useTheme'; +import { useThemesContext } from './useThemesContext'; /** - * This sets the Material UI theme based on the store. + * This sets the Material UI theme based on the preferred theme type. * * @param props * @param props.children The children of the component. - * @param props.theme The initial theme. + * @param {ThemeOptions} props.theme The initial theme. Optional, use the one from the context if not provided. + * + * @example + * + * import { ThemesContext, ThemeProvider } from 'react-admin'; + * + * const App = () => ( + * <ThemesContext.Provider value={{ lightTheme, darkTheme }}> + * <ThemeProvider> + * <Button>Test</Button> + * </ThemeProvider> + * </ThemesContext.Provider> + * ); */ export const ThemeProvider = ({ children, theme: themeOverride, }: ThemeProviderProps) => { - const [theme] = useTheme(themeOverride); + const { lightTheme, darkTheme, defaultTheme } = useThemesContext(); + + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)', { + noSsr: true, + }); + const [mode] = useTheme( + defaultTheme || (prefersDarkMode && darkTheme ? 'dark' : 'light') + ); + const themeValue = useMemo(() => { try { - return createTheme(theme); + return createTheme( + typeof mode === 'object' + ? mode // FIXME: legacy useTheme, to be removed in v5 + : mode === 'dark' + ? darkTheme + : lightTheme || themeOverride + ); } catch (e) { console.warn('Failed to reuse custom theme from store', e); return createTheme(); } - }, [theme]); + }, [mode, themeOverride, lightTheme, darkTheme]); return <MuiThemeProvider theme={themeValue}>{children}</MuiThemeProvider>; }; export interface ThemeProviderProps { children: ReactNode; - theme: ThemeOptions; + /** + * @deprecated Use the `ThemesProvider` component instead. + */ + theme?: RaThemeOptions; } diff --git a/packages/ra-ui-materialui/src/layout/Theme/ThemesContext.ts b/packages/ra-ui-materialui/src/layout/Theme/ThemesContext.ts new file mode 100644 index 00000000000..a2bce9111d5 --- /dev/null +++ b/packages/ra-ui-materialui/src/layout/Theme/ThemesContext.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; +import { RaThemeOptions } from './types'; + +export const ThemesContext = createContext<ThemesContextValue>({}); + +export interface ThemesContextValue { + darkTheme?: RaThemeOptions; + lightTheme?: RaThemeOptions; + defaultTheme?: 'dark' | 'light'; +} diff --git a/packages/ra-ui-materialui/src/layout/Theme/index.ts b/packages/ra-ui-materialui/src/layout/Theme/index.ts index 8066c3f6818..384268cb02d 100644 --- a/packages/ra-ui-materialui/src/layout/Theme/index.ts +++ b/packages/ra-ui-materialui/src/layout/Theme/index.ts @@ -1,2 +1,5 @@ export * from './useTheme'; export * from './ThemeProvider'; +export * from './ThemesContext'; +export * from './useThemesContext'; +export * from './types'; diff --git a/packages/ra-ui-materialui/src/layout/Theme/types.ts b/packages/ra-ui-materialui/src/layout/Theme/types.ts new file mode 100644 index 00000000000..5f510a2c429 --- /dev/null +++ b/packages/ra-ui-materialui/src/layout/Theme/types.ts @@ -0,0 +1,15 @@ +import { ThemeOptions as MuiThemeOptions } from '@mui/material'; + +export type ComponentsTheme = { + [key: string]: any; +}; + +export interface RaThemeOptions extends MuiThemeOptions { + sidebar?: { + width?: number; + closedWidth?: number; + }; + components?: ComponentsTheme; +} + +export type ThemeType = 'light' | 'dark'; diff --git a/packages/ra-ui-materialui/src/layout/Theme/useTheme.spec.tsx b/packages/ra-ui-materialui/src/layout/Theme/useTheme.spec.tsx index 606d38e112b..b0923706afa 100644 --- a/packages/ra-ui-materialui/src/layout/Theme/useTheme.spec.tsx +++ b/packages/ra-ui-materialui/src/layout/Theme/useTheme.spec.tsx @@ -1,10 +1,9 @@ import * as React from 'react'; -import { CoreAdminContext, useStore } from 'ra-core'; +import { CoreAdminContext, useStore, memoryStore } from 'ra-core'; import expect from 'expect'; import { render, screen } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import { useTheme } from './useTheme'; -import { SimplePaletteColorOptions } from '@mui/material'; const authProvider = { login: jest.fn().mockResolvedValueOnce(''), @@ -16,28 +15,43 @@ const authProvider = { const Foo = () => { const [theme] = useTheme(); - return theme !== undefined ? <div aria-label="has-theme" /> : <></>; + return theme !== undefined ? ( + <div aria-label="has-theme">{theme}</div> + ) : ( + <></> + ); }; describe('useTheme', () => { - it('should return current theme', () => { + it('should return undefined by default', () => { render( <CoreAdminContext authProvider={authProvider}> <Foo /> </CoreAdminContext> ); + expect(screen.queryByLabelText('has-theme')).toBeNull(); + }); + + it('should return current theme when set', () => { + render( + <CoreAdminContext + authProvider={authProvider} + store={memoryStore({ theme: 'dark' })} + > + <Foo /> + </CoreAdminContext> + ); expect(screen.getByLabelText('has-theme')).not.toBeNull(); }); it('should return theme from settings when available', () => { const { result: storeResult } = renderHook(() => useStore('theme')); - storeResult.current[1]({ palette: { primary: { main: 'red' } } }); + const [_, setTheme] = storeResult.current; + setTheme('dark'); const { result: themeResult } = renderHook(() => useTheme()); + const [theme, __] = themeResult.current; - expect(storeResult.current[0].palette.primary.main).toEqual( - (themeResult.current[0].palette - ?.primary as SimplePaletteColorOptions).main - ); + expect(theme).toEqual('dark'); }); }); diff --git a/packages/ra-ui-materialui/src/layout/Theme/useTheme.ts b/packages/ra-ui-materialui/src/layout/Theme/useTheme.ts index 65afc529ae8..ec3ead4cdd7 100644 --- a/packages/ra-ui-materialui/src/layout/Theme/useTheme.ts +++ b/packages/ra-ui-materialui/src/layout/Theme/useTheme.ts @@ -1,12 +1,29 @@ import { useStore } from 'ra-core'; -import { ThemeOptions, useTheme as useThemeMUI } from '@mui/material'; +import { RaThemeOptions, ThemeType } from './types'; -export type ThemeSetter = (theme: ThemeOptions) => void; +export type ThemeSetter = (theme: ThemeType | RaThemeOptions) => void; +/** + * Read and update the theme mode (light or dark) + * + * @example + * const [theme, setTheme] = useTheme('light'); + * const toggleTheme = () => { + * setTheme(theme === 'light' ? 'dark' : 'light'); + * }; + * + * @example // legacy mode, stores the full theme object + * // to be removed in v5 + * const [theme, setTheme] = useTheme({ + * palette: { + * type: 'light', + * }, + * }); + */ export const useTheme = ( - themeOverride?: ThemeOptions -): [ThemeOptions, ThemeSetter] => { - const themeMUI = useThemeMUI(); - const [theme, setter] = useStore('theme', themeOverride); - return [theme || themeMUI, setter]; + type?: ThemeType | RaThemeOptions +): [ThemeType | RaThemeOptions, ThemeSetter] => { + // FIXME: remove legacy mode in v5, and remove the RaThemeOptions type + const [theme, setter] = useStore<ThemeType | RaThemeOptions>('theme', type); + return [theme, setter]; }; diff --git a/packages/ra-ui-materialui/src/layout/Theme/useThemesContext.ts b/packages/ra-ui-materialui/src/layout/Theme/useThemesContext.ts new file mode 100644 index 00000000000..16046974975 --- /dev/null +++ b/packages/ra-ui-materialui/src/layout/Theme/useThemesContext.ts @@ -0,0 +1,21 @@ +import { useContext } from 'react'; + +import { ThemesContext } from './ThemesContext'; +import { RaThemeOptions } from './types'; + +export const useThemesContext = (params?: UseThemesContextParams) => { + const { lightTheme, darkTheme, defaultTheme } = params || {}; + const context = useContext(ThemesContext); + return { + lightTheme: lightTheme || context.lightTheme, + darkTheme: darkTheme || context.darkTheme, + defaultTheme: defaultTheme ?? context.defaultTheme, + }; +}; + +export interface UseThemesContextParams { + lightTheme?: RaThemeOptions; + darkTheme?: RaThemeOptions; + defaultTheme?: 'dark' | 'light'; + [key: string]: any; +} diff --git a/packages/ra-ui-materialui/src/layout/ThemeTestWrapper.tsx b/packages/ra-ui-materialui/src/layout/ThemeTestWrapper.tsx new file mode 100644 index 00000000000..de7712422c1 --- /dev/null +++ b/packages/ra-ui-materialui/src/layout/ThemeTestWrapper.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; + +/** + * Test utility to simulate a preferred theme mode (light or dark) + * + * Do not use inside a browser. + * + * @example + * + * <ThemeTestWrapper mode="dark"> + * <MyComponent /> + * <ThemeTestWrapper> + */ +export const ThemeTestWrapper = ({ + mode = 'light', + children, +}: ThemeTestWrapperProps): JSX.Element => { + const theme = createTheme(); + const ssrMatchMedia = query => ({ + matches: + mode === 'dark' && query === '(prefers-color-scheme: dark)' + ? true + : false, + }); + + return ( + <ThemeProvider + theme={{ + ...theme, + components: { + MuiUseMediaQuery: { + defaultProps: { + ssrMatchMedia, + matchMedia: ssrMatchMedia, + }, + }, + }, + }} + > + {children} + </ThemeProvider> + ); +}; + +export interface ThemeTestWrapperProps { + mode: 'light' | 'dark'; + children: JSX.Element; +} 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 c088d8c7e71..1e5203fd8d8 100644 --- a/packages/ra-ui-materialui/src/list/filter/SavedQueriesList.stories.tsx +++ b/packages/ra-ui-materialui/src/list/filter/SavedQueriesList.stories.tsx @@ -3,15 +3,11 @@ import merge from 'lodash/merge'; import { Admin, - AppBar, defaultTheme, - Layout, - LayoutProps, Resource, Datagrid, List, TextField, - TitlePortal, NumberField, DateField, FilterList, @@ -26,7 +22,6 @@ import frenchMessages from 'ra-language-french'; import { createMemoryHistory } from 'history'; import { SavedQueriesList } from './SavedQueriesList'; -import { LocalesMenuButton, ToggleThemeButton } from '../../button'; import { RaThemeOptions } from '../..'; import fakeRestProvider from 'ra-data-fakerest'; @@ -185,8 +180,13 @@ const i18nProvider = polyglotI18nProvider( locale === 'fr' ? merge(frenchMessages, frenchAppMessages) : englishMessages, - 'en' // Default locale + 'en', // Default locale + [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'Français' }, + ] ); + const darkTheme: RaThemeOptions = { ...defaultTheme, palette: { @@ -200,29 +200,12 @@ const darkTheme: RaThemeOptions = { }, }; -const MyAppBar = () => ( - <AppBar> - <TitlePortal /> - <ToggleThemeButton lightTheme={defaultTheme} darkTheme={darkTheme} /> - <LocalesMenuButton - languages={[ - { locale: 'en', name: 'English' }, - { locale: 'fr', name: 'Français' }, - ]} - /> - </AppBar> -); - -const MyLayout = (props: LayoutProps) => ( - <Layout {...props} appBar={MyAppBar} /> -); - export const WithThemeAndLocale = () => ( <Admin history={createMemoryHistory()} i18nProvider={i18nProvider} dataProvider={dataProvider} - layout={MyLayout} + darkTheme={darkTheme} > <Resource name="songs" list={SongList} /> </Admin> diff --git a/packages/react-admin/src/Admin.tsx b/packages/react-admin/src/Admin.tsx index 65ffeee7069..32e36c59979 100644 --- a/packages/react-admin/src/Admin.tsx +++ b/packages/react-admin/src/Admin.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; import { ComponentType } from 'react'; import { CoreAdminProps, localStorageStore } from 'ra-core'; -import { AdminUI, AdminContext } from 'ra-ui-materialui'; -import { ThemeOptions } from '@mui/material'; +import { AdminUI, AdminContext, RaThemeOptions } from 'ra-ui-materialui'; import { defaultI18nProvider } from './defaultI18nProvider'; @@ -111,6 +110,9 @@ export const Admin = (props: AdminProps) => { store, ready, theme, + lightTheme, + darkTheme, + defaultTheme, title = 'React Admin', } = props; @@ -130,6 +132,9 @@ export const Admin = (props: AdminProps) => { history={history} queryClient={queryClient} theme={theme} + lightTheme={lightTheme} + darkTheme={darkTheme} + defaultTheme={defaultTheme} > <AdminUI layout={layout} @@ -159,6 +164,9 @@ Admin.defaultProps = { export default Admin; export interface AdminProps extends CoreAdminProps { - theme?: ThemeOptions; + theme?: RaThemeOptions; + lightTheme?: RaThemeOptions; + darkTheme?: RaThemeOptions; + defaultTheme?: 'light' | 'dark'; notification?: ComponentType; }