diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 6d0ae970c63..03afc0bd986 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -20,7 +20,7 @@ /> @@ -134,7 +134,9 @@ + + - {% if page.dir contains "doc" %} {% assign version = page.dir | split: @@ -185,21 +186,5 @@ var AUTOHIDE = Boolean(0); - - - - diff --git a/docs/assets/features.md b/docs/assets/features.md new file mode 100644 index 00000000000..b5bdfff2d5f --- /dev/null +++ b/docs/assets/features.md @@ -0,0 +1,350 @@ +If you don't use `` in a list, you'll need a control to let users choose the sort order. The `` component does just that. #list https://marmelab.com/react-admin/SortButton.html + +![SortButton](https://marmelab.com/react-admin/img/sort-button.gif) + +--- + +The filter sidebar is a great way to let users filter a list by selecting possible filter values with the mouse. But for a full-text search, you need a form input with a watch on the value. That's what `` does. #list https://marmelab.com/react-admin/FilterLiveSearch.html + +![FilterLiveSearch](https://marmelab.com/react-admin/img/filter-live-search.gif) + +--- + +`` renders the number of records related to another record via a one-to-many relationship (e.g. the number of comments related to a post). It calls dataProvider.getManyReference() with the pagination parameter set to retrieve no data - only the total number of records. #relationships https://marmelab.com/react-admin/ReferenceManyCount.html + +![ReferenceManyCount](https://marmelab.com/react-admin/img/reference_many_count.webp) + +--- + +The `` component lets users switch from light to dark mode, and persists that choice by leveraging the store. #preferences https://marmelab.com/react-admin/ToggleThemeButton.html + +![ToggleThemeButton](https://marmelab.com/react-admin/img/ToggleThemeButton.gif) + +--- + +If you need horizontal space, switch the classic menu for an ``. It renders a reduced menu bar with a sliding panel for second-level menu items. This menu saves a lot of screen real estate, and allows for sub menus of any level of complexity. #UI https://marmelab.com/react-admin/IconMenu.html + +![IconMenu](https://react-admin-ee.marmelab.com/assets/ra-multilevelmenu-categories.gif) + +--- + +To let user enter a boolean value, you can use the `` to render a switch. But most often, you need the ``, which renders a select input with three choices: "Yes", "No" and "" (empty string). It is necessary to distinguish between false and empty values #input https://marmelab.com/react-admin/NullableBooleanInput.html + +![NullableBooleanInput](https://marmelab.com/react-admin/img/nullable-boolean-input.gif) + +--- + +The `` component renders a confirmation dialog. It's useful to confirm destructive actions, such as deleting a record. #ui https://marmelab.com/react-admin/Confirm.html + +![Confirm dialog](https://marmelab.com/react-admin/img/confirm-dialog.png) + +--- + +`` has one drawback: users have to click on it to see the choices. `` is a better alternative for a small list of choices, as it shows all the options by default. #input https://marmelab.com/react-admin/CheckboxGroupInput.html + +![CheckboxGroupInput](https://marmelab.com/react-admin/img/checkbox-group-input.gif) + +--- + +When a form becomes too complex, organize the inputs in different sections with the ``. #form https://marmelab.com/react-admin/TabbedForm.html + +![TabbedForm](https://marmelab.com/react-admin/img/tabbed-form.gif) + +--- + +To provide users visibility on their progression through a complex form, use the ``. #form https://marmelab.com/react-admin/WizardForm.html + +![WizardForm](https://react-admin-ee.marmelab.com/assets/ra-wizard-form-overview.gif) + +--- + +If you need nested forms to edit e.g. variants of a product, use the `` component. It leverages the `` to let you edit record in another table. #form https://marmelab.com/react-admin/ReferenceManyInput.html + +![ReferenceManyInput](https://marmelab.com/react-admin/img/reference-many-input.gif) + +--- + +If your app relies heavily on filters, you can offer your users the ability to save their favorite filters. The `` component lets users save the current filter and sort parameters, and retrieve them later. #list https://marmelab.com/react-admin/FilteringTutorial.html#saved-queries-let-users-save-filter-and-sort + + + +--- + +You can let end users customize the fields displayed in the `` by using the `` component instead. #list https://marmelab.com/react-admin/Datagrid.html#configurable + + + +--- + +If you are working with records that can be edited by only one user at a time, you can use react-admin to put locks on records. The `useLockOnMount` hook will lock the record when the component mounts, while the `useLockOnCall` hook allows you to trigger the lock manually. #realtime https://marmelab.com/react-admin/useLockOnMount.html + + + +--- + +You can leverage react-admin's realtime abilities to get live updates about the lock status of records. Use `useGetLockLive` to get the live lock status for a specific record, or `useGetLocksLive` to get them all for a specific resource. #realtime https://marmelab.com/react-admin/useGetLockLive.html + + + +--- + +Many end-users prefer an Excel-like experience when browsing a list of records. They want to see the first records, then scroll down to see more, and so on. This is called infinite pagination, and it's supported in react-admin. #list https://marmelab.com/react-admin/InfiniteList.html + + + +--- + +With your admin getting bigger, or if you have to manage many resources and/or sub-resources, it might be a good idea to add a breadcrumb to your app. The `` component will do just that. #ui https://marmelab.com/react-admin/Breadcrumb.html + + + +--- + +React-admin ships with a powerful and versatile validation engine. It supports the most common validation strategies: + +- per field validation +- form validation +- schema validation, e.g. powered by yup or zod +- server-side validation + +\#forms https://marmelab.com/react-admin/Features.html#forms--validation + +![Validation example](https://marmelab.com/react-admin/img/validation.png) + +--- + +The `` component, also known as the “language switcher”, displays a menu allowing users to select the language of the interface. It leverages the store to persist their selection. #i18n #preferences https://marmelab.com/react-admin/LocalesMenuButton.html + + + +--- + +To bootstrap a List view based on a new API route, you can use the `` component to generate the JSX for the table columns for you. #list https://marmelab.com/react-admin/ListGuesser.html + +![ListGuesser](https://marmelab.com/react-admin/img/guessed-list.png) + +--- + +To bootstrap a new react-admin project, prefer the `create-react-admin` package. It will generate a fully functional React App powered by Vite, complete with a data provider, an auth provider, and a few example resources. #getting-started https://marmelab.com/react-admin/CreateReactAdmin.html + + + +--- + +Edition forms in react-admin have a built-in "Undo" feature, letting end users cancel a form submissions a few seconds after they have submitted it. You can disable this built-in feature by setting `mutationMode` as `pessimistic`. #form https://marmelab.com/react-admin/Features.html#forms--undo + + + +--- + +`` is designed to be a page component, passed to the edit prop of the `` component. But you may want to let users edit a record in a dialiog without leaving the context of the list page. If so, you can use the `` component. #form https://marmelab.com/react-admin/EditDialog.html + + + +--- + +If you want to let users edit a record from another page, use the `` component. #form https://marmelab.com/react-admin/EditInDialogButton.html + + + +--- + +The `` component lets users save a form automatically after a given delay. It's useful to avoid losing data when users navigate away from a form without submitting it. #form https://marmelab.com/react-admin/AutoSave.html + + + +--- + +Use `` in an `` or `` view to edit a record linked to the current record via a one-to-one relationship, e.g. to edit the details of a book in the book edition view. #form https://marmelab.com/react-admin/ReferenceOneInput.html + + + +--- + +The `` component renders a global search input. It’s designed to be always accessible in the top ``. #ui https://marmelab.com/react-admin/Search.html + + + +--- + +The `` component makes another component configurable by the end user. When users enter the configuration mode, they can customize the component’s settings via the inspector. #ui https://marmelab.com/react-admin/Configurable.html + + + +--- + +In the case you want keep track of user actions, and get an overview of the activity of your admin, you can display event lists and audit logs with ``, `` and with `` components. #activitylog #timeline #eventlog https://marmelab.com/ra-enterprise/modules/ra-audit-log + + + +--- + +To show more data from a resource without adding too many columns, you can show data in an expandable panel below the row on demand, using the `` prop. #datagrid https://marmelab.com/react-admin/Datagrid.html#expand + + + +--- +With your admin getting bigger, the default sidebar menu might become too crowded. The `SolarLayout` is a beautiful alternative layout that help you organize your pages. #ui + + + +--- + +If your app needs to display **events**, **appointments**, **time intervals**, or any other kind of time-based data, you can use the `` component. #ui https://marmelab.com/react-admin/Calendar.html + + + +--- + +`` allows you to perform bulk actions such as mass deletion or mass edition. You can add other bulk action buttons by passing a custom element as the `bulkActionButtons` prop of the `` component. #datagrid https://marmelab.com/react-admin/Datagrid.html#bulkactionbuttons + + + +--- + +The `` component renders a search input and the search results directly below the input. It's ideal for dashboards or menu panels. #ui #search https://marmelab.com/ra-enterprise/modules/ra-search#searchwithresult + + + +--- + +All Field components accept a type parameter that describes the record. This lets TypeScript validate that the `source` prop targets an actual field of the record, and will also allow your IDE to provide auto-completion for both the `source` and `sortBy` props. `` will also allow TypeScript to provide auto-completion for the `record` fields. #typescript https://marmelab.com/react-admin/Fields.html#typescript + +![FunctionField TypeScript](https://marmelab.com/static/44d79f8feb06b87c363c301395037081/df77d/typescript-function-field.webp) + +--- + +React Admin supports translation out of the box. It supports features like interpolation, pluralization and default translation. You can customize the translation keys to translate any part of the UI, or you can use the `useTranslate` hook to easily translate custom components. #i18n https://marmelab.com/react-admin/Translation.html + + + +--- + +The `` component renders navigation buttons linking to the next or previous record of a resource. It also renders the current index and the total number of records. #ux #navigation https://marmelab.com/react-admin/PrevNextButtons.html + + + +--- + +`` offers a replacement for `` when the records form a **tree structure** (like directories, categories, etc.). It allows to render the tree alongside the show/edit views. #list #tree https://marmelab.com/react-admin/TreeWithDetails.html + + + +--- + +The `` component allows to select one or several nodes from a tree. #input #tree https://marmelab.com/react-admin/TreeInput.html + + + +--- + +React-admin Enterprise Edition proposes alternative components to ``, `` and `` based on the MUI X Date/Time pickers. They allow for more customization of the UI, and make it easier to work with specific locale and date formats. #input #date https://react-admin-ee.marmelab.com/documentation/ra-form-layout#dateinput-datetimeinput-and-timeinput + + + +--- + +When your admin application is a Single Page Application, users who keep a browser tab open at all times might not use the most recent version of the application. + +If you include the `` component to your layout, React Admin will regularly check whether the application source code has changed and prompts users to reload the page when an update is available. #spa #ux https://marmelab.com/react-admin/CheckForApplicationUpdate.html + +![CheckForApplicationUpdate](https://marmelab.com/react-admin/img/CheckForApplicationUpdate.png) + +--- + +If you need to display an enumerated field in a list component like Datagrid, `` allows to easily map the value to a string. #list #datagrid https://marmelab.com/react-admin/SelectField.html + +![SelectField](https://marmelab.com/react-admin/img/SelectField.png) + diff --git a/docs/assets/tips.md b/docs/assets/tips.md new file mode 100644 index 00000000000..b0b6d5a004e --- /dev/null +++ b/docs/assets/tips.md @@ -0,0 +1,1943 @@ +You can restrict access to certain pages based on the current user's permissions. For example, to restrict the ability to edit customer details or to add new admin users: + +```jsx +const App = () => ( + + {(permissions) => ( + <> + {/* Restrict access to the edit view to admin only */} + + {/* Only include the adminUsers resource for admin users */} + {permissions === "admin" ? ( + + ) : null} + + )} + +); +``` + +https://marmelab.com/react-admin/Permissions.html#restricting-access-to-resources-or-views + +--- + +Use `` with a `sort` prop to display the latest record related to the current one. For instance, to render the latest message in a discussion: + +{% raw %} + +```jsx + + + +``` + +{% endraw %} + +https://marmelab.com/react-admin/ReferenceOneField.html#usage + +--- + +Use `useAuthenticated` to restrict access to custom pages to authenticated users. + +```jsx +import { useAuthenticated } from "react-admin"; + +const MyPage = () => { + useAuthenticated(); // redirects to login if not authenticated + return
...
; +}; + +export default MyPage; +``` + +https://marmelab.com/react-admin/useAuthenticated.html + +--- + +Add the following theme override to remove bottom gutters on the last row of all ``, and hence improve the UI of all lists, especially when using rounded corners. + +```js +import { defaultTheme } from "react-admin"; + +export const lightTheme = { + ...defaultTheme, + components: { + ...defaultTheme.components, + MuiTableRow: { + styleOverrides: { + root: { + "&:last-child td": { border: 0 }, + }, + }, + }, + }, +}; +``` + +https://github.com/marmelab/react-admin/blob/master/examples/demo/src/layout/themes.ts#L102 + +--- + +If you want to publish a public list page in an application that uses authentication, you must set the `disableAuthentication` prop to `true` on the `` component. #security + +```jsx +import { List } from "react-admin"; + +const BookList = () => ...; +``` + +https://marmelab.com/react-admin/List.html#disableauthentication + +--- + +If you use `useGetOne` in a custom component that may be used more than once per page, prefer the `useGetManyAggregate` hook to group and deduplicate all API requests into a single one. #performance + +```diff +-import { useGetOne, useRecordContext } from 'react-admin'; ++import { useGetManyAggregate, useRecordContext } from 'react-admin'; + +const UserProfile = () => { + const record = useRecordContext(); +- const { data: user, isLoading, error } = useGetOne('users', { id: record.userId }); ++ const { data: users, isLoading, error } = useGetManyAggregate('users', { ids: [record.userId] }); + if (isLoading) { return ; } + if (error) { return

ERROR

; } +- return
User {user.username}
; ++ return
User {users[0].username}
; +}; +``` + +https://marmelab.com/react-admin/useGetOne.html#aggregating-getone-calls + +--- + +The filter sidebar is a great way to let users filter a list by selecting possible filter values with the mouse. But for a full-text search, you need a form input with a watch on the value. That's what `` does. #list + +```diff ++import { FilterLiveSearch } from 'react-admin'; + +const FilterSidebar = () => ( + + ++ + + + + + + +); +``` + +https://marmelab.com/react-admin/FilterLiveSearch.html + +--- + +If you don't use `` in a list, you'll need a control to let users choose the sort order. The `` component does just that. #list + +```jsx +import * as React from "react"; +import { + TopToolbar, + SortButton, + CreateButton, + ExportButton, +} from "react-admin"; + +const ListActions = () => ( + + + + + +); +``` + +https://marmelab.com/react-admin/SortButton.html + +--- + +Sometimes, the default views from react-admin may not match the design you want. For instance, you may want to have more prominent page titles, add breadcrumbs or display the view actions elsewhere. React-admin provides _base_ components for each view (e.g. `` instead of ``). These headless components give you full control over the UI: + +```jsx +import * as React from "react"; +import { EditBase, SimpleForm, TextInput, SelectInput } from "react-admin"; +import { Card } from "@mui/material"; + +export const BookEdit = () => ( + +
+ + <Card> + <SimpleForm> + <TextInput source="title" /> + <TextInput source="author" /> + <SelectInput source="availability" choices={[ + { id: "in_stock", name: "In stock" }, + { id: "out_of_stock", name: "Out of stock" }, + { id: "out_of_print", name: "Out of print" }, + ]} /> + </SimpleForm> + </Card> + </div> + </EditBase> +); +``` + +--- + +When leveraging `<ReferenceInput>` to let users select a related record from a list, you may want to display only a subset of the related records (e.g. only the published posts). You can do so by adding a `filter` prop to the `<ReferenceInput>`. #relationship https://marmelab.com/react-admin/ReferenceInput.html#filter + +{% raw %} + +```jsx +<ReferenceInput + source="post_id" + reference="posts" + filter={{ is_published: true }} +/> +``` + +{% endraw %} + +--- + +The `sx` prop is responsive: you can set different values depending on the breakpoint. #style https://marmelab.com/react-admin/Theming.html#sx-overriding-a-component-style + +{% raw %} + +```jsx +<Box + sx={{ + width: { + xs: 100, // theme.breakpoints.up('xs') + sm: 200, // theme.breakpoints.up('sm') + md: 300, // theme.breakpoints.up('md') + lg: 400, // theme.breakpoints.up('lg') + xl: 500, // theme.breakpoints.up('xl') + }, + }} +> + This box has a responsive width. +</Box> +``` + +{% endraw %} + +--- + +The `<ListLive>` component is a `<List>` that automatically refreshes when the data changes. #list #realtime https://marmelab.com/react-admin/ListLive.html + +```jsx +import { Datagrid, TextField } from "react-admin"; +import { ListLive } from "@react-admin/ra-realtime"; + +const PostList = () => ( + <ListLive> + <Datagrid> + <TextField source="title" /> + </Datagrid> + </ListLive> +); +``` + +--- + +Don't add an optional "s" to a name depending on the item count: `useTranslate` supports pluralization. #i18n https://marmelab.com/react-admin/useTranslate.html#using-pluralization-and-interpolation + +```jsx +const messages = { + 'hello_name': 'Hello, %{name}', + 'count_beer': 'One beer |||| %{smart_count} beers', +}; + +const translate = useTranslate(); + +// interpolation +translate('hello_name', { name: 'John Doe' }); +=> 'Hello, John Doe.' + +// pluralization +translate('count_beer', { smart_count: 1 }); +=> 'One beer' + +translate('count_beer', { smart_count: 2 }); +=> '2 beers' +``` + +--- + +`<SelectInput>` can render certain options as disabled. Just set the `disabled:true` property on the option. #input https://marmelab.com/react-admin/SelectInput.html#disablevalue + +```jsx +const choices = [ + { id: "tech", name: "Tech" }, + { id: "lifestyle", name: "Lifestyle" }, + { id: "people", name: "People", disabled: true }, +]; +<SelectInput source="category" choices={choices} />; +``` + +--- + +If you need to transform or combine multiple values to render a field, `<FunctionField>` is the perfect match. + +While `render` is the only required prop, when used inside a `<Datagrid>`, you can also provide a `source` or a `sortBy` prop to make the column sortable. Indeed when a user clicks on a column, `<Datagrid>` uses these props to sort. Should you provide both, `sortBy` will override `source` for sorting the column. + +#field https://marmelab.com/react-admin/FunctionField.html + +```diff +<Datagrid> + <FunctionField ++ source="last_name" + render={customer => + customer + ? `${customer.first_name} ${customer.last_name}` + : '' + } + /> +</Datagrid> +``` + +--- + +Need to setup a POC quickly and you don't have an API yet? Use the FakeRest data provider and get a local fake REST server based on simple JSON objects! #dataFetching https://www.npmjs.com/package/ra-data-fakerest + +```jsx +import * as React from "react"; +import { Admin, Resource } from "react-admin"; +import fakeDataProvider from "ra-data-fakerest"; + +const dataProvider = fakeDataProvider({ + posts: [ + { id: 0, title: "Hello, world!" }, + { id: 1, title: "FooBar" }, + ], + comments: [ + { id: 0, post_id: 0, author: "John Doe", body: "Sensational!" }, + { id: 1, post_id: 0, author: "Jane Doe", body: "I agree" }, + ], +}); + +import { PostList } from "./posts"; + +const App = () => ( + <Admin dataProvider={dataProvider}> + <Resource name="posts" list={PostList} /> + </Admin> +); + +export default App; +``` + +--- + +If the provided form layouts do not suit your needs, you can build your own with the `<Form>` component. #form https://marmelab.com/react-admin/Form.html + +```jsx +import { + Create, + Form, + TextInput, + RichTextInput, + SaveButton, +} from "react-admin"; +import { Grid } from "@mui/material"; + +export const PostCreate = () => ( + <Create> + <Form> + <Grid container> + <Grid item xs={6}> + <TextInput source="title" fullWidth /> + </Grid> + <Grid item xs={6}> + <TextInput source="author" fullWidth /> + </Grid> + <Grid item xs={12}> + <RichTextInput source="body" fullWidth /> + </Grid> + <Grid item xs={12}> + <SaveButton /> + </Grid> + </Grid> + </Form> + </Create> +); +``` + +--- + +By default, react-admin synchronizes the `<List>` parameters (sort, pagination, filters) with the query string in the URL (using react-router location). + +When you use a `<List>` component anywhere else than as `<Resource list>`, you may want to disable this feature. +This allows, among others, to have multiple lists on a single page. + +To do so, pass the `disableSyncWithLocation` prop to the `<List>`. + +#list https://marmelab.com/react-admin/List.html#disablesyncwithlocation + +```jsx +const Dashboard = () => ( + <div> + <ResourceContextProvider value="posts"> + <List disableSyncWithLocation> + <SimpleList + primaryText={(record) => record.title} + secondaryText={(record) => `${record.views} views`} + tertiaryText={(record) => + new Date(record.published_at).toLocaleDateString() + } + /> + </List> + </ResourceContextProvider> + <ResourceContextProvider value="comments"> + <List disableSyncWithLocation> + <SimpleList + primaryText={(record) => record.title} + secondaryText={(record) => `${record.views} views`} + tertiaryText={(record) => + new Date(record.published_at).toLocaleDateString() + } + /> + </List> + </ResourceContextProvider> + </div> +); +``` + +--- + +If you need to have multiple lists of the same resource in your app, and would like to keep distinct states for each of them (filters, sorting and pagination), you can use the `storeKey` prop to differentiate them. + +#list https://marmelab.com/react-admin/List.html#storekey + +{% raw %} + +```jsx +import * as React from "react"; +import { Admin, CustomRoutes, List, Datagrid, TextField } from "react-admin"; +import { Route } from "react-router-dom"; +import { dataProvider } from "./dataProvider"; + +const Books = ({ storeKey, order }) => ( + <List resource="books" storeKey={storeKey} sort={{ field: "year", order }}> + <Datagrid> + <TextField source="id" /> + <TextField source="title" /> + <TextField source="author" /> + <TextField source="year" /> + </Datagrid> + </List> +); + +const App = () => ( + <Admin dataProvider={dataProvider}> + <CustomRoutes> + <Route + path="/newerBooks" + element={<Books storeKey="newerBooks" order="DESC" />} + /> + <Route + path="/olderBooks" + element={<Books storeKey="olderBooks" order="ASC" />} + /> + </CustomRoutes> + </Admin> +); +``` + +{% endraw %} + +--- + +How to build a multi-tenant web app with react-admin? Although this may sound simple, you should NOT do the following. This blog post explains why. #multiTenancy https://marmelab.com/blog/2022/12/14/multitenant-spa.html + +```diff ++const tenantId = localStorage.getItem('tenantId'); +const { data, isLoading } = useGetList( + 'tickets', + { + pagination: { page: 1, perPage: 25 }, + sort: { field: 'id', order: 'DESC' }, +- filter: {}, ++ filter: { tenantId }, + }, +); +``` + +--- + +If you need to build an app relying on more than one API, you can combine multiple data providers into one using the `combineDataProviders` helper. #dataFetching https://marmelab.com/react-admin/DataProviders.html#combining-data-providers + +```jsx +import buildRestProvider from "ra-data-simple-rest"; +import { + Admin, + Resource, + ListGuesser, + combineDataProviders, +} from "react-admin"; + +const dataProvider1 = buildRestProvider("http://path.to.my.first.api/"); +const dataProvider2 = buildRestProvider("http://path.to.my.second.api/"); + +const dataProvider = combineDataProviders((resource) => { + switch (resource) { + case "posts": + case "comments": + return dataProvider1; + case "users": + return dataProvider2; + default: + throw new Error(`Unknown resource: ${resource}`); + } +}); + +export const App = () => ( + <Admin dataProvider={dataProvider}> + <Resource name="posts" list={ListGuesser} /> + <Resource name="comments" list={ListGuesser} /> + <Resource name="users" list={ListGuesser} /> + </Admin> +); +``` + +--- + +React Admin's `fetchJson` is a neat utility that makes it easier to query a JSON API with the required headers. It also allows to add your own headers, and handles the JSON decoding of the response. #dataProvider https://marmelab.com/react-admin/fetchJson.html + +```js +import { fetchUtils } from "react-admin"; +const httpClient = async (url, options = {}) => { + const { status, headers, body, json } = await fetchUtils.fetchJson( + url, + options + ); + console.log("fetchJson result", { status, headers, body, json }); + return { status, headers, body, json }; +}; +``` + +--- + +If you need to combine several fields in a single cell (in a `<Datagrid>`) or in a single row (in a `<SimpleShowLayout>`), use a `<WrapperField>` to define the `label` and `sortBy` props. #field https://marmelab.com/react-admin/WrapperField.html + +```js +import { List, Datagrid, WrapperField, TextField } from "react-admin"; + +const BookList = () => ( + <List> + <Datagrid> + <TextField source="title" /> + <WrapperField label="author" sortBy="author.last_name"> + <TextField source="author_first_name" /> + <TextField source="author_last_name" /> + </WrapperField> + </Datagrid> + </List> +); +``` + +--- + +If you use `useMediaQuery` to display different content on mobile and desktop, be aware that the component will render twice on mobile - that's a limitation of the hook to support server-side rendering. To avoid that double render, set the `noSsr` option. #mobile https://marmelab.com/react-admin/Theming.html#usemediaquery-hook + +```jsx +import { useMediaQuery, Theme } from "@material-ui/core"; + +const PostList = () => { + const isSmall = + useMediaQuery < + Theme > + ((theme) => theme.breakpoints.down("md"), { noSsr: true }); + return isSmall ? <PostListMobile /> : <PostListDesktop />; +}; +``` + +--- + +You can use a `<Show>` Layout component for the `<Datagrid expand>` prop. #datagrid https://marmelab.com/react-admin/Datagrid.html#expand + +```jsx +const PostShow = () => ( + <SimpleShowLayout> + <RichTextField source="body" /> + </SimpleShowLayout> +); + +const PostList = () => ( + <List> + <Datagrid expand={<PostShow />}> + <TextField source="id" /> + <TextField source="title" /> + <DateField source="published_at" /> + <BooleanField source="commentable" /> + <EditButton /> + </Datagrid> + </List> +); +``` + +--- + +By default, the `polyglotI18nProvider` logs a warning each time a message can’t be found in the translations. This helps track missing translation keys. To silence this, you can pass the `allowMissing` option to Polyglot. #i18n https://marmelab.com/react-admin/TranslationSetup.html#silencing-translation-warnings + +```diff +// in src/i18nProvider.js +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import en from './i18n/englishMessages'; +import fr from './i18n/frenchMessages'; + +const i18nProvider = polyglotI18nProvider(locale => + locale === 'fr' ? fr : en, + 'en', // Default locale + [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'Français' } + ], ++ { allowMissing: true } +); +``` + +--- + +The `<AutocompleteInput>` dropdown has the same width as the input. You can make it larger by specifying a custom `PopperComponent` prop. #autocomplete https://marmelab.com/react-admin/AutocompleteInput.html + +{% raw %} + +```jsx +import { AutocompleteInput, ReferenceInput, SearchInput } from "react-admin"; +import { Popper } from "@mui/material"; + +const LargePopper = (props: any) => ( + <Popper {...props} style={{ width: 400 }} placement="bottom-start" /> +); + +const ListFilters = [ + <SearchInput alwaysOn />, + <ReferenceInput reference="companies" source="company_id" alwaysOn> + <AutocompleteInput PopperComponent={LargePopper} /> + </ReferenceInput>, +]; +``` + +{% endraw %} + +--- +If you need to add custom pages in your application, for instance to let users manage their profile (password, preferences, etc), use the `<CustomRoutes>` component. #router https://marmelab.com/react-admin/CustomRoutes.html + +```jsx +import { Admin, Resource, CustomRoutes } from 'react-admin'; +import { Route } from "react-router-dom"; + +import dataProvider from './dataProvider'; +import posts from './posts'; +import comments from './comments'; +import Settings from './Settings'; +import Profile from './Profile'; + +const App = () => ( + <Admin dataProvider={dataProvider}> + <Resource name="posts" {...posts} /> + <Resource name="comments" {...comments} /> + <CustomRoutes> + <Route path="/settings" element={<Settings />} /> + <Route path="/profile" element={<Profile />} /> + </CustomRoutes> + </Admin> +); + +export default App; +``` + +--- + +If you'd like to reset the list filters when your users click on a menu item, you can use an empty `filter` query parameter to empty the filters. #list #menu https://marmelab.com/react-admin/Menu.html#resetting-filters-on-menu-click + +```jsx +<Menu.Item + to="/posts?filter=%7B%7D" // %7B%7D is JSON.stringify({}) + primaryText="Posts" + leftIcon={<BookIcon />} +/> +``` + +--- + +If you need to display/hide an input based on the value of another input, the `<FormDataConsumer>` component can help. Also, remember to set the `shouldUnregister` prop, so that when the input is hidden, its value isn’t included in the submitted data. #form https://marmelab.com/react-admin/Inputs.html#hiding-inputs-based-on-other-inputs + +{% raw %} + +```jsx +import { FormDataConsumer } from 'react-admin'; + +const PostEdit = () => ( + <Edit> + <SimpleForm shouldUnregister> + <BooleanInput source="hasEmail" /> + <FormDataConsumer> + {({ formData, ...rest }) => formData.hasEmail && + <TextInput source="email" {...rest} /> + } + </FormDataConsumer> + </SimpleForm> + </Edit> +); +``` + +{% endraw %} + +--- + +You can leverage the `useListContext` hook to build your own list UI. #list https://marmelab.com/react-admin/useListContext.html + +{% raw %} + +```jsx +const CompanyList = () => { + const { data, isLoading } = useListContext(); + + if (isLoading) return null; + + return ( + <Box display="flex" flexWrap="wrap" width="100%" gap={1}> + {data.map(record => ( + <RecordContextProvider key={record.id} value={record}> + <CompanyCard /> + </RecordContextProvider> + ))} + </Box> + ); +}; +``` + +{% endraw %} + +--- + +`<AutocompleteInput>` and `<SelectInput>` both provide an easy way to create new options on-the-fly. Simply use the `onCreate` prop to render a `prompt` to ask users about the new value. #input https://marmelab.com/react-admin/AutocompleteInput.html#oncreate + +{% raw %} +```jsx +import { AutocompleteInput, Create, SimpleForm, TextInput } from 'react-admin'; + +const PostCreate = () => { + const categories = [ + { name: 'Tech', id: 'tech' }, + { name: 'Lifestyle', id: 'lifestyle' }, + ]; + return ( + <Create> + <SimpleForm> + <TextInput source="title" /> + <AutocompleteInput + onCreate={() => { + const newCategoryName = prompt('Enter a new category'); + const newCategory = { id: newCategoryName.toLowerCase(), name: newCategoryName }; + categories.push(newCategory); + return newCategory; + }} + source="category" + choices={categories} + /> + </SimpleForm> + </Create> + ); +} +``` + +{% endraw %} + +--- + +You can customize the preview of an image file dropped in an [`<ImageInput>` component](https://marmelab.com/react-admin/ImageInput.html). Let's replace the default preview provided by `<ImageField>`, with the [MUI Avatar component](https://mui.com/material-ui/react-avatar/): + +{% raw %} + +```jsx +import { Create, ImageInput, SimpleForm, useRecordContext } from 'react-admin'; +import Avatar from '@mui/material/Avatar'; + +export const PostCreate = () => ( + <Create> + <SimpleForm> + <ImageInput + source="file" + label="Image" + accept="image/*" + sx={{ + '& .RaFileInput-removeButton': { + '& button': { + top: -20, + right: 40, + zIndex: 1, + }, + }, + }} + > + <Preview /> + </ImageInput> + </SimpleForm> + </Create> +); + +const Preview = () => { + const record = useRecordContext(); + return ( + <Avatar + sx={{ width: 56, height: 56 }} + alt={record.title} + src={record.src} + /> + ); +}; +``` + +{% endraw %} + +--- + +`<AutocompleteInput>` accepts the same props as MUI's `<Autocomplete>`. For instance, you can use `filterSelectedOptions` to choose whether or not to display the currently selected option among the list of available choices. #input https://marmelab.com/react-admin/AutocompleteInput.html#additional-props + +{% raw %} + +```jsx +// Setting filterSelectedOptions to false will include the currently selected option +// in the list of available choices (it will be highlighted in blue) +<AutocompleteInput + filterSelectedOptions={false} +/> +``` + +{% endraw %} + +--- + +If your API does not serve a resource as expected by your application, and if you cannot tranform this resource server-side, you can alter it client-side in the `dataProvider` by using `withLifecycleCallbacks`. #dataProvider https://marmelab.com/react-admin/withLifecycleCallbacks.html + +{% raw %} +```ts +// in src/dataProvider.ts +import { withLifecycleCallbacks } from 'react-admin'; +import simpleRestProvider from 'ra-data-simple-rest'; + +const baseDataProvider = simpleRestProvider('http://path.to.my.api/'); + +export const dataProvider = withLifecycleCallbacks( + baseDataProvider, + [ + { + // we want to transform the resource called event + resource: "events", + // transform the resource after getList() + afterGetList: async (result) => { + const events = result.data.map((event) => ({ + id: event.id, + date: event.created_at, + author: { + id: event.user_id, + }, + resource: "posts", + action: event.type, + payload: { + comment: event.comment, + }, + })); + return { data: events, total: result.total }; + }, + }, + ] +); +``` +{% endraw %} + +--- + +If you want to style a particular column in a `<Datagrid>`, you can take advantage of the generated class names per column. For instance a field with `source="title"` will have the class `column-title`. #datagrid https://marmelab.com/react-admin/Datagrid.html#styling-specific-columns + +{% raw %} + +```tsx +import { List, Datagrid, TextField } from 'react-admin'; + +const PostList = () => ( + <List> + <Datagrid + sx={{ + '& .column-title': { backgroundColor: '#fee' }, + }} + > + <TextField source="id" /> + <TextField source="title" /> {/* will have different background */} + <TextField source="author" /> + <TextField source="year" /> + </Datagrid> + </List> +); +``` + +{% endraw %} + +--- + +Instead of defining the same `optionText` prop in multiple `<Input>` or `<Field>`, you can choose a default template using the `recordRepresentation` prop of `<Resource>`. #resource https://marmelab.com/react-admin/Resource.html#recordrepresentation + +{% raw %} + +```tsx +import { Admin, Resource } from 'react-admin'; + +const App = () => ( + <Admin> + <Resource + name="customers" + list={CustomerList} + recordRepresentation={(record) => + `${record.first_name} ${record.last_name}` + } + /> + </Admin> +); +``` + +{% endraw %} + +--- + +The `<ReferenceManyField>` provides a `ListContext` to its children, just like the `<List>`. You can leverage that to build richer UI for references. For instance, adding search. https://marmelab.com/react-admin/ReferenceManyField.html + +{% raw %} + +```tsx +export const PostEdit = () => ( + <Edit> + <SimpleForm> + <TextInput source="title" /> + <ReferenceManyField reference="comments" target="postId"> + <FilterLiveSearch /> + <Datagrid> + <TextField source="id" /> + <TextField source="name" /> + <TextField source="email" /> + <TextField source="body" /> + </Datagrid> + </ReferenceManyField> + </SimpleForm> + </Edit> +); +``` + +{% endraw %} + +--- + +Check the uniqueness of a value in a form with the `useUnique` validator. It checks the validity using `dataProvider.getList()` with a filter param, and fails when a record exists with the current input value. +#form https://marmelab.com/react-admin/useUnique.html + +{% raw %} +```tsx +import { SimpleForm, TextInput, useUnique } from 'react-admin'; + +const UserCreateForm = () => { + const unique = useUnique(); + return ( + <SimpleForm> + <TextInput source="username" validate={unique({ message: 'myapp.validation.unique' })} /> + </SimpleForm> + ); +}; +``` +{% endraw %} + +--- + +In confirmation messages, and on the empty page, the resource name appears in the middle of sentences, as lower case. This works in English, but you may want to display resources in another way, like in German, where names are always capitalized. To do this, simply add a `forcedCaseName` key next to the `name` key in your translation file. #i18n https://marmelab.com/react-admin/TranslationTranslating.html#forcing-the-case-in-confirm-messages-and-empty-page + +{% raw %} + +```js +{ + resources: { + comments: { + name: 'Kommentar |||| Kommentare', + // Will render "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?" + // Instead of "Sind Sie sicher, dass Sie diesen kommentar löschen möchten?" + forcedCaseName: 'Kommentar |||| Kommentare', + fields: { + id: 'Id', + name: 'Bezeichnung', + } + } + } +} +``` + +{% endraw %} + +--- + +With `<Datagrid>`, you can select a range of rows by pressing the shift key while clicking on a row checkbox. #datagrid https://marmelab.com/react-admin/Datagrid.html#bulkactionbuttons + +<video controls autoplay playsinline muted loop> + <source src="https://marmelab.com/react-admin/img/datagrid-select-range.mp4" type="video/mp4"/> + Your browser does not support the video tag. +</video> + +--- + +If you need to customize the `<SolarLayout>` appBar that appears on Mobile, you can set the `appBar` prop of `<SolarLayout>`. For instance, here's how you could customize its colors and add some extra content to its far right. +#SolarLayout https://react-admin-ee.marmelab.com/documentation/ra-navigation#appbar-1 + +{% raw %} +```tsx +const CustomAppBar = () => ( + <SolarAppBar + sx={{ color: "text.secondary", bgcolor: "background.default" }} + toolbar={ + <Box display="flex" justifyContent="space-between" alignItems="center"> + <Box mr={1}>Custom toolbar</Box> + <Box mr={1}>with</Box> + <Box mr={1}>multiple</Box> + <Box mr={1}>elements</Box> + </Box> + } + /> +); + +const CustomLayout = (props: SolarLayoutProps) => ( + <SolarLayout {...props} appBar={CustomAppBar} /> +); +``` +{% endraw %} + +--- + +There are several ways to upload files: you can send files as Base64 string, you can send files using multipart/form-data, or you might want to send files to a third party service such as CDN, etc. The Handling File Uploads section of our documentation contains example code for all three, including and example using Cloudinary as CDN. + +#dataProvider https://marmelab.com/react-admin/DataProviders.html#handling-file-uploads + +{% raw %} +```tsx +export const dataProvider = withLifecycleCallbacks( + simpleRestProvider('http://path.to.my.api'), + [ + { + resource: 'posts', + beforeSave: async (params: any) => { + const response = await fetch( + 'http://path.to.my.api/get-cloudinary-signature', + { method: 'GET' } + ); // get the Cloudinary signature from your backend + + const signData = await response.json(); + const formData = new FormData(); + formData.append('file', params.picture.rawFile); + formData.append('api_key', signData.api_key); + // add other Cloudinary parameters here, such as `signature` + + const imageResponse = await fetch( + `https://api.cloudinary.com/v1_1/${signData.cloud_name}/auto/upload`, + { + method: 'POST', + body: formData, + } + ); + + const image = await imageResponse.json(); + return { + ...params, + picture: { + src: image.secure_url, + title: image.asset_id, + }, + }; + }, + }, + ] +); +``` +{% endraw %} + +--- + +Most `<List>` children, such as `<Datagrid>`, `<SimpleList>` or `<SingleFieldList>` support the `empty` prop. It accepts a React Component, allowing to customize the content to display when the list is empty. #list https://marmelab.com/react-admin/List.html#empty + +{% raw %} +```jsx +const CustomEmpty = () => <div>No books found</div>; + +const PostList = () => ( + <List> + <Datagrid empty={<CustomEmpty />}> + <TextField source="id" /> + <TextField source="title" /> + <TextField source="views" /> + </Datagrid> + </List> +); +``` +{% endraw %} + +--- + +If you are implementing a custom list iterator, but don't want to handle the loading state by grabbing the `isLoading` variable from the `ListContext`, a convenient solution is to leverage the `<List emptyWhileLoading>` prop. With it, the `<List>` component won’t render its child component until the data is available. #list https://marmelab.com/react-admin/ListTutorial.html#building-a-custom-iterator + +{% raw %} +```jsx +import { List, useListContext } from "react-admin"; +import { Stack, Typography } from "@mui/material"; + +const BookListView = () => { + const { data } = useListContext(); + return ( + <Stack spacing={2} sx={{ padding: 2 }}> + {data.map((book) => ( + <Typography key={book.id}> + <i>{book.title}</i>, by {book.author} ({book.year}) + </Typography> + ))} + </Stack> + ); +}; + +const BookList = () => ( + <List emptyWhileLoading> + <BookListView /> + </List> +); +``` +{% endraw %} + +--- + +Components based on Material UI `<Dialog>`, like `<EditDialog>` or `<CreateDialog>`, can be set to take up full width by setting the `fullWidth` prop to `true`. It works well in conjunction with the `maxWidth` prop, allowing the dialog to grow only until a given breakpoint. #ui https://marmelab.com/react-admin/EditDialog.html#fullwidth + +{% raw %} +```jsx +const MyEditDialog = () => ( + <EditDialog fullWidth maxWidth="sm"> + ... + </EditDialog> +); +``` +{% endraw %} + +--- + +With `<EditableDatagrid>`, if you are providing your own side effects to manage successful or failed save or delete actions, you can leverage the `useRowContext` hook to close the form programmatically. #datagrid #form https://marmelab.com/react-admin/EditableDatagrid.html#providing-custom-side-effects + +{% raw %} +```tsx +import { RowForm, useRowContext } from '@react-admin/ra-editable-datagrid'; +import { useNotify } from 'react-admin'; + +const ArtistCreationForm = () => { + const notify = useNotify(); + const { close } = useRowContext(); + + const handleSuccess = response => { + notify(`Artist ${response.name} ${response.firstName} has been added`); + close(); + }; + + return ( + <RowForm mutationOptions={{ onSuccess: handleSuccess }}> + {/*...*/} + </RowForm> + ); +}; +``` +{% endraw %} + +--- + +When editing a record, if ever the record is refetched and its value has changed, the form will be reset to the new value. If you want to avoid loosing changes already made to the record, you can leverage the `resetOptions` to keep the dirty values. #form https://marmelab.com/react-admin/Form.html#props + +{% raw %} +```tsx +<SimpleForm + resetOptions={{ + keepDirtyValues: true, + }} +> + {/*...*/} +</SimpleForm> +``` +{% endraw %} + +--- + +If you would like to make the tabs vertical in a `<TabbedShowLayout>`, you can specify a different `orientation` to `<TabbedShowLayoutTabs>`, as they accept the same props as the MUI `<Tabs>` component. #form #layout https://mui.com/material-ui/react-tabs/ + +{% raw %} +```tsx +<TabbedShowLayout + tabs={<TabbedShowLayoutTabs orientation="vertical" />} + sx={{ + display: 'flex', + flexDirection: 'row', + }} +> + {/*...*/} +</TabbedShowLayout> +``` +{% endraw %} + +--- + +Instead of numbers, you can pass an array of objects to `<Pagination rowsPerPageOptions>` to specify a custom label corresponding to each value. #list https://marmelab.com/react-admin/List.html#pagination + +{% raw %} +```tsx +import { Pagination, List } from 'react-admin'; + +const PostPagination = () => ( + <Pagination + rowsPerPageOptions={[ + { label: 'ten', value: 10 }, + { label: 'twenty', value: 20 }, + { label: 'one hundred', value: 100 }, + ]} + /> +); + +export const PostList = () => ( + <List pagination={<PostPagination />}> + {/*...*/} + </List> +); +``` +{% endraw %} + +--- + +If you render more than one `<DatagridConfigurable>` on the same page, you must pass a unique preferenceKey prop to each one. Do not forget to link their `<SelectColumnsButton>` components by giving them the same preferenceKey. #datagrid https://marmelab.com/react-admin/Datagrid.html#configurable + +{% raw %} +```tsx +const PostListActions = () => ( + <TopToolbar> + <SelectColumnsButton preferenceKey="posts.datagrid" /> + </TopToolbar> +); + +const PostList = () => ( + <List actions={<PostListActions />}> + <DatagridConfigurable preferenceKey="posts.datagrid"> + <TextField source="id" /> + <TextField source="title" /> + <TextField source="author" /> + <TextField source="year" /> + </DatagridConfigurable> + </List> +); +``` +{% endraw %} + +--- + +If your API answers with an error status after submitting a form and you need to access the error object, you can use the `mutationOptions` prop. #form +https://marmelab.com/react-admin/Edit.html#mutationoptions + +{% raw %} +```ts +import * as React from 'react'; +import { useNotify, useRefresh, useRedirect, Edit, SimpleForm } from 'react-admin'; + +const PostEdit = () => { + const notify = useNotify(); + + const onError = (error) => { + notify(`Could not edit post: ${error.message}`); + }; + + return ( + <Edit mutationOptions={{ onError }}> + <SimpleForm> + ... + </SimpleForm> + </Edit> + ); +} +``` +{% endraw %} + +--- + +Setting a `QueryClient` with a `staleTime` greater than 0 means that the UI may display stale data. If necessary, you can invalidate the query so that the next `useQuery` hook fetches fresh data. +https://marmelab.com/react-admin/Admin.html#queryclient + +{% raw %} +```ts +import { useQueryClient } from "react-query"; + +const PostEdit = () => { + const queryClient = useQueryClient(); + queryClient.invalidateQueries(["posts", "getOne"]); + return <PostForm />; +}; +``` +{% endraw %} + +--- + +Don’t confuse Material-UI’s `useTheme`, which returns the material-ui theme object, with react-admin’s `useTheme`, which lets you read and update the theme name (light or dark). #theme https://marmelab.com/react-admin/useTheme.html + +{% raw %} +```ts +import { defaultTheme, useTheme } from 'react-admin'; +import { Button } from '@mui/material'; + +const ThemeToggler = () => { + const [theme, setTheme] = useTheme(); + + return ( + <Button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}> + {theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'} + </Button> + ); +} +``` +{% endraw %} + +--- + +If you need to tweak the default layout to add a right column or move the menu to the top, you’re probably better off writing your own layout component (https://marmelab.com/react-admin/Layout.html#writing-a-layout-from-scratch). #layout #style + +```tsx +import * as React from 'react'; +import { Box } from '@mui/material'; +import { AppBar, Menu, Sidebar } from 'react-admin'; + +const MyLayout = ({ children, dashboard }) => ( + <Box + display="flex" + flexDirection="column" + zIndex={1} + minHeight="100vh" + backgroundColor="theme.palette.background.default" + position="relative" + > + <Box + display="flex" + flexDirection="column" + overflowX="auto" + > + <AppBar /> + <Box display="flex" flexGrow={1}> + <Sidebar> + <Menu hasDashboard={!!dashboard} /> + </Sidebar> + <Box + display="flex" + flexDirection="column" + flexGrow={2} + p={3} + marginTop="4em" + paddingLeft={5} + > + {children} + </Box> + </Box> + </Box> + </Box> +); + +export default MyLayout; +``` + +--- + +If you need to override the Layout's global styles (like the default font size or family), you should write a custom theme (https://marmelab.com/react-admin/AppTheme.html#writing-a-custom-theme) rather than override the `<Layout sx>` prop. #layout #style + +```tsx +const theme = { + palette: { + primary: { + main: '#FF5733', + }, + secondary: { + main: '#E0C2FF', + light: '#F5EBFF', + contrastText: '#47008F', + }, + }, + spacing: 4, + typography: { + fontFamily: 'Raleway, Arial', + }, + components: { + MuiCssBaseline: { + styleOverrides: ` + @font-face { + font-family: 'Raleway'; + font-style: normal; + font-display: swap; + } + `, + }, + }, +}; +``` + +--- + +If you're using the `<RichTextInput>` component, you may want to access the `TipTap` editor object to tweak extensions, input rules, etc. To do so, you can assign a ref in the `onCreate` function in the `editorOptions` prop of your `<RichTextInput>` component. #rich-text https://marmelab.com/react-admin/RichTextInput.html#calling-the-editor-object + +{% raw %} +```tsx +import * as React from "react"; +import { Edit, SaveButton, SimpleForm, TextInput, Toolbar } from "react-admin"; +import { DefaultEditorOptions, RichTextInput } from "ra-input-rich-text"; +import { Button } from "ra-ui-materialui"; +import { Editor } from "@tiptap/react"; + +const MyToolbar = ({ editorRef }) => ( + <Toolbar> + <SaveButton /> + <Button + onClick={() => { + if (!editorRef.current) return; + editorRef.current.commands.setContent("<h3>Template content</h3>"); // access the editor ref + }} + > + Use template + </Button> + </Toolbar> +); + +export const PostEdit = () => { + const editorRef = React.useRef<Editor | null>(null); + + return ( + <Edit> + <SimpleForm toolbar={<MyToolbar editorRef={editorRef} />}> + <RichTextInput + source="body" + editorOptions={{ + ...DefaultEditorOptions, + onCreate: ({ editor }: { editor: Editor }) => { + editorRef.current = editor; // assign the editor ref + }, + }} + /> + </SimpleForm> + </Edit> + ); +}; +``` +{% endraw %} + +--- + +When users don’t find the reference they are looking for in the list of possible values, they need to create a new reference. Here's how you can let users create a new reference on the fly with `<ReferenceInput>`, `<AutocompleteInput onCreate>` and `useCreate`. #form #reference https://marmelab.com/react-admin/ReferenceInput.html#creating-a-new-reference + +{% raw %} +```tsx +export const ContactEdit = () => { + const [create] = useCreate(); + const notify = useNotify(); + const handleCreateCompany = async (companyName?: string) => { + if (!companyName) return; + try { + const newCompany = await create( + 'companies', + { data: { name: companyName } }, + { returnPromise: true } + ); + return newCompany; + } catch (error) { + notify('An error occurred while creating the company', { + type: 'error', + }); + throw(error); + } + }; + return ( + <Edit> + <SimpleForm> + <TextInput source="first_name" /> + <TextInput source="last_name" /> + <ReferenceInput source="company_id" reference="companies"> + <AutocompleteInput onCreate={handleCreateCompany} /> + </ReferenceInput> + </SimpleForm> + </Edit> + ); +}; +``` +{% endraw %} + +--- + +When you need to programmatically set form values, use the `useFormContext` hook from `react-hook-form`. #form #input https://react-hook-form.com/docs/useformcontext https://react-hook-form.com/docs/useform/setvalue + +{% raw %} +```tsx +import { useFormContext } from 'react-hook-form'; + +export const ArticleInputs = () => { + const { setValue } = useFormContext(); + + return ( + <> + <Button + type="button" + onClick={() => { + setValue('title', ''); + setValue('description', ''); + }} + > + Reset + </Button> + <TextInput source="title" /> + <TextInput source="description" /> + </> + ); +}; +``` +{% endraw %} + +--- + +Thanks to the react-admin community, you can find third-party react-admin components to enhance your apps, such as a color-picker, JSON fields and inputs, a trim field, a URL input, and more. Check the non-exhaustive list in [the third-party inputs documentation](https://marmelab.com/react-admin/Inputs.html#third-party-components). + +--- + +The `notifictaion` prop of the `<Admin>` component allows you to customize the notification component. You can use the `Notification` component from the `react-admin` package to customize the notification content. #ui https://marmelab.com/react-admin/Admin.html#notification + +{% raw %} +```tsx +import { Admin, Notification } from 'react-admin'; +import { Slide } from '@mui/material'; + +const MyNotification = () => ( + <Notification + autoHideDuration={3000} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + TransitionComponent={Slide} + message="My custom notification" + /> +); + +export const App = () => { + return ( + <Admin notification={<MyNotification />}> + {/*...*/} + </Admin> + ); +}; +``` +{% endraw %} + +--- + +The `useNotify` hook allows you to display notifications in your application. You can customize the notification content by passing a component directly to the `notify` method. #ui https://marmelab.com/react-admin/useNotify.html + +{% raw %} +```tsx +import { useNotify } from 'react-admin'; + +const NotifyButton = () => { + const notify = useNotify(); + const handleClick = () => { + notify( + <Alert severity="info"> + Comment approved + </Alert>, + { type: 'info' } + ); + } + return <button onClick={handleClick}>Notify</button>; +}; +``` +{% endraw %} + +--- + +Throwing an `HttpError` in your `dataProvider` will have the effect to display a notification in your application. You can customize the content of the notification by passing a component directly to the `HttpError` method. #dataProvider https://marmelab.com/react-admin/DataProviderWriting.html#error-format + +{% raw %} +```tsx +import { HttpError } from 'react-admin'; +import { Alert, AlertTitle } from '@mui/material'; +import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined'; +export const MyDataProvider = { + getList: (resource, params) => { + return new Promise((resolve, reject) => { + myApiClient(url, { ...options, headers: requestHeaders }) + .then(response => + //... + ) + .then(({ status, statusText, headers, body }) => { + let json; + try { + json = JSON.parse(body); + } catch (e) { + // not json, no big deal + } + if (status < 200 || status >= 300) { + return reject( + new HttpError( + ( + <Alert + severity="error" + variant="outlined" + sx={{ width: '100%' }} + iconMapping={{ + error: ( + <ReportProblemOutlinedIcon /> + ), + }} + > + <AlertTitle> + An error has occured + </AlertTitle> + {(json && json.message) || statusText} + </Alert> + ), + status, + json + ) + ); + } + //... + }); + }); + }, + // ... +}; +``` +{% endraw %} + +--- + +The `<Datagrid rowClick>` prop also accepts a function. This allows for more complex scenarios, like opening the 'edit' or 'show' view depending on whether or not a post accepts comments. #datagrid https://marmelab.com/react-admin/Datagrid.html#rowclick + +{% raw %} +```tsx +import { List, Datagrid } from 'react-admin'; + +const rowClick = (_id, _resource, record) => { + if (record.commentable) { + return 'edit'; + } + return 'show'; +}; + +export const PostList = () => ( + <List> + <Datagrid rowClick={rowClick}> + ... + </Datagrid> + </List> +); +``` +{% endraw %} + +--- + +By default, the Dashboard page requires users to be authenticated and will redirect anonymous users to the login page. If you want to allow anonymous access to the dashboard, edit your `authProvider` to add an exception to the `checkAuth` method, as follows. #dashboard #authProvider https://marmelab.com/react-admin/Admin.html#dashboard + +{% raw %} +```diff +const authProvider = { + // ... + checkAuth: (params) => { ++ if (params?.route === 'dashboard') return Promise.resolve(); + // ... + }, +} +``` +{% endraw %} + +--- + +When exporting the content of a list with the `<ExportButton>`, you may need to augment your objects based on relationships (e.g. comments should include the title of the related post). Fortunately, the `exporter` receives a `fetchRelatedRecords` function which helps fetch related records. #list https://marmelab.com/react-admin/List.html#exporter + +{% raw %} +```tsx +// in CommentList.js +import { List, downloadCSV } from 'react-admin'; +import type { FetchRelatedRecords } from 'react-admin'; +import jsonExport from 'jsonexport/dist'; + +const exporter = async (comments: Comments[], fetchRelatedRecords: FetchRelatedRecords) => { + // will call dataProvider.getMany('posts', { ids: records.map(record => record.post_id) }), + // ignoring duplicate and empty post_id + const posts = await fetchRelatedRecords<Post>(comments, 'post_id', 'posts') + const commentsWithPostTitle = comments.map(comment => ({ + ...comment, + post_title: posts[comment.post_id].title, + })); + return jsonExport(commentsWithPostTitle, { + headers: ['id', 'post_id', 'post_title', 'body'], + }, (err, csv) => { + downloadCSV(csv, 'comments'); + }); +}; + +const CommentList = () => ( + <List exporter={exporter}> + ... + </List> +); +``` +{% endraw %} + +--- + +Should you need quick actions that update a record, you can leverage [the `<UpdateButton>` component](https://marmelab.com/react-admin/Buttons.html#updatebutton). It supports `undoable`, `optimistic` and `pessimistic` mutation modes and will even ask users for confirmation unless you chose the `undoable` mode. + +{% raw %} +```tsx +import { Edit, SimpleForm, TextInput, TopToolbar, UpdateButton } from 'react-admin'; + +const PostEditActions = () => ( + <TopToolbar> + <UpdateButton label="Reset views" data={{ views: 0 }} /> + </TopToolbar> +); + +export const PostEdit = () => ( + <Edit actions={<PostEditActions />}> + <SimpleForm> + <TextInput source="title" /> + <TextInput source="body" /> + </SimpleForm> + </Edit> +); +``` +{% endraw %} + +--- + +Sometimes you need to render a record property but it might cumbersome to build a component calling [the `useRecordContext` hook](https://marmelab.com/react-admin/useRecordContext.html). The `WithRecord` component might often be enough: + +{% raw %} +```tsx +import { Show, SimpleShowLayout, WithRecord } from 'react-admin'; + +const BookShow = () => ( + <Show> + <SimpleShowLayout> + <WithRecord label="author" render={record => <span>{record.author}</span>} /> + </SimpleShowLayout> + </Show> +); +``` +{% endraw %} + +--- + +After an upgrade of a react-admin package, you may encounter new bugs where a component cannot read a context created by another component. This is often caused by 2 conflicting versions of the same package in your dependencies. To fix such bugs, relax your npm dependencies (from `~` to `^`) and / or run [the `dedupe` command](https://docs.npmjs.com/cli/v9/commands/npm-dedupe) with you package manager. + +```sh +$ npm dedupe +``` + +--- + +When displaying large pages of data, you might experience some performance issues. This is mostly due to the fact that we iterate over the `<Datagrid>` children and clone them. + +In such cases, you can opt-in for an optimized version of the `<Datagrid>` by setting its optimized prop to true. Be aware that you can’t have dynamic children, such as those displayed or hidden by checking permissions, when using this mode. + +```jsx +import { List, Datagrid, TextField } from 'react-admin'; + +const PostList = () => ( + <List> + <Datagrid optimized> + <TextField source="id" /> + <TextField source="title" /> + <TextField source="views" /> + </Datagrid> + </List> +); +``` + +--- + +Edition forms often contain linked inputs, e.g. country and city (the choices of the latter depending on the value of the former). + +React-admin relies on react-hook-form for form handling. You can grab the current form values using react-hook-form’s useWatch hook. + +{% raw %} + +```jsx +import * as React from 'react'; +import { Edit, SimpleForm, SelectInput } from 'react-admin'; +import { useWatch } from 'react-hook-form'; + +const countries = ['USA', 'UK', 'France']; +const cities = { + USA: ['New York', 'Los Angeles', 'Chicago', 'Houston', 'Phoenix'], + UK: ['London', 'Birmingham', 'Glasgow', 'Liverpool', 'Bristol'], + France: ['Paris', 'Marseille', 'Lyon', 'Toulouse', 'Nice'], +}; +const toChoices = items => items.map(item => ({ id: item, name: item })); + +const CityInput = () => { + const country = useWatch({ name: 'country' }); + return ( + <SelectInput + choices={country ? toChoices(cities[country]) : []} + source="cities" + /> + ); +}; + +const OrderEdit = () => ( + <Edit> + <SimpleForm> + <SelectInput source="country" choices={toChoices(countries)} /> + <CityInput /> + </SimpleForm> + </Edit> +); + +export default OrderEdit; +``` + +{% endraw %} + +--- + +When used inside a [`<ReferenceArrayInput>`](https://marmelab.com/react-admin/ReferenceArrayInput.html), whenever users type a string in the autocomplete input, `<AutocompleteArrayInput>` calls `dataProvider.getList()` using the string as filter, to return a filtered list of possible options from the reference resource. This filter is built using the `filterToQuery` prop. + +By default, the filter is built using the `q` parameter. This means that if the user types the string ‘lorem’, the filter will be `{ q: 'lorem' }`. + +You can customize the filter by setting the `filterToQuery` prop. It should be a function that returns a filter object. + +{% raw %} + +```tsx +const filterToQuery = searchText => ({ name_ilike: `%${searchText}%` }); + +<ReferenceArrayInput source="tag_ids" reference="tags"> + <AutocompleteArrayInput filterToQuery={filterToQuery} /> +</ReferenceArrayInput> +``` + +{% endraw %} + +--- + +You can choose to permanently filter the tree to display only a sub tree. + +For instance, imagine you have one `employees` resource with a `department` field, and you want to display a tree for the Finance department. Use the `filter` prop to filter the tree: + +{% raw %} + +```jsx +const EmployeeList = () => <TreeWithDetails filter={{ department: 'finance' }} />; +``` + +{% endraw %} + +**Note:** This only works if the filter field allows to extract a subtree with its own root node. If you use the `filter` prop to display a sparse selection of nodes (e.g. only the `male` employees), dragging nodes in this tree will not work as expected. + +--- + +`react-hook-form` supports schema validation with many libraries through its [`resolver` props](https://react-hook-form.com/docs/useform#validationResolver). + +To use schema validation, use the `resolver` prop following [react-hook-form’s resolvers documentation](https://github.com/react-hook-form/resolvers). Here’s an example using `yup`: + +```tsx +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { SimpleForm, TextInput, NumberInput } from 'react-admin'; + +const schema = yup + .object() + .shape({ + name: yup.string().required(), + age: yup.number().required(), + }) + .required(); + +const CustomerCreate = () => ( + <Create> + <SimpleForm resolver={yupResolver(schema)}> + <TextInput source="name" /> + <NumberInput source="age" /> + </SimpleForm> + </Create> +); +``` + +--- + +If you are using `ra-data-simple-rest`, you can change the name of the content range header to be something else than the default `Content-Range`. +#dataProvider https://github.com/marmelab/react-admin/tree/master/packages/ra-data-simple-rest#replacing-content-range-with-another-header + +```jsx +const dataProvider = simpleRestProvider('http://path.to.my.api/', undefined, 'X-Total-Count'); +``` + +--- + +The `useUpdate` or `useCreate` hooks return `@tanstack/query` mutation callbacks, which cannot be awaited. If you wish to work with a Promise instead, you can use the `returnPromise` prop. This can be useful if the server changes the record, and you need the updated data to update another record. +#query #dataProvider https://marmelab.com/react-admin/useUpdate.html#returnpromise + +{% raw %} + +```tsx +const [update] = useUpdate( + 'posts', + { id: record.id, data: { isPublished: true } }, + { returnPromise: true } +); +const [create] = useCreate('auditLogs'); + +const publishPost = async () => { + try { + const post = await update(); + create('auditLogs', { data: { action: 'publish', recordId: post.id, date: post.updatedAt } }); + } catch (error) { + // handle error + } +}; +``` + +{% endraw %} + +--- +If you need to have custom pages to let your users sign-up, use the `<CustomRoutes>` component and set its `noLayout` prop. This ensures React-admin won't check whether users are authenticated. #router https://marmelab.com/react-admin/CustomRoutes.html + +```jsx +import { Admin, Resource, CustomRoutes } from 'react-admin'; +import { Route } from "react-router-dom"; + +import dataProvider from './dataProvider'; +import posts from './posts'; +import comments from './comments'; +import SignUp from './SignUp'; + +const App = () => ( + <Admin dataProvider={dataProvider}> + <Resource name="posts" {...posts} /> + <Resource name="comments" {...comments} /> + <CustomRoutes noLayout> + <Route path="/sign-up" element={<SignUp />} /> + </CustomRoutes> + </Admin> +); + +export default App; +``` \ No newline at end of file diff --git a/docs/css/style-v22.css b/docs/css/style-v22.css new file mode 100644 index 00000000000..60941be4e44 --- /dev/null +++ b/docs/css/style-v22.css @@ -0,0 +1,768 @@ +/* Layout */ + +main > .container { + padding-top: 50px; +} +main > .container .markdown-section { + max-width: 740px; + margin: 0 auto; +} +@media only screen and (min-width: 992px) { + body:not(.no-sidebar) > header, + body:not(.no-sidebar) > main, + body:not(.no-sidebar) > footer { + padding-left: 300px; + } +} +@media only screen and (max-width: 992px) { + body > header, + body > main, + body > footer { + padding-left: 0; + } +} + +@media only screen and (min-width: 601px) { + body { + line-height: 1.6; + font-size: 16px; + -webkit-font-smoothing: antialiased; + } + main > .container { + width: 100%; + padding-left: 1em; + padding-right: 1em; + } +} + +/* Sticky TOC */ + +.sticker-container { + position: relative; +} +.toc-container { + position: sticky; + align-self: start; + top: 10px; + padding-left: 1em; +} +.toc { + overflow-y: auto; + max-height: calc( + 100vh - 114px + ); /* 104px = 50px container top padding + 64px navbar height */ +} +.toc.is-position-fixed { + position: static !important; + max-height: calc( + 100vh - 10px + ); /* container top padding */ +} + +/* Navigation */ + +nav { + background: linear-gradient(145deg, #027be3 11%, #1a237e 75%); +} +nav ul#nav-mobile a { + font-family: 'Roboto', sans-serif; + font-size: 16px; +} + +nav ul a { + padding: 0 12px; +} + +nav ul li.social a { + padding: 0 6px; +} + +nav ul li:nth-child(5) { + padding-left: 6px; +} + +nav ul li:nth-child(7) { + padding-right: 6px; +} +.sidenav { + height: 100%; + padding-bottom: 1em; +} + +.sidenav.sidenav-fixed li { + line-height: 44px; +} + +.sidenav li.logo { + text-align: center; + margin-top: 1em; +} + +.sidenav li.logo a { + height: auto; +} + +.sidenav li.logo img { + width: 50px; +} + +.sidenav li.version { + text-align: center; + padding: 0.5em 0 1em 0; +} + +.sidenav li.version li { + min-height: auto; +} + +.sidenav li.version .dropdown-trigger { + margin: 0 auto; + display: inline-block; + padding: 0 10px; + border-bottom: 1px solid rgba(0, 0, 0, 0.2); + width: 70%; + height: 2em; +} + +.sidenav li.version .dropdown-trigger .caret { + position: relative; + top: 10px; + fill: rgba(0, 0, 0, 0.6); +} + +.sidenav ul { + padding: 0.5em 0 0.5em 1em; +} + +.sidenav ul div { + text-transform: uppercase; + font-weight: bold; + font-size: 0.8em; +} + +.sidenav li > a { + height: 1.6em; + line-height: 1.6em; + padding: 0 0 0 1em; + font-size: 1em; + font-weight: normal; +} + +@media only screen and (max-width: 992px) { + .sidenav li > a { + height: 1.7em; + line-height: 1.7em; + padding: 0 0 0 1em; + font-size: 1.2em; + font-weight: normal; + } +} + +.sidenav.sidenav-fixed ul.collapsible-accordion a.collapsible-header { + padding: 0 30px; +} + +.sidenav.sidenav-fixed ul.collapsible-accordion .collapsible-body li a { + font-weight: 400; + padding: 0 37.5px 0 45px; +} + +ul.sidenav code { + font-size: 0.9em; + font-family: Menlo, Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; +} + +.sidenav img.premium { + width: 23px; + height: 23px; + vertical-align: middle; + box-shadow: none; +} + +.switch label input[type=checkbox]:checked+.lever { + background-color: #85b3df; +} + +.switch label .lever:before { + background-color: rgba(38,77,166,0.15); +} + +.switch label input[type=checkbox]:checked+.lever:after { + background-color: #145dc2; +} + +/* Table of contents */ + +.table-of-contents { + overflow-y: auto; + height: calc(100% - 75px); +} + +.table-of-contents a.active { + font-weight: 500; + padding-left: 14px; + border-left: 2px solid #1a237e; +} + +.table-of-contents a:hover { + color: #a8a8a8; + padding-left: 15px; + border-left: 1px solid #1a237e; +} + +.table-of-contents a { + color: rgba(0, 0, 0, 0.55); + font-weight: 400; +} + +.markdown-section { + display: block; + word-wrap: break-word; + overflow: auto; + color: #333; + line-height: 1.7; + text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + -moz-text-size-adjust: 100%; +} + +.markdown-section * { + box-sizing: border-box; + -webkit-box-sizing: border-box; +} + +.markdown-section *:focus { + outline: none; +} + +.markdown-section > :first-child { + margin-top: 0 !important; +} + +.markdown-section > :last-child { + margin-bottom: 0 !important; +} + +.markdown-section blockquote, +.markdown-section code, +.markdown-section figure, +.markdown-section img, +.markdown-section pre, +.markdown-section table, +.markdown-section tr { + page-break-inside: avoid; +} + +.markdown-section h2, +.markdown-section h3, +.markdown-section h4, +.markdown-section h5, +.markdown-section p { + orphans: 3; + widows: 3; +} + +.markdown-section h1, +.markdown-section h2, +.markdown-section h3, +.markdown-section h4, +.markdown-section h5 { + page-break-after: avoid; +} + +.markdown-section b, +.markdown-section strong { + font-weight: 700; +} + +.markdown-section em { + font-style: italic; +} + +.markdown-section blockquote, +.markdown-section dl, +.markdown-section ol, +.markdown-section p, +.markdown-section table, +.markdown-section ul { + margin-top: 0; + margin-bottom: 0.85em; +} + +.markdown-section ul > li { + list-style-type: disc; +} + +.markdown-section a { + color: #4183c4; + text-decoration: none; + background: 0 0; +} + +.markdown-section a code { + color: #4183c4; +} + +.markdown-section a:active, +.markdown-section a:focus, +.markdown-section a:hover { + text-decoration: underline; +} + +.markdown-section .highlight { + margin-bottom: 1em; +} + +.marmelab-language-switcher-tabs { + margin-top: -0.5em; +} + +@media only screen and (max-width: 992px) { + img:not(.no-shadow) { + width: 100%; + box-shadow: 1px 1px 5px 0px rgba(0, 0, 0, 0.22); + } +} + +.markdown-section img, +.markdown-section video { + max-width: 100%; +} + +@media only screen and (min-width: 601px) { + .markdown-section img, + .markdown-section video { + max-width: 90%; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.24); + border-radius: 2px; + margin: 1rem; + } + .markdown-section img.no-shadow, + .markdown-section video.no-shadow { + max-width: 90%; + box-shadow: none; + border-radius: 0; + margin: 1rem; + } +} + +.markdown-section hr { + height: 4px; + padding: 0; + margin: 1.7em 0; + overflow: hidden; + background-color: #e7e7e7; + border: none; +} + +.markdown-section hr:after, +.markdown-section hr:before { + display: table; + content: ' '; +} + +.markdown-section hr:after { + clear: both; +} + +.markdown-section h1, +.markdown-section h2, +.markdown-section h3 { + margin-top: 1.275em; + margin-bottom: 0.85em; + font-weight: 700; +} + +.markdown-section h1 { + font-size: 2.5em; +} + +.markdown-section h2 { + font-size: 1.75em; +} +.markdown-section h3 { + margin-top: 1.5em; + font-size: 1.25em; +} + +.markdown-section h4 { + margin-top: 1.25em; + font-size: 1em; + font-weight: bold; +} + +.markdown-section code, +.markdown-section pre { + font-family: Menlo, Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; + direction: ltr; + margin: 0; + padding: 0; + border: none; + color: inherit; +} + +.markdown-section pre { + overflow: auto; + word-wrap: normal; + margin: 0; + padding: 0.85em 1em; + margin-bottom: 1.275em; + background: #f7f7f7; + line-height: 1.3; +} +.markdown-section pre[class*='language-'] { + line-height: 1.3; +} +.markdown-section pre > code { + display: inline; + max-width: initial; + padding: 0; + margin: 0; + overflow: initial; + line-height: inherit; + font-size: 0.85em; + white-space: pre; + background: 0 0; +} +.markdown-section .highlight > pre > code { + font-size: 14px; +} +.markdown-section pre > code:after, +.markdown-section pre > code:before { + content: normal; +} +.markdown-section code { + padding: 0.2em; + margin: 0; + font-size: 0.85em; +} +.markdown-section p > code { + font-size: 14px; + background-color: #f7f7f7; +} +.markdown-section code:after, +.markdown-section code:before { + letter-spacing: -0.2em; + content: '\00a0'; +} +.markdown-section table { + display: table; + width: 100%; + border-collapse: collapse; + border-spacing: 0; + overflow: auto; +} +.markdown-section table td, +.markdown-section table th { + padding: 6px 13px; + border: 1px solid #ddd; +} +.markdown-section table tr { + background-color: #fff; + border-top: 1px solid #ccc; +} +.markdown-section table tr:nth-child(2n) { + background-color: #f8f8f8; +} +.markdown-section table th { + font-weight: 700; +} +.markdown-section ol, +.markdown-section ul { + padding: 0; + margin: 0; + margin-bottom: 0.85em; + padding-left: 2em; +} +.markdown-section ol ol, +.markdown-section ol ul, +.markdown-section ul ol, +.markdown-section ul ul { + margin-top: 0; + margin-bottom: 0; +} +.markdown-section ol ol { + list-style-type: lower-roman; +} +.markdown-section blockquote { + margin: 0; + margin-bottom: 0.85em; + padding: 0 15px; + color: #858585; + border-left: 4px solid #e5e5e5; +} +.markdown-section blockquote:first-child { + margin-top: 0; +} +.markdown-section blockquote:last-child { + margin-bottom: 0; +} +.markdown-section dl { + padding: 0; +} +.markdown-section dl dt { + padding: 0; + margin-top: 0.85em; + font-style: italic; + font-weight: 700; +} +.markdown-section dl dd { + padding: 0 0.85em; + margin-bottom: 0.85em; +} +.markdown-section dd { + margin-left: 0; +} +.markdown-section .glossary-term { + cursor: help; + text-decoration: underline; +} + +.markdown-section .icon { + box-shadow: none; + margin: 0; + width: 26px; + height: 26px; + transform: translate(0, 40%); + margin-top: -16px; + margin-left: -4px; + margin-right: -4px; +} + +.docBlocks { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-column-gap: 1em; + grid-row-gap: 1em; + grid-auto-rows: 1fr; + margin: 1em; +} + +.docBlocks a { + position: relative; + padding: 1em 1.5em 1.5em 1em; + overflow: hidden; + background: #efefef; + border-width: 1px; + border-style: solid; + border-radius: 5px; + border-color: #d7d7d7; + box-shadow: 0 0px 0px 0px rgba(0, 0, 0, 0); + color: inherit; + transition: box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out; +} + +.docBlocks a:hover { + border: #c0c0c0 1px solid; + box-shadow: 0 2px 3px 1px rgba(0, 0, 0, 0.1); + text-decoration: none; +} + +.docBlocks a .docBlock { + position: relative; + z-index: 10; +} + +.docBlocks a .material-icons { + position: absolute; + z-index: 1; + font-size: 36px; + opacity: 0.06; + top: 70%; + left: 80%; + transform: translate(-50%, -50%) scale(3) rotate(30deg); + transition: transform 0.2s ease-in-out, right 0.2s ease-in-out, + top 0.2s ease-in-out, left 0.2s ease-in-out; +} +.docBlocks a:hover .material-icons { + top: 60%; + left: 60%; + transform: scale(7) rotate(40deg); +} + +.docBlock h2 { + margin: 0 0 0.5em 0; + color: var(--docsearch-primary-color); + font-size: 1.5em; + font-weight: 700; + line-height: 1.2; +} + +@media only screen and (max-width: 1100px) { + .docBlocks { + grid-template-columns: 1fr 1fr; + } +} + +@media only screen and (max-width: 735px) { + .docBlocks { + display: block; + margin: 0; + } + .docBlocks a { + display: block; + margin-bottom: 1em; + } + .docBlock h2 { + margin: 0 0 0.2em 0; + } + .docBlocks a .material-icons { + left: auto; + top: 50%; + right: -16px; + transform: translate(-50%, -50%) scale(3.5) rotate(30deg); + } + .docBlocks a:hover .material-icons { + left: auto; + top: 50%; + right: 16px; + transform: translate(-50%, -50%) scale(2) rotate(0deg); + } +} + +/* Documentation summary navigation tweaks */ + +.react-admin-logo { + margin: 0 0.5em 0 1em; + display: none; + padding: 0.75em 0; + height: 100%; +} +body.no-sidebar .react-admin-logo { + display: block; +} +.react-admin-logo svg { + height: 100%; + width: auto; + aspect-ratio: 1; +} +.react-admin-logo:hover svg { + opacity: 1; +} +body.no-sidebar .react-admin-logo { + display: block; +} +body.no-sidebar .sidenav-trigger { + display: none; +} + +/* Prism overrides */ +/* Added the container selector to have a greater specificity */ +.container .token.operator, +.container .token.entity, +.container .token.url, +.container .language-css .token.string, +.container .style .token.string { + color: #9a6e3a; + background: none; +} + +.pages-index { + column-count: 3; +} +.pages-index ul { + padding-left: 0; +} +.pages-index ul > li { + list-style-type: none; + line-height: 1.2em; +} + +.pages-index code { + font-size: 0.8em; + background: none !important; +} + +#docsearch .DocSearch-Button { + transition: width 0.2s ease-in-out; +} + +@media only screen and (max-width: 1500px) { + #docsearch .DocSearch-Button { + width: 200px !important; + } +} + +.news { + display: inline; + margin: 0 15px; + padding: 4px 8px; + border-radius: 4px; + border: solid 1px #368fe7; + background: #368fe7; + color: #fff; +} + +.news-mobile { + margin-right: 16px; +} +@media only screen and (min-width: 991px) { + .news-mobile { + display: none; + } +} +@media only screen and (max-width: 600px) { + .news-mobile { + display: none; + } +} + +.marmelab-language-switcher-tabs { + display: flex; + gap: 1rem; + justify-content: end; +} + +.language-switcher { + background-color: #eff1f5; + padding: 0.5rem 1rem; + transition: all 0.2s ease-in-out; +} + +.language-switcher.active { + background-color: #eff1f5; + text-decoration: none !important; +} + +/* Auth and Data Provider icons */ + +.providers-list img { + width: 25px !important; + margin: 2px 10px 2px 0px !important; + box-shadow: none !important; + vertical-align: middle; +} +.providers-list .flex { + display: flex !important; + text-align: -webkit-match-parent !important; + align-items: center !important; +} +.providers-list .avatar { + display: flex; + align-items: center; + justify-content: center; + background-color: DarkGray; + width: 25px; + height: 25px; + margin: 2px 10px 2px 0px; + border-radius: 100%; + font-size: 12px; +} +.providers-list ul { + padding-left: 0; +} +.providers-list ul > li { + list-style-type: none; + max-height: 2em; +} + +#tip-container { + margin: 0 auto; + max-width: 740px; +} +#tip video, #tip img { + display: block; + margin: 0 auto; + max-width: 100%; +} \ No newline at end of file diff --git a/docs/documentation.html b/docs/documentation.html index 0c7ae1088ea..f4e6502d30a 100644 --- a/docs/documentation.html +++ b/docs/documentation.html @@ -19,7 +19,7 @@ /> <link rel="stylesheet" - href="{{ '/css/style-v21.css' | relative_url }}" + href="{{ '/css/style-v22.css' | relative_url }}" /> <link rel="stylesheet" href="{{ '/css/syntax.css' | relative_url }}" /> <link rel="stylesheet" href="{{ '/css/prism.css' | relative_url }}" /> @@ -260,6 +260,17 @@ <h2>Recipes</h2> <div class="material-icons"></div> </a> </div> + <div + id="tip-container" + style="visibility: hidden; padding: 32px 12px 32px; color: #888;" + > + <div style="border: solid 1px #C0CCD9; padding: 17px;"> + <div class="sib-form-block" style="font-size:16px; text-align:left; font-weight:700; font-family:"Helvetica", sans-serif; color:#3C4858; background-color:transparent; text-align:left"> + <p>Tip</p> + </div> + <div id="tip"></div> + </div> + </div> <div class="needHelp" style="text-align: center; padding-top: 0; color: #888" @@ -280,7 +291,9 @@ <h2>Recipes</h2> </main> <script src="js/materialize.min.js"></script> + <script src="js/ra-navigation.js"></script> <script src="js/prism.js"></script> + <script src="js/ra-doc-exec.js" type="module" defer async></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/tocbot/4.12.0/tocbot.min.js" defer @@ -288,7 +301,6 @@ <h2>Recipes</h2> crossorigin="anonymous" referrerpolicy="no-referrer" ></script> - <script src="js/ra-doc-exec.js"></script> <script src="https://cdn.jsdelivr.net/npm/@docsearch/js@3"></script> {% if page.dir contains "doc" %} {% assign version = page.dir | split: @@ -331,21 +343,5 @@ <h2>Recipes</h2> var AUTOHIDE = Boolean(0); </script> - - <script - async - defer - src="https://unpkg.com/typescript@5.1.3/lib/typescript.js" - ></script> - <script - async - defer - src="https://unpkg.com/prettier@2.8.8/standalone.js" - ></script> - <script - async - defer - src="https://unpkg.com/prettier@2.8.8/parser-babel.js" - ></script> </body> </html> diff --git a/docs/js/ra-doc-exec.js b/docs/js/ra-doc-exec.js index f8e52a32dda..5b196b7afde 100644 --- a/docs/js/ra-doc-exec.js +++ b/docs/js/ra-doc-exec.js @@ -1,5 +1,43 @@ -/* global Prism, prettier, prettierPlugins */ -var allMenus, navLinks, versionsLinks; +/* global Prism */ +import { transpileModule } from 'https://esm.sh/typescript@5.7.3'; +import * as prettier from 'https://esm.sh/prettier@3.5.1/standalone'; +import * as babel from 'https://esm.sh/prettier@3.5.1/plugins/babel'; +import * as estree from 'https://esm.sh/prettier@3.5.1/plugins/estree'; +import { marked } from 'https://esm.sh/marked@15.0.7'; + +export const showTip = async () => { + const tipContainer = document.getElementById('tip-container'); + const tipElement = document.getElementById('tip'); + if (!tipElement) return; + + const tips = await getContents('/assets/tips.md'); + const features = await getContents('/assets/features.md'); + const all = tips.concat(features); + + const content = all[Math.floor(Math.random() * all.length)] + .replace('{% raw %}', '') + .replace('{% endraw %}', ''); + tipElement.innerHTML = marked.parse(content); + // First highlight the code blocks so that Prism generates the HTML we need for TS transpilation + const codeBlock = tipElement.querySelector('pre > code'); + if (codeBlock) { + Prism.highlightElement(codeBlock); + } + tipContainer.style.visibility = 'visible'; +}; + +const getContents = async file => { + try { + const response = await fetch(file); + if (response.ok) { + const text = await response.text(); + return text.split('---'); + } + return []; + } catch { + return []; + } +}; const applyPreferredLanguage = async () => { const preferredLanguage = @@ -56,18 +94,7 @@ const applyPreferredLanguage = async () => { }; const transpileToJS = async tsCode => { - // As we load those libs asynchronously, we need to ensure they are loaded - // before using them - if ( - window.ts === undefined || - window.prettier === undefined || - window.prettierPlugins === undefined - ) { - await new Promise(resolve => setTimeout(resolve, 100)); - return transpileToJS(tsCode); - } - - const transpilation = window.ts.transpileModule( + const transpilation = transpileModule( // Ensure blank lines are preserved tsCode.replace(/\n\n/g, '\n/** THIS_IS_A_NEWLINE **/'), { @@ -90,7 +117,7 @@ const transpileToJS = async tsCode => { '\n\n' ), { - plugins: [prettierPlugins.babel], + plugins: [babel, estree], parser: 'babel', tabWidth: 4, printWidth: 120, @@ -100,8 +127,10 @@ const transpileToJS = async tsCode => { return jsCode; }; -const buildJSCodeBlocksFromTS = async () => { - const tsBlocks = document.querySelectorAll('div.language-tsx'); +export const buildJSCodeBlocksFromTS = async ( + selector = 'div.language-tsx' +) => { + const tsBlocks = document.querySelectorAll(selector); await Promise.all( Array.from(tsBlocks).map(async (block, index) => { @@ -185,232 +214,9 @@ const buildJSCodeBlocksFromTS = async () => { applyPreferredLanguage(); }; -// eslint-disable-next-line no-unused-vars -function slugify(text) { - return text - .toString() - .toLowerCase() - .replace(/\s+/g, '-') // Replace spaces with - - .replace(/[^\w-]+/g, '') // Remove all non-word chars - .replace(/--+/g, '-') // Replace multiple - with single - - .replace(/^-+/, '') // Trim - from start of text - .replace(/-+$/, ''); // Trim - from end of text -} - -function navigationFitScroll() { - var scrollIntoView = window.sessionStorage.getItem('scrollIntoView'); - if (scrollIntoView !== 'false') { - var activeMenu = document.querySelector('.sidenav li.active'); - if (activeMenu) activeMenu.parentNode.scrollIntoView(); - } - window.sessionStorage.removeItem('scrollIntoView'); -} - -function buildPageToC() { - var M = window.M; - M.Sidenav.init(document.querySelectorAll('.sidenav')); - // Initialize version selector - M.Dropdown.init(document.querySelectorAll('.dropdown-trigger')); - - var Prism = window.Prism; - Prism.highlightAll(); - - /* Generate table of contents */ - if (document.querySelector('.toc') != null) { - var tocbot = window.tocbot; - tocbot.init({ - // Where to render the table of contents - tocSelector: '.toc', - positionFixedSelector: '.toc', - // Where to grab the headings to build the table of contents - contentSelector: '.toc-content', - // More options - headingSelector: 'h2, h3, h4', - includeHtml: true, - collapseDepth: 2, - hasInnerContainers: true, - }); - } -} - -function replaceContent(text) { - var tocContainer = document.querySelector('.toc-container'); - tocContainer.className = - text.trim() !== '' - ? 'toc-container col hide-on-small-only m3' - : 'toc-container'; - - var tmpElement = document.createElement('div'); - tmpElement.innerHTML = text; - - toggleDockBlocks(false); - - var content = document.querySelector('.DocSearch-content'); - content.innerHTML = - tmpElement.querySelector('.DocSearch-content').innerHTML; - - window.scrollTo(0, 0); - - buildPageToC(); - - navigationFitScroll(); -} - -function changeSelectedMenu() { - var activeMenu = document.querySelector(`.sidenav li.active`); - activeMenu && activeMenu.classList.remove('active'); - allMenus - .find(menuEl => menuEl.href === window.location.href) - .parentNode.classList.add('active'); -} - -function toggleDockBlocks(status) { - const docBlock = document.querySelector('.docBlocks'); - const needHelp = document.querySelector('.needHelp'); - if (status) { - if (docBlock) docBlock.style.display = 'grid'; - if (needHelp) needHelp.style.display = 'block'; - } else { - if (docBlock) docBlock.style.display = 'none'; - if (needHelp) needHelp.style.display = 'none'; - } -} - -function loadNewsletterScript() { - /* Load the script only of the form is in the DOM */ - if (document.querySelector('#sib-form') != null) { - const script = document.createElement('script'); - script.src = 'https://sibforms.com/forms/end-form/build/main.js'; - script.type = 'text/javascript'; - script.id = 'newsletter_script'; - document.head.appendChild(script); - } else { - document.getElementById('newsletter_script')?.remove(); - } -} - -/** - * Beginner mode - */ - -let beginnerMode = window.localStorage.getItem('beginner-mode') === 'true'; - -function hideNonBeginnerDoc() { - const chapters = document.querySelectorAll('.sidenav > ul li'); - chapters.forEach(chapter => { - if (!chapter.classList.contains('beginner')) { - chapter.style.display = 'none'; - } - }); - document.querySelectorAll('.beginner-mode-on').forEach(el => { - el.style.display = 'block'; - }); -} - -function showNonBeginnerDoc() { - const chapters = document.querySelectorAll('.sidenav > ul li'); - chapters.forEach(chapter => { - chapter.style.display = 'list-item'; - }); - document.querySelectorAll('.beginner-mode-on').forEach(el => { - el.style.display = 'none'; - }); -} - -document.addEventListener('DOMContentLoaded', () => { - const beginnerModeTrigger = document.getElementById( - 'beginner-mode-trigger' - ); - - if (beginnerModeTrigger) { - beginnerModeTrigger.addEventListener('click', () => { - beginnerMode = !beginnerMode; - if (beginnerMode) { - window.localStorage.setItem('beginner-mode', 'true'); - hideNonBeginnerDoc(); - } else { - window.localStorage.removeItem('beginner-mode'); - showNonBeginnerDoc(); - } - }); - - beginnerModeTrigger.checked = beginnerMode; - if (beginnerMode) { - hideNonBeginnerDoc(); - } - } -}); - -// Replace full page reloads by a fill of the content area -// so that the side navigation keeps its state -// use a global event listener to also catch links inside the content area -document.addEventListener('click', event => { - var link = event.target.closest('a'); - if (!link) { - return; // click not on a link - } - var location = document.location.href.split('#')[0]; - var href = link.href; - if (href.indexOf(`${location}#`) === 0) { - return; // click on an anchor in the current page - } - if (!navLinks.includes(href)) { - return; // not a navigation link - } - window.sessionStorage.setItem( - 'scrollIntoView', - link.closest('.sidenav') ? 'false' : 'true' - ); - // now we're sure it's an internal navigation link - // transform it to an AJAX call - event.preventDefault(); - // update versions links - var currentPage = href.split('/').pop(); - versionsLinks.forEach(link => { - link.href = - link.href.substr(0, link.href.lastIndexOf('/') + 1) + currentPage; - }); - // fetch the new content - fetch(href) - .then(res => res.text()) - .then(replaceContent) - .then(buildJSCodeBlocksFromTS) - .then(loadNewsletterScript); - // change the URL - window.history.pushState(null, null, href); - changeSelectedMenu(); -}); - -// make back button work again -window.addEventListener('popstate', () => { - if (document.location.href.indexOf('#') !== -1) { - // popstate triggered by a click on an anchor, not back button - return; - } +window.addEventListener('DOMContentLoaded', () => { if (window.location.pathname === '/documentation.html') { - document.querySelector('.DocSearch-content').innerHTML = ''; - toggleDockBlocks(true); - } else { - // fetch the new content - fetch(window.location.pathname) - .then(res => res.text()) - .then(replaceContent) - .then(buildJSCodeBlocksFromTS) - .then(loadNewsletterScript); + showTip(); } - changeSelectedMenu(); -}); - -window.addEventListener('DOMContentLoaded', () => { - allMenus = Array.from(document.querySelectorAll(`.sidenav a.nav-link`)); - navLinks = allMenus - .filter(link => !link.classList.contains('external')) - .map(link => link.href); - versionsLinks = Array.from(document.querySelectorAll('#versions > li > a')); - - buildPageToC(); - - navigationFitScroll(); buildJSCodeBlocksFromTS(); - loadNewsletterScript(); }); diff --git a/docs/js/ra-navigation.js b/docs/js/ra-navigation.js new file mode 100644 index 00000000000..9977788c606 --- /dev/null +++ b/docs/js/ra-navigation.js @@ -0,0 +1,270 @@ +let allMenus, navLinks, versionsLinks; + +function hideTips() { + const tipElement = document.getElementById('tip'); + const tipContainer = document.getElementById('tip-container'); + + if (tipElement) { + tipElement.remove(); + } + if (tipContainer) { + tipContainer.remove(); + } +} + +// eslint-disable-next-line no-unused-vars +function slugify(text) { + return text + .toString() + .toLowerCase() + .replace(/\s+/g, '-') // Replace spaces with - + .replace(/[^\w-]+/g, '') // Remove all non-word chars + .replace(/--+/g, '-') // Replace multiple - with single - + .replace(/^-+/, '') // Trim - from start of text + .replace(/-+$/, ''); // Trim - from end of text +} + +function navigationFitScroll() { + const scrollIntoView = window.sessionStorage.getItem('scrollIntoView'); + if (scrollIntoView !== 'false') { + const activeMenu = document.querySelector('.sidenav li.active'); + if (activeMenu) activeMenu.parentNode.scrollIntoView(); + } + window.sessionStorage.removeItem('scrollIntoView'); +} + +function buildPageToC() { + const M = window.M; + M.Sidenav.init(document.querySelectorAll('.sidenav')); + // Initialize version selector + M.Dropdown.init(document.querySelectorAll('.dropdown-trigger')); + + const Prism = window.Prism; + Prism.highlightAll(); + + /* Generate table of contents */ + if (document.querySelector('.toc') != null) { + const tocbot = window.tocbot; + tocbot.init({ + // Where to render the table of contents + tocSelector: '.toc', + positionFixedSelector: '.toc', + // Where to grab the headings to build the table of contents + contentSelector: '.toc-content', + // More options + headingSelector: 'h2, h3, h4', + includeHtml: true, + collapseDepth: 2, + hasInnerContainers: true, + }); + } +} + +function replaceContent(text) { + const tocContainer = document.querySelector('.toc-container'); + let tmpElement; + if (tocContainer) { + tocContainer.className = + text.trim() !== '' + ? 'toc-container col hide-on-small-only m3' + : 'toc-container'; + + tmpElement = document.createElement('div'); + tmpElement.innerHTML = text; + } + + const content = document.querySelector('.container'); + const tmpContent = tmpElement.querySelector('.container'); + if (content && tmpContent) { + content.innerHTML = tmpContent.innerHTML; + } + + window.scrollTo(0, 0); + + buildPageToC(); + + navigationFitScroll(); +} + +function changeSelectedMenu() { + const activeMenu = document.querySelector(`.sidenav li.active`); + activeMenu && activeMenu.classList.remove('active'); + const newActiveMenu = allMenus.find( + menuEl => menuEl.href === window.location.href + ); + newActiveMenu && newActiveMenu.parentNode.classList.add('active'); +} + +function toggleDockBlocks(status) { + const docBlock = document.querySelector('.docBlocks'); + const needHelp = document.querySelector('.needHelp'); + if (status) { + if (docBlock) docBlock.style.display = 'grid'; + if (needHelp) needHelp.style.display = 'block'; + } else { + if (docBlock) docBlock.style.display = 'none'; + if (needHelp) needHelp.style.display = 'none'; + } +} + +function loadNewsletterScript() { + /* Load the script only of the form is in the DOM */ + if (document.querySelector('#sib-form') != null) { + const script = document.createElement('script'); + script.src = 'https://sibforms.com/forms/end-form/build/main.js'; + script.type = 'text/javascript'; + script.id = 'newsletter_script'; + document.head.appendChild(script); + } else { + document.getElementById('newsletter_script')?.remove(); + } +} + +/** + * Beginner mode + */ + +let beginnerMode = window.localStorage.getItem('beginner-mode') === 'true'; + +function hideNonBeginnerDoc() { + const chapters = document.querySelectorAll('.sidenav > ul li'); + chapters.forEach(chapter => { + if (!chapter.classList.contains('beginner')) { + chapter.style.display = 'none'; + } + }); + document.querySelectorAll('.beginner-mode-on').forEach(el => { + el.style.display = 'block'; + }); +} + +function showNonBeginnerDoc() { + const chapters = document.querySelectorAll('.sidenav > ul li'); + chapters.forEach(chapter => { + chapter.style.display = 'list-item'; + }); + document.querySelectorAll('.beginner-mode-on').forEach(el => { + el.style.display = 'none'; + }); +} + +document.addEventListener('DOMContentLoaded', () => { + const beginnerModeTrigger = document.getElementById( + 'beginner-mode-trigger' + ); + + if (window.location.pathname === '/documentation.html') { + } + + if (beginnerModeTrigger) { + beginnerModeTrigger.addEventListener('click', () => { + beginnerMode = !beginnerMode; + if (beginnerMode) { + window.localStorage.setItem('beginner-mode', 'true'); + hideNonBeginnerDoc(); + } else { + window.localStorage.removeItem('beginner-mode'); + showNonBeginnerDoc(); + } + }); + + beginnerModeTrigger.checked = beginnerMode; + if (beginnerMode) { + hideNonBeginnerDoc(); + } + } +}); + +// Replace full page reloads by a fill of the content area +// so that the side navigation keeps its state +// use a global event listener to also catch links inside the content area +document.addEventListener('click', event => { + const link = event.target.closest('a'); + if (!link) { + return; // click not on a link + } + const location = document.location.href.split('#')[0]; + const href = link.href; + if (href.indexOf(`${location}#`) === 0) { + return; // click on an anchor in the current page + } + if (!navLinks.includes(href)) { + return; // not a navigation link + } + window.sessionStorage.setItem( + 'scrollIntoView', + link.closest('.sidenav') ? 'false' : 'true' + ); + // now we're sure it's an internal navigation link + // transform it to an AJAX call + event.preventDefault(); + // update versions links + const currentPage = href.split('/').pop(); + versionsLinks.forEach(link => { + link.href = + link.href.substr(0, link.href.lastIndexOf('/') + 1) + currentPage; + }); + // fetch the new content + fetch(href) + .then(res => res.text()) + .then(replaceContent) + .then(() => import('./ra-doc-exec.js')) + .then(docExecModule => { + if (href.includes('documentation.html')) { + docExecModule.showTip(); + } else { + hideTips(); + } + docExecModule.buildJSCodeBlocksFromTS(); + }) + .then(loadNewsletterScript); + // change the URL + window.history.pushState(null, null, href); + changeSelectedMenu(); +}); + +// make back button work again +window.addEventListener('popstate', () => { + if (document.location.href.indexOf('#') !== -1) { + // popstate triggered by a click on an anchor, not back button + return; + } + + if (window.location.pathname === '/documentation.html') { + fetch(window.location.pathname) + .then(res => res.text()) + .then(replaceContent) + .then(() => import('./ra-doc-exec.js')) + .then(docExecModule => { + document.querySelector('.DocSearch-content').innerHTML = ''; + toggleDockBlocks(true); + docExecModule.showTip(); + }); + } else { + // fetch the new content + fetch(window.location.pathname) + .then(res => res.text()) + .then(replaceContent) + .then(() => { + toggleDockBlocks(false); + }) + .then(hideTips) + .then(() => import('./ra-doc-exec.js')) + .then(docExecModule => docExecModule.buildJSCodeBlocksFromTS()) + .then(loadNewsletterScript); + } + changeSelectedMenu(); +}); + +window.addEventListener('DOMContentLoaded', () => { + console.log('DOMContentLoaded'); + allMenus = Array.from(document.querySelectorAll(`.sidenav a.nav-link`)); + navLinks = allMenus + .filter(link => !link.classList.contains('external')) + .map(link => link.href); + versionsLinks = Array.from(document.querySelectorAll('#versions > li > a')); + + buildPageToC(); + navigationFitScroll(); + loadNewsletterScript(); +});