Skip to content

Commit

Permalink
Merge pull request #8145 from marmelab/configurable
Browse files Browse the repository at this point in the history
Configurable components
  • Loading branch information
slax57 committed Sep 28, 2022
2 parents e0a3104 + 7315488 commit 517c014
Show file tree
Hide file tree
Showing 64 changed files with 1,972 additions and 116 deletions.
195 changes: 195 additions & 0 deletions docs/Configurable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
---
layout: default
title: "The Configurable Component"
---

# `<Configurable>`

This 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.

![SimpleListConfigurable](./img/SimpleListConfigurable.gif)

Some react-admin components are already configurable - or rather they have a configurable counterpart:

- [`<SimpleListConfigurable>`](./SimpleList.md#configurable)
- `<PageTitleConfigurable>` - used by the `<Title>` component

## Usage

Wrap any component with `<Configurable>` and define its editor component to let users customize it via a UI. Don't forget to pass down props to the inner component. Note that every configurable component needs a unique preference key, that is used to persist the user's preferences in the Store.

```jsx
import { Configurable } from 'react-admin';

const ConfigurableTextBlock = ({ preferenceKey = "textBlock", ...props }) => (
<Configurable editor={<TextBlockEditor />} preferenceKey={preferenceKey}>
<TextBlock {...props} />
</Configurable>
);
```

`<Configurable>` creates a context for the `preferenceKey`, so that both the child component and the editor can access it.

The editor commponent lets users edit the preferences for the configurable compoonent. It does so using the `usePreference` hook, which is a namespaced version of [the `useStore` hook](./useStore.md) for the current `preferenceKey`:

```jsx
import { usePreference } from 'react-admin';

const TextBlockEditor = () => {
const [color, setColor] = usePreference('color', '#ffffff');
// equivalent to:
// const [color, setColor] = useStore('textBlock.color', '#ffffff');
return (
<Box>
<Typography>Configure the text block</Typography>
<TextField
label="Color"
value={color}
onChange={e => setColor(e.target.value)}
/>
</Box>
);
};
```

The inner component reads the preferences using the same `usePreference` hook:

```jsx
const TextBlock = ({ title, content }) => {
const [color] = usePreference('color', '#ffffff');
return (
<Box bgcolor={color}>
<Typography variant="h6">{title}</Typography>
<Typography>{content}</Typography>
</Box>
);
};
```

Then, use the configurable component in your app:

```jsx
import { ConfigurableTextBlock } from './ConfigurableTextBlock';

export const Dashboard = () => (
<ConfigurableTextBlock
title="Welcome to the administration"
content="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
/>
);
```

## `children`

The wrapped component can be any component relying on `usePreference`. Configurable components let users customize their content, look and feel, and behavior.

For instance, the following `<TextBlock>` component lets end users change its foreground and background colors:

{% raw %}
```jsx
import { usePreference } from 'react-admin';

const TextBlock = ({ title, content }) => {
const [color] = usePreference('color', 'primary.contrastTest');
const [bgcolor] = usePreference('bgcolor', 'primary.main');
return (
<Box sx={{ color, bgcolor }}>
<Typography variant="h6">{title}</Typography>
<Typography>{content}</Typography>
</Box>
);
};
```
{% endraw %}

## `editor`

The `editor` component should let the user change the settings of the child component - usually via form controls. When the user enters configuration mode then selects the configurable component, react-admin renders the `editor` component in the inspector.

The editor component must also use `usePreference` to read and write a given preference.

For instance, here is a simple editor for the above `<TextBlock>` component, letting users customize the foreground and background colors:

```jsx
import { usePreference } from 'react-admin';

const TextBlockEditor = () => {
const [color, setColor] = usePreference('color', 'primary.contrastTest');
const [bgcolor, setBgcolor] = usePreference('bgcolor', 'primary.main');
return (
<Box>
<Typography>Configure the text block</Typography>
<TextField
label="Color"
value={color}
onChange={e => setColor(e.target.value)}
/>
<TextField
label="Background Color"
value={bgcolor}
onChange={e => setBgcolor(e.target.value)}
/>
</Box>
);
};
```

In practice, instead of updating the preferences on change like in the above example, you should wait for the user to validate the input. Otherwise, the setting may temporarily have an invalid value (e.g., when entering the string 'primary.main', the value may temporarily be 'prim', which is invalid).

React-admin provides a `usePreferenceInput` hook to help you with that. It returns an object with the following properties: `{ value, onChange, onBlur, onKeyDown }`, and you can directly pass it to an input component:

```jsx
import { usePreferenceInput } from 'react-admin';

const TextBlockEditor = () => {
const colorField = usePreferenceInput('color', 'primary.contrastTest');
const bgcolorField = usePreferenceInput('bgcolor', 'primary.main');
return (
<Box>
<Typography>Configure the text block</Typography>
<TextField label="Color" {...colorField} />
<TextField label="Background Color" {...bgcolorField} />
</Box>
);
};
```

`usePreferenceInput` changes the preference on blur, or when the user presses the Enter key. Just like `usePreference`, it uses the `preferenceKey` from the context to namespace the preference.

## `preferenceKey`

This parameter lets you specify the key used to store the configuration in the user's preferences. This allows you to have more than one configurable component of the same type per page.

```jsx
import { Configurable } from 'react-admin';

const ConfigurableTextBlock = ({ preferenceKey, ...props }) => (
<Configurable editor={<TextBlockInspector />} preferenceKey={preferenceKey}>
<TextBlock {...props} />
</Configurable>
);
```

Then in your application, set the `preferenceKey` prop to a unique value for each component:

```jsx
import { ConfigurableTextBlock } from './ConfigurableTextBlock';

export const Dashboard = () => (
<>
<ConfigurableTextBlock
preferenceKey="textBlock1"
title="Welcome to the administration"
content="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
/>
<ConfigurableTextBlock
preferenceKey="textBlock2"
title="Security reminder"
content="Nullam bibendum orci tortor, a posuere arcu sollicitudin ac"
/>
</>
);
```

Users will be able to customize each component independently.

1 change: 0 additions & 1 deletion docs/Confirm.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,5 @@ The `<Confirm>` component accepts the usual `className` prop. You can also overr
|---------------------------------|----------------------------------------------------------------|
| `& .RaConfirm-confirmPrimary` | Applied to the confirm button when `confirmColor` is `primary` |
| `& .RaConfirm-confirmWarning` | Applied to the confirm button when `confirmColor` is `warning` |
| `& .RaConfirm-iconPaddingStyle` | Applied to the confirm and cancel icon elements |

To override the style of all instances of `<Confirm>` using the [MUI style overrides](https://mui.com/customization/globals/#css), use the `RaConfirm` key.
73 changes: 70 additions & 3 deletions docs/SimpleList.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,43 @@ export const PostList = () => (

## `primaryText`

The `primaryText`, `secondaryText` and `tertiaryText` functions can be either a function returning a string, or a React element. This means you can use any react-admin field, including reference fields:
The `primaryText`, `secondaryText` and `tertiaryText` props can accept 3 types of values:

1. a function returning a string,
2. a string,
3. a React element.

If it's a **function**, react-admin passes the current record as parameter:

```jsx
import { List, SimpleList } from 'react-admin';

export const PostList = () => (
<List>
<SimpleList
primaryText={record => record.title}
secondaryText={record => `${record.views} views`}
/>
</List>
);
```

If it's a **string**, react-admin passes it to [the `translate` function](./useTranslate.md), together with the `record` so you can use substitutions with the `%{token}` syntax:

```jsx
import { List, SimpleList } from 'react-admin';

export const PostList = () => (
<List>
<SimpleList
primaryText="%{title}"
secondaryText="%{views} views"
/>
</List>
);
```

If it's a **React element**, react-admin renders it. This means you can use any react-admin field, including reference fields:

```jsx
import {
Expand All @@ -97,8 +133,7 @@ export const PostList = () => (
<List>
<SimpleList
primaryText={<TextField source="title" />}
secondaryText={record => `${record.views} views`}
tertiaryText={
secondaryText={
<ReferenceField reference="categories" source="category_id">
<TextField source="name" />
</ReferenceField>
Expand Down Expand Up @@ -172,3 +207,35 @@ export const PostList = props => {
);
}
```

## Configurable

You can let end users customize the fields displayed in the `<SimpleList>` by using the `<SimpleListConfigurable>` component instead.

![SimpleListConfigurable](./img/SimpleListConfigurable.gif)

```diff
import {
List,
- SimpleList,
+ SimpleListConfigurable,
} from 'react-admin';

export const BookList = () => (
<List>
- <SimpleList
+ <SimpleListConfigurable
primaryText={record => record.title}
secondaryText={record => record.author}
tertiaryText={record => record.date}
/>
</List>
);
```

When users enter the configuration mode and select the `<SimpleList>`, they can set the `primaryText`, `secondaryText`, and `tertiaryText` fields via the inspector. `<SimpleList>` uses [the `useTranslate` hook](./useTranslate.md) to render the fields. The `translate` function receives the current record as parameter. This means users can access the record field using the `%{field}` syntax, e.g.:

```
Title: %{title} (by %{author})
```

Binary file added docs/img/SimpleListConfigurable.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/navigation.html
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,12 @@

<ul><div>The Store</div>
<li {% if page.path == 'Store.md' %} class="active" {% endif %}><a class="nav-link" href="./Store.html">Introduction</a></li>
<li {% if page.path == 'LocalesMenuButton.md' %} class="active" {% endif %}><a class="nav-link" href="./LocalesMenuButton.html"><code>&lt;LocalesMenuButton&gt;</code></a></li>
<li {% if page.path == 'ToggleThemeButton.md' %} class="active" {% endif %}><a class="nav-link" href="./ToggleThemeButton.html"><code>&lt;ToggleThemeButton&gt;</code></a></li>
<li {% if page.path == 'useStore.md' %} class="active" {% endif %}><a class="nav-link" href="./useStore.html"><code>useStore</code></a></li>
<li {% if page.path == 'useRemoveFromStore.md' %} class="active" {% endif %}><a class="nav-link" href="./useRemoveFromStore.html"><code>useRemoveFromStore</code></a></li>
<li {% if page.path == 'useResetStore.md' %} class="active" {% endif %}><a class="nav-link" href="./useResetStore.html"><code>useResetStore</code></a></li>
<li {% if page.path == 'useStoreContext.md' %} class="active" {% endif %}><a class="nav-link" href="./useStoreContext.html"><code>useStoreContext</code></a></li>
<li {% if page.path == 'Configurable.md' %} class="active" {% endif %}><a class="nav-link" href="./Configurable.html"><code>&lt;Configurable&gt;</code></a></li>
<li {% if page.path == 'ToggleThemeButton.md' %} class="active" {% endif %}><a class="nav-link" href="./ToggleThemeButton.html"><code>&lt;ToggleThemeButton&gt;</code></a></li>
</ul>

<ul><div>I18N Provider and Translations</div>
Expand Down
15 changes: 11 additions & 4 deletions examples/simple/src/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import * as React from 'react';

import { memo } from 'react';
import { ReactQueryDevtools } from 'react-query/devtools';
import { Layout } from 'react-admin';
import { CssBaseline } from '@mui/material';
import { AppBar, Layout, InspectorButton } from 'react-admin';
import { CssBaseline, Typography } from '@mui/material';

const MyAppBar = memo(props => (
<AppBar {...props}>
<Typography flex="1" variant="h6" id="react-admin-title" />
<InspectorButton />
</AppBar>
));

export default props => (
<>
<CssBaseline />
<Layout {...props} />
<Layout {...props} appBar={MyAppBar} />
<ReactQueryDevtools
initialIsOpen={false}
toggleButtonProps={{ style: { width: 20, height: 30 } }}
Expand Down
25 changes: 14 additions & 11 deletions packages/ra-core/src/core/CoreAdminContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
defaultDataProvider,
} from '../dataProvider';
import { StoreContextProvider, Store, memoryStore } from '../store';
import { PreferencesEditorContextProvider } from '../preferences/PreferencesEditorContextProvider';
import { I18nContextProvider } from '../i18n';
import { ResourceDefinitionContextProvider } from './ResourceDefinitionContext';
import { NotificationContextProvider } from '../notification';
Expand Down Expand Up @@ -81,17 +82,19 @@ React-admin requires a valid dataProvider function to work.`);
<AuthContext.Provider value={finalAuthProvider}>
<DataProviderContext.Provider value={finalDataProvider}>
<StoreContextProvider value={store}>
<QueryClientProvider client={finalQueryClient}>
<AdminRouter history={history} basename={basename}>
<I18nContextProvider value={i18nProvider}>
<NotificationContextProvider>
<ResourceDefinitionContextProvider>
{children}
</ResourceDefinitionContextProvider>
</NotificationContextProvider>
</I18nContextProvider>
</AdminRouter>
</QueryClientProvider>
<PreferencesEditorContextProvider>
<QueryClientProvider client={finalQueryClient}>
<AdminRouter history={history} basename={basename}>
<I18nContextProvider value={i18nProvider}>
<NotificationContextProvider>
<ResourceDefinitionContextProvider>
{children}
</ResourceDefinitionContextProvider>
</NotificationContextProvider>
</I18nContextProvider>
</AdminRouter>
</QueryClientProvider>
</PreferencesEditorContextProvider>
</StoreContextProvider>
</DataProviderContext.Provider>
</AuthContext.Provider>
Expand Down
12 changes: 9 additions & 3 deletions packages/ra-core/src/i18n/I18nContext.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { createContext } from 'react';
import { I18nProvider } from '../types';
import { substituteTokens } from './substituteTokens';

export type I18nContextProps = I18nProvider;

export const I18nContext = createContext<I18nProvider>({
translate: x => x,
const defaultI18nProvider = {
translate: (key, options) =>
options?._
? substituteTokens(options._, options)
: substituteTokens(key, options),
changeLocale: () => Promise.resolve(),
getLocale: () => 'en',
});
};

export const I18nContext = createContext<I18nProvider>(defaultI18nProvider);

I18nContext.displayName = 'I18nContext';
Loading

0 comments on commit 517c014

Please sign in to comment.