Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SimpleFormConfigurable component #8395

Merged
merged 14 commits into from
Nov 18, 2022
61 changes: 61 additions & 0 deletions docs/SimpleForm.md
Original file line number Diff line number Diff line change
@@ -492,3 +492,64 @@ export const UserCreate = () => {
}
```
{% endraw %}

## Configurable

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

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

```diff
import {
Edit,
- SimpleForm,
+ SimpleFormConfigurable,
TextInput,
} from 'react-admin';

const PostEdit = () => (
<Edit>
- <SimpleForm>
+ <SimpleFormConfigurable>
<TextInput source="title" />
<TextInput source="author" />
<TextInput source="year" />
- </SimpleForm>
+ </SimpleFormConfigurable>
</Edit>
);
```

When users enter the configuration mode and select the `<SimpleForm>`, they can show / hide SimpleForm inputs.

By default, `<SimpleFormConfigurable>` renders all child inputs. But you can also omit some of them by passing an `omit` prop containing an array of input sources:

```jsx
// By default, hide the author input
// users can choose to show it in configuration mode
const PostEdit = () => (
<Edit>
<SimpleFormConfigurable omit={['author']}>
<TextInput source="title" />
<TextInput source="author" />
<TextInput source="year" />
</SimpleFormConfigurable>
</Edit>
);
```

If you render more than one `<SimpleFormConfigurable>` in the same page, you must pass a unique `preferenceKey` prop to each one:

```jsx
const PostEdit = () => (
<Edit>
<SimpleFormConfigurable preferenceKey="posts.simpleForm">
<TextInput source="title" />
<TextInput source="author" />
<TextInput source="year" />
</SimpleFormConfigurable>
</Edit>
);
```

`<SimpleFormConfigurable>` accepts the same props as `<SimpleForm>`.
Binary file added docs/img/SimpleFormConfigurable.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions examples/simple/src/comments/CommentCreate.tsx
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ import {
Create,
DateInput,
TextInput,
SimpleForm,
SimpleFormConfigurable,
required,
minLength,
} from 'react-admin'; // eslint-disable-line import/no-unresolved
@@ -15,7 +15,7 @@ const defaultSort = { field: 'title', order: 'ASC' };

const CommentCreate = () => (
<Create redirect={false}>
<SimpleForm>
<SimpleFormConfigurable>
<PostReferenceInput
source="post_id"
reference="posts"
@@ -26,7 +26,7 @@ const CommentCreate = () => (
<TextInput source="author.name" validate={minLength(10)} />
<DateInput source="created_at" defaultValue={now} />
<TextInput fullWidth source="body" multiline />
</SimpleForm>
</SimpleFormConfigurable>
</Create>
);

6 changes: 3 additions & 3 deletions examples/simple/src/posts/PostCreate.tsx
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@ import {
ReferenceInput,
SaveButton,
SelectInput,
SimpleForm,
SimpleFormConfigurable,
SimpleFormIterator,
TextInput,
Toolbar,
@@ -103,7 +103,7 @@ const PostCreate = () => {
const dateDefaultValue = useMemo(() => new Date(), []);
return (
<Create redirect="edit">
<SimpleForm
<SimpleFormConfigurable
toolbar={<PostCreateToolbar />}
defaultValues={defaultValues}
>
@@ -193,7 +193,7 @@ const PostCreate = () => {
</SimpleFormIterator>
</ArrayInput>
)}
</SimpleForm>
</SimpleFormConfigurable>
</Create>
);
};
8 changes: 3 additions & 5 deletions examples/simple/src/tags/TagCreate.tsx
Original file line number Diff line number Diff line change
@@ -2,21 +2,19 @@
import * as React from 'react';
import {
Create,
SimpleForm,
TextField,
SimpleFormConfigurable,
TextInput,
required,
TranslatableInputs,
} from 'react-admin';

const TagCreate = () => (
<Create redirect="list">
<SimpleForm>
<TextField source="id" />
<SimpleFormConfigurable>
<TranslatableInputs locales={['en', 'fr']}>
<TextInput source="name" validate={[required()]} />
</TranslatableInputs>
</SimpleForm>
</SimpleFormConfigurable>
</Create>
);

6 changes: 3 additions & 3 deletions examples/simple/src/tags/TagEdit.tsx
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import * as React from 'react';
import { useParams } from 'react-router';
import {
Edit,
SimpleForm,
SimpleFormConfigurable,
TextField,
TextInput,
required,
@@ -19,12 +19,12 @@ const TagEdit = () => {
return (
<>
<Edit redirect="list">
<SimpleForm warnWhenUnsavedChanges>
<SimpleFormConfigurable warnWhenUnsavedChanges>
<TextField source="id" />
<TranslatableInputs locales={['en', 'fr']}>
<TextInput source="name" validate={[required()]} />
</TranslatableInputs>
</SimpleForm>
</SimpleFormConfigurable>
</Edit>
<ResourceContextProvider value="posts">
<List
14 changes: 11 additions & 3 deletions packages/ra-core/src/i18n/TranslationMessages.ts
Original file line number Diff line number Diff line change
@@ -179,15 +179,23 @@ export interface TranslationMessages extends StringMap {
configurable?: {
customize: string;
configureMode: string;
Datagrid: {
unlabeled: string;
};
inspector: {
title: string;
content: string;
reset: string;
hideAll: string;
showAll: string;
};
Datagrid: {
title: string;
unlabeled: string;
};
SimpleForm: {
title: string;
unlabeled: string;
};
SimpleList: {
title: string;
primaryText: string;
secondaryText: string;
tertiaryText: string;
14 changes: 11 additions & 3 deletions packages/ra-language-english/src/index.ts
Original file line number Diff line number Diff line change
@@ -180,15 +180,23 @@ const englishMessages: TranslationMessages = {
configurable: {
customize: 'Customize',
configureMode: 'Configure this page',
Datagrid: {
unlabeled: 'Unlabeled column #%{column}',
},
inspector: {
title: 'Inspector',
content: 'Hover the application UI elements to configure them',
reset: 'Reset Settings',
hideAll: 'Hide All',
showAll: 'Show All',
},
Datagrid: {
title: 'Datagrid',
unlabeled: 'Unlabeled column #%{column}',
},
SimpleForm: {
title: 'Form',
unlabeled: 'Unlabeled input #%{input}',
},
SimpleList: {
title: 'List',
primaryText: 'Primary text',
secondaryText: 'Secondary text',
tertiaryText: 'Tertiary text',
14 changes: 11 additions & 3 deletions packages/ra-language-french/src/index.ts
Original file line number Diff line number Diff line change
@@ -186,15 +186,23 @@ const frenchMessages: TranslationMessages = {
configurable: {
customize: 'Personnaliser',
configureMode: 'Configurer cette page',
Datagrid: {
unlabeled: 'Colonne sans label #%{column}',
},
inspector: {
title: 'Inspecteur',
content: 'Sélectionner un composant pour le configurer',
reset: 'Réinitialiser',
hideAll: 'Masquer tout',
showAll: 'Afficher tout',
},
Datagrid: {
title: 'Tableau',
unlabeled: 'Colonne #%{column}',
},
SimpleForm: {
title: 'Formulaire',
unlabeled: 'Champ #%{input}',
},
SimpleList: {
title: 'Liste',
primaryText: 'Texte principal',
secondaryText: 'Texte secondaire',
tertiaryText: 'Texte annexe',
47 changes: 47 additions & 0 deletions packages/ra-ui-materialui/src/form/SimpleFormConfigurable.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import expect from 'expect';

import { Basic, Omit, PreferenceKey } from './SimpleFormConfigurable.stories';

describe('<SimpleFormConfigurable>', () => {
const enterConfigurationMode = async () => {
screen.getByLabelText('Configure mode').click();
await screen.findByText('Inspector');
fireEvent.mouseOver(screen.getAllByDisplayValue('War and Peace')[0]);
await screen.getByTitle('ra.configurable.customize').click();
await screen.findByText('Form');
};
it('should render a form with configurable inputs', async () => {
render(<Basic />);
await enterConfigurationMode();
expect(screen.queryByDisplayValue('Leo Tolstoy')).not.toBeNull();
screen.getAllByLabelText('Author')[0].click();
expect(screen.queryByDisplayValue('Leo Tolstoy')).toBeNull();
screen.getAllByLabelText('Author')[0].click();
expect(screen.queryByDisplayValue('Leo Tolstoy')).not.toBeNull();
});
describe('omit', () => {
it('should not render omitted inputs by default', async () => {
render(<Omit />);
expect(screen.queryByLabelText('Author')).toBeNull();
expect(screen.queryByDisplayValue('Leo Tolstoy')).toBeNull();
await enterConfigurationMode();
screen.getByLabelText('Author').click();
expect(screen.queryByDisplayValue('Leo Tolstoy')).not.toBeNull();
});
});
describe('preferenceKey', () => {
it('should allow two ConfigurableDatagrid not to share the same preferences', async () => {
render(<PreferenceKey />);
expect(screen.queryAllByDisplayValue('War and Peace')).toHaveLength(
2
);
await enterConfigurationMode();
screen.getAllByLabelText('Title')[0].click();
expect(screen.queryAllByDisplayValue('War and Peace')).toHaveLength(
1
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import * as React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { PreferencesEditorContextProvider, I18nContextProvider } from 'ra-core';
import { ThemeProvider, createTheme, Box, Paper } from '@mui/material';
import { QueryClientProvider, QueryClient } from 'react-query';
import polyglotI18nProvider from 'ra-i18n-polyglot';
import en from 'ra-language-english';

import { Inspector, InspectorButton } from '../preferences';
import { NumberInput, TextInput } from '../input';
import { SimpleFormConfigurable } from './SimpleFormConfigurable';
import { defaultTheme } from '../defaultTheme';

export default { title: 'ra-ui-materialui/forms/SimpleFormConfigurable' };

const data = {
id: 1,
title: 'War and Peace',
author: 'Leo Tolstoy',
year: 1869,
};

const Wrapper = ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider theme={createTheme(defaultTheme)}>
<PreferencesEditorContextProvider>
<MemoryRouter>
<Inspector />
<Box display="flex" justifyContent="flex-end">
<InspectorButton />
</Box>
<Paper sx={{ width: 600, m: 2 }}>{children}</Paper>
</MemoryRouter>
</PreferencesEditorContextProvider>
</ThemeProvider>
</QueryClientProvider>
);

export const Basic = () => (
<Wrapper>
<SimpleFormConfigurable record={data} resource="books">
<TextInput source="title" fullWidth />
<TextInput source="author" />
<NumberInput source="year" />
</SimpleFormConfigurable>
</Wrapper>
);

export const Omit = () => (
<Wrapper>
<SimpleFormConfigurable
record={data}
resource="books2"
omit={['author']}
>
<TextInput source="title" fullWidth />
<TextInput source="author" />
<NumberInput source="year" />
</SimpleFormConfigurable>
</Wrapper>
);

export const PreferenceKey = () => (
<Wrapper>
<SimpleFormConfigurable
record={data}
resource="books3"
preferenceKey="pref1"
>
<TextInput source="title" fullWidth />
<TextInput source="author" />
<NumberInput source="year" />
</SimpleFormConfigurable>
<SimpleFormConfigurable
record={data}
resource="books3"
preferenceKey="pref2"
>
<TextInput source="title" fullWidth />
<TextInput source="author" />
<NumberInput source="year" />
</SimpleFormConfigurable>
</Wrapper>
);

const translations = { en };
const i18nProvider = polyglotI18nProvider(locale => translations[locale], 'en');

export const I18N = () => (
<I18nContextProvider value={i18nProvider}>
<Wrapper>
<SimpleFormConfigurable record={data} resource="books">
<TextInput source="title" fullWidth />
<TextInput source="author" />
<NumberInput source="year" />
</SimpleFormConfigurable>
</Wrapper>
</I18nContextProvider>
);
128 changes: 128 additions & 0 deletions packages/ra-ui-materialui/src/form/SimpleFormConfigurable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import * as React from 'react';
import {
useResourceContext,
usePreference,
useStore,
useTranslate,
} from 'ra-core';

import { Configurable } from '../preferences';
import { SimpleForm, SimpleFormProps } from './SimpleForm';
import { SimpleFormEditor } from './SimpleFormEditor';

export const SimpleFormConfigurable = ({
preferenceKey,
omit,
...props
}: SimpleFormConfigurableProps) => {
const translate = useTranslate();
const resource = useResourceContext(props);
const finalPreferenceKey = preferenceKey || `${resource}.simpleForm`;

const [availableInputs, setAvailableInputs] = useStore<
SimpleFormConfigurableColumn[]
>(`preferences.${finalPreferenceKey}.availableInputs`, []);

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, setOmit] = useStore<string[]>(
`preferences.${finalPreferenceKey}.omit`,
omit
);

React.useEffect(() => {
// first render, or the preference have been cleared
const inputs = React.Children.map(props.children, (child, index) =>
React.isValidElement(child)
? {
index: String(index),
source: child.props.source,
label:
child.props.source || child.props.label
? child.props.label
: translate(
'ra.configurable.SimpleForm.unlabeled',
{
input: index,
_: `Unlabeled input #%{input}`,
}
),
}
: null
).filter(column => column != null);
if (JSON.stringify(inputs) !== JSON.stringify(availableInputs)) {
setAvailableInputs(inputs);
setOmit(omit);
}
}, [availableInputs]); // eslint-disable-line react-hooks/exhaustive-deps

return (
<Configurable
editor={<SimpleFormEditor />}
preferenceKey={finalPreferenceKey}
sx={{
display: 'block',
'&.RaConfigurable-editMode': {
margin: '2px',
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAde necessary because the outline is invisible for elements used as children of Paper, which has overflow:hidden

},
}}
>
<SimpleFormWithPreferences {...props} />
</Configurable>
);
};

export interface SimpleFormConfigurableProps extends SimpleFormProps {
/**
* Key to use to store the user's preferences for this SimpleForm.
*
* Set to '[resource].simpleForm' by default. Pass a custom key if you need
* to display more than one SimpleFormConfigurable per resource.
*/
preferenceKey?: string;
/**
* columns to hide by default
*
* @example
* // by default, hide the id and author columns
* // users can choose to show show them in configuration mode
* const PostEdit = () => (
* <Edit>
* <SimpleFormConfigurable omit={['id', 'author']}>
* <TextInput source="id" />
* <TextInput source="title" />
* <TextInput source="author" />
* <TextInput source="year" />
* </SimpleFormConfigurable>
* </Edit>
* );
*/
omit?: string[];
}

export interface SimpleFormConfigurableColumn {
index: string;
source: string;
label?: string;
}

/**
* This SimpleForm filters its children depending on preferences
*/
const SimpleFormWithPreferences = ({ children, ...props }: SimpleFormProps) => {
const [availableInputs] = usePreference('availableInputs', []);
const [omit] = usePreference('omit', []);
const [inputs] = usePreference(
'inputs',
availableInputs
.filter(input => !omit?.includes(input.source))
.map(input => input.index)
);
const childrenArray = React.Children.toArray(children);
return (
<SimpleForm {...props}>
{inputs === undefined
? children
: inputs.map(index => childrenArray[index])}
</SimpleForm>
);
};
10 changes: 10 additions & 0 deletions packages/ra-ui-materialui/src/form/SimpleFormEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as React from 'react';
import { useSetInspectorTitle } from 'ra-core';

import { FieldsSelector } from '../preferences';

export const SimpleFormEditor = () => {
useSetInspectorTitle('ra.inspector.SimpleForm.title', { _: 'Form' });

return <FieldsSelector name="inputs" availableName="availableInputs" />;
};
1 change: 1 addition & 0 deletions packages/ra-ui-materialui/src/form/index.tsx
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ export * from './TabbedForm';
export * from './FormTab';
export * from './FormTabHeader';
export * from './SimpleForm';
export * from './SimpleFormConfigurable';
export * from './TabbedForm';
export * from './TabbedFormTabs';
export * from './TabbedFormView';
Original file line number Diff line number Diff line change
@@ -14,11 +14,7 @@ export const SimpleListConfigurable = ({
<Configurable
editor={<SimpleListEditor />}
preferenceKey={preferenceKey || `${resource}.SimpleList`}
sx={{
display: 'block',
'& .MuiBadge-root': { display: 'flex' },
'& ul': { flex: 1 },
}}
sx={{ display: 'block' }}
>
<SimpleListWithPreferences {...props} />
</Configurable>
Original file line number Diff line number Diff line change
@@ -21,7 +21,7 @@ export const SimpleListEditor = (props: SimpleListEditorProps) => {
defaultTertiatyText = '',
} = props;

useSetInspectorTitle('ra.inspector.simple_list', { _: 'List' });
useSetInspectorTitle('ra.inspector.SimpleList.title', { _: 'List' });
const translate = useTranslate();

const primaryTextField = usePreferenceInput(
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import expect from 'expect';

import { Basic, Omit, PreferenceKey } from './DatagridConfigurable.stories';
@@ -9,7 +9,8 @@ describe('<DatagridConfigurable>', () => {
render(<Basic />);
screen.getByLabelText('Configure mode').click();
await screen.findByText('Inspector');
await screen.getAllByTitle('ra.configurable.customize')[0].click();
fireEvent.mouseOver(screen.getByText('Leo Tolstoy'));
await screen.getByTitle('ra.configurable.customize').click();
await screen.findByText('Datagrid');
expect(screen.queryByText('1869')).not.toBeNull();
screen.getByLabelText('Year').click();
@@ -21,7 +22,8 @@ describe('<DatagridConfigurable>', () => {
render(<Basic />);
screen.getByLabelText('Configure mode').click();
await screen.findByText('Inspector');
await screen.getAllByTitle('ra.configurable.customize')[0].click();
fireEvent.mouseOver(screen.getByText('Leo Tolstoy'));
await screen.getByTitle('ra.configurable.customize').click();
await screen.findByText('Datagrid');
expect(screen.queryByText('War and Peace')).not.toBeNull();
screen.getByLabelText('Original title').click();
@@ -33,7 +35,8 @@ describe('<DatagridConfigurable>', () => {
render(<Basic />);
screen.getByLabelText('Configure mode').click();
await screen.findByText('Inspector');
await screen.getAllByTitle('ra.configurable.customize')[0].click();
fireEvent.mouseOver(screen.getByText('Leo Tolstoy'));
await screen.getByTitle('ra.configurable.customize').click();
await screen.findByText('Datagrid');
expect(screen.queryByText('Leo Tolstoy')).not.toBeNull();
screen.getByLabelText('Author').click();
@@ -48,7 +51,8 @@ describe('<DatagridConfigurable>', () => {
expect(screen.queryByText('War and Peace')).toBeNull();
screen.getByLabelText('Configure mode').click();
await screen.findByText('Inspector');
await screen.getAllByTitle('ra.configurable.customize')[0].click();
fireEvent.mouseOver(screen.getByText('Leo Tolstoy'));
await screen.getByTitle('ra.configurable.customize').click();
await screen.findByText('Datagrid');
screen.getByLabelText('Original title').click();
expect(screen.queryByText('War and Peace')).not.toBeNull();
@@ -60,7 +64,8 @@ describe('<DatagridConfigurable>', () => {
expect(screen.queryAllByText('War and Peace')).toHaveLength(2);
screen.getByLabelText('Configure mode').click();
await screen.findByText('Inspector');
await screen.getAllByTitle('ra.configurable.customize')[0].click();
fireEvent.mouseOver(screen.getAllByText('Leo Tolstoy')[0]);
await screen.getByTitle('ra.configurable.customize').click();
await screen.findByText('Datagrid');
screen.getByLabelText('Original title').click();
expect(screen.queryAllByText('War and Peace')).toHaveLength(1);
Original file line number Diff line number Diff line change
@@ -41,10 +41,10 @@ export const DatagridConfigurable = ({
'DatagridConfigurable does not support the optimized prop'
);
}
const resource = useResourceContext(props);
const finalPreferenceKey = preferenceKey || `${resource}.datagrid`;

const translate = useTranslate();
const resource = useResourceContext(props);
const finalPreferenceKey = preferenceKey || `${resource}.datagrid`;

const [availableColumns, setAvailableColumns] = useStore<
ConfigurableDatagridColumn[]
@@ -86,13 +86,7 @@ export const DatagridConfigurable = ({
<Configurable
editor={<DatagridEditor />}
preferenceKey={finalPreferenceKey}
sx={{
display: 'block',
'& .MuiBadge-root': { display: 'flex' },
'& .RaDatagrid-root': { flex: 1 },
'& .MuiBadge-badge': { zIndex: 2 },
minHeight: 2,
}}
sx={{ display: 'block', minHeight: 2 }}
>
<DatagridWithPreferences {...props} />
</Configurable>
72 changes: 4 additions & 68 deletions packages/ra-ui-materialui/src/list/datagrid/DatagridEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,10 @@
import * as React from 'react';
import { usePreference, useSetInspectorTitle, useTranslate } from 'ra-core';
import { Box, Button } from '@mui/material';
import { useSetInspectorTitle } from 'ra-core';

import { ConfigurableDatagridColumn } from './DatagridConfigurable';
import { FieldEditor } from './FieldEditor';
import { FieldsSelector } from '../../preferences';

export const DatagridEditor = () => {
const translate = useTranslate();
useSetInspectorTitle('ra.inspector.datagrid', { _: 'Datagrid' });
useSetInspectorTitle('ra.inspector.Datagrid.title', { _: 'Datagrid' });

const [availableColumns] = usePreference<ConfigurableDatagridColumn[]>(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refactored to FieldsSelector (which is also used by SimpleFormEditor

'availableColumns',
[]
);
const [omit] = usePreference('omit', []);

const [columns, setColumns] = usePreference(
'columns',
availableColumns
.filter(column => !omit?.includes(column.source))
.map(column => column.index)
);

const handleToggle = event => {
if (event.target.checked) {
// add the column at the right position
setColumns(
availableColumns
.filter(
column =>
column.index === event.target.name ||
columns.includes(column.index)
)
.map(column => column.index)
);
} else {
setColumns(columns.filter(index => index !== event.target.name));
}
};

const handleHideAll = () => {
setColumns([]);
};
const handleShowAll = () => {
setColumns(availableColumns.map(column => column.index));
};
return (
<div>
{availableColumns.map(column => (
<FieldEditor
key={column.index}
source={column.source}
label={column.label}
index={column.index}
selected={columns.includes(column.index)}
onToggle={handleToggle}
/>
))}
<Box display="flex" justifyContent="space-between" mx={-0.5} mt={1}>
<Button size="small" onClick={handleHideAll}>
{translate('ra.inspector.datagrid.hideAll', {
_: 'Hide All',
})}
</Button>
<Button size="small" onClick={handleShowAll}>
{translate('ra.inspector.datagrid.showAll', {
_: 'Show All',
})}
</Button>
</Box>
</div>
);
return <FieldsSelector name="columns" availableName="availableColumns" />;
};
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@ import {
} from '@mui/material';
import ViewWeekIcon from '@mui/icons-material/ViewWeek';

import { FieldEditor } from './FieldEditor';
import { FieldToggle } from '../../preferences';
import { ConfigurableDatagridColumn } from './DatagridConfigurable';
import { styled } from '@mui/material/styles';

@@ -123,7 +123,7 @@ export const SelectColumnsButton = props => {
>
<Box p={1}>
{availableColumns.map(column => (
<FieldEditor
<FieldToggle
key={column.index}
source={column.source}
label={column.label}
20 changes: 13 additions & 7 deletions packages/ra-ui-materialui/src/preferences/Configurable.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { screen, render, waitFor } from '@testing-library/react';
import { screen, render, waitFor, fireEvent } from '@testing-library/react';
import expect from 'expect';

import { Basic, Unmount } from './Configurable.stories';
@@ -9,15 +9,17 @@ describe('Configurable', () => {
render(<Basic />);
screen.getByLabelText('Configure mode').click();
await screen.findByText('Inspector');
screen.getAllByTitle('ra.configurable.customize')[0].click();
fireEvent.mouseOver(screen.getByText('Lorem ipsum'));
screen.getByTitle('ra.configurable.customize').click();
await screen.findByText('Text block');
});

it('should show the default value for the settings', async () => {
render(<Basic />);
screen.getByLabelText('Configure mode').click();
await screen.findByText('Inspector');
await screen.getAllByTitle('ra.configurable.customize')[0].click();
fireEvent.mouseOver(screen.getByText('Lorem ipsum'));
screen.getByTitle('ra.configurable.customize').click();
expect(
(screen.getByLabelText('Background color') as HTMLInputElement)
.value
@@ -28,7 +30,8 @@ describe('Configurable', () => {
render(<Basic />);
screen.getByText('Today');
screen.getByLabelText('Configure mode').click();
screen.getAllByTitle('ra.configurable.customize')[1].click();
fireEvent.mouseOver(screen.getByText('Sales'));
screen.getByTitle('ra.configurable.customize').click();
await screen.findByText('Sales block');
screen.getByLabelText('Show date').click();
expect(screen.queryByText('Today')).toBeNull();
@@ -38,7 +41,8 @@ describe('Configurable', () => {
render(<Basic />);
screen.getByText('Today');
screen.getByLabelText('Configure mode').click();
screen.getAllByTitle('ra.configurable.customize')[1].click();
fireEvent.mouseOver(screen.getByText('Sales'));
screen.getByTitle('ra.configurable.customize').click();
await screen.findByText('Sales block');
screen.getByLabelText('Show date').click();
screen.getByLabelText('ra.action.close').click();
@@ -49,7 +53,8 @@ describe('Configurable', () => {
render(<Unmount />);
screen.getByLabelText('Configure mode').click();
await screen.findByText('Inspector');
screen.getAllByTitle('ra.configurable.customize')[0].click();
fireEvent.mouseOver(screen.getByText('Lorem ipsum'));
screen.getByTitle('ra.configurable.customize').click();
await screen.findByText('Text block');
screen.getByText('toggle text block').click();
await waitFor(() => {
@@ -62,7 +67,8 @@ describe('Configurable', () => {
render(<Unmount />);
screen.getByLabelText('Configure mode').click();
await screen.findByText('Inspector');
screen.getAllByTitle('ra.configurable.customize')[0].click();
fireEvent.mouseOver(screen.getByText('Lorem ipsum'));
screen.getByTitle('ra.configurable.customize').click();
await screen.findByText('Text block');
screen.getByText('toggle sales block').click();
await waitFor(() => {
101 changes: 66 additions & 35 deletions packages/ra-ui-materialui/src/preferences/Configurable.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import * as React from 'react';
import { useRef, useEffect, cloneElement, ReactElement } from 'react';
import { useRef, useEffect, useState, cloneElement, ReactElement } from 'react';
import {
usePreferencesEditor,
PreferenceKeyContextProvider,
useTranslate,
} from 'ra-core';
import { alpha, Badge } from '@mui/material';
import { alpha, Popover } from '@mui/material';
import { styled, SxProps } from '@mui/material/styles';
import SettingsIcon from '@mui/icons-material/Settings';
import clsx from 'clsx';
@@ -50,6 +50,10 @@ export const Configurable = (props: ConfigurableProps) => {

const isEditorOpen = prefixedPreferenceKey === currentPreferenceKey;
const editorOpenRef = useRef(isEditorOpen);
const wrapperRef = useRef(null);
const [isCustomizeButtonVisible, setIsCustomizeButtonVisible] = useState(
false
);

useEffect(() => {
editorOpenRef.current = isEditorOpen;
@@ -84,6 +88,14 @@ export const Configurable = (props: ConfigurableProps) => {
setPreferenceKey(prefixedPreferenceKey);
};

const handleShowButton = event => {
setIsCustomizeButtonVisible(true);
};

const handleHideButton = () => {
setIsCustomizeButtonVisible(false);
};

return (
<PreferenceKeyContextProvider value={prefixedPreferenceKey}>
<Root
@@ -92,26 +104,55 @@ export const Configurable = (props: ConfigurableProps) => {
isEditorOpen && ConfigurableClasses.editorActive
)}
sx={sx}
ref={wrapperRef}
onMouseEnter={isEnabled ? handleShowButton : undefined}
onMouseLeave={isEnabled ? handleHideButton : undefined}
>
<Badge
badgeContent={
<SettingsIcon
// @ts-ignore
fontSize="12px"
/>
}
componentsProps={{
badge: {
title: translate(openButtonLabel),
onClick: handleOpenEditor,
},
}}
color="warning"
invisible={!isEnabled}
>
{children}
</Badge>
{children}
</Root>
<Popover
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to move from Badge to Popover because there is no other way to make the customize button break out of its parent container when that container has overflow:hidden

open={isEnabled && (isCustomizeButtonVisible || isEditorOpen)}
sx={{
pointerEvents: 'none',
'& .MuiPaper-root': {
pointerEvents: 'auto',
borderRadius: 10,
padding: '2px',
lineHeight: 0,
backgroundColor: 'warning.light',
color: 'warning.contrastText',
'&:hover': {
cursor: 'pointer',
},
},
}}
anchorEl={wrapperRef.current}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'center',
horizontal: 'center',
}}
onClose={handleHideButton}
PaperProps={{
elevation: 1,
onMouseEnter: handleShowButton,
onMouseLeave: handleHideButton,
title: translate(openButtonLabel),
onClick: handleOpenEditor,
}}
disableAutoFocus
disableRestoreFocus
disableScrollLock
marginThreshold={8}
>
<SettingsIcon
// @ts-ignore
fontSize="12px"
/>
</Popover>
</PreferenceKeyContextProvider>
);
};
@@ -128,33 +169,23 @@ const PREFIX = 'RaConfigurable';

export const ConfigurableClasses = {
editMode: `${PREFIX}-editMode`,
button: `${PREFIX}-button`,
editorActive: `${PREFIX}-editorActive`,
};

const Root = styled('span', {
name: PREFIX,
overridesResolver: (props, styles) => styles.root,
})(({ theme }) => ({
[`& .MuiBadge-badge`]: {
visibility: 'hidden',
pointerEvents: 'none',
padding: 0,
},
[`&.${ConfigurableClasses.editMode}:hover > .MuiBadge-root > .MuiBadge-badge`]: {
visibility: 'visible',
pointerEvents: 'initial',
cursor: 'pointer',
},
[`&.${ConfigurableClasses.editMode} > .MuiBadge-root > :not(.MuiBadge-badge)`]: {
position: 'relative',
display: 'inline-block',
[`&.${ConfigurableClasses.editMode}`]: {
transition: theme.transitions.create('outline'),
outline: `${alpha(theme.palette.warning.main, 0.3)} solid 2px`,
},
[`&.${ConfigurableClasses.editMode}:hover > .MuiBadge-root > :not(.MuiBadge-badge)`]: {
[`&.${ConfigurableClasses.editMode}:hover `]: {
outline: `${alpha(theme.palette.warning.main, 0.5)} solid 2px`,
},

[`&.${ConfigurableClasses.editMode}.${ConfigurableClasses.editorActive} > .MuiBadge-root > :not(.MuiBadge-badge), &.${ConfigurableClasses.editMode}.${ConfigurableClasses.editorActive}:hover > .MuiBadge-root > :not(.MuiBadge-badge)`]: {
[`&.${ConfigurableClasses.editMode}.${ConfigurableClasses.editorActive} , &.${ConfigurableClasses.editMode}.${ConfigurableClasses.editorActive}:hover `]: {
outline: `${theme.palette.warning.main} solid 2px`,
},
}));
Original file line number Diff line number Diff line change
@@ -3,9 +3,9 @@ import { FieldTitle, useResourceContext } from 'ra-core';
import { Switch, Typography } from '@mui/material';

/**
* UI to edit a field in a DatagridEditor
* UI to enable/disable a field
*/
export const FieldEditor = props => {
export const FieldToggle = props => {
const { selected, label, onToggle, source, index } = props;
const resource = useResourceContext();
return (
84 changes: 84 additions & 0 deletions packages/ra-ui-materialui/src/preferences/FieldsSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import * as React from 'react';
import { usePreference, useTranslate } from 'ra-core';
import { Box, Button } from '@mui/material';

import { FieldToggle } from './FieldToggle';

/**
* UI to select / deselect fields, and store the selection in preferences
*/
export const FieldsSelector = ({
name = 'columns',
availableName = 'availableColumns',
}) => {
const translate = useTranslate();

const [availableFields] = usePreference<SelectableField[]>(
availableName,
[]
);
const [omit] = usePreference('omit', []);

const [fields, setFields] = usePreference(
name,
availableFields
.filter(field => !omit?.includes(field.source))
.map(field => field.index)
);

const handleToggle = event => {
if (event.target.checked) {
// add the column at the right position
setFields(
availableFields
.filter(
field =>
field.index === event.target.name ||
fields.includes(field.index)
)
.map(field => field.index)
);
} else {
setFields(fields.filter(index => index !== event.target.name));
}
};

const handleHideAll = () => {
setFields([]);
};
const handleShowAll = () => {
setFields(availableFields.map(field => field.index));
};
return (
<div>
{availableFields.map(field => (
<FieldToggle
key={field.index}
source={field.source}
label={field.label}
index={field.index}
selected={fields.includes(field.index)}
onToggle={handleToggle}
/>
))}
<Box display="flex" justifyContent="space-between" mx={-0.5} mt={1}>
<Button size="small" onClick={handleHideAll}>
{translate('ra.inspector.hideAll', {
_: 'Hide All',
})}
</Button>
<Button size="small" onClick={handleShowAll}>
{translate('ra.inspector.showAll', {
_: 'Show All',
})}
</Button>
</Box>
</div>
);
};

export interface SelectableField {
index: string;
source: string;
label?: string;
}
2 changes: 2 additions & 0 deletions packages/ra-ui-materialui/src/preferences/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './Configurable';
export * from './FieldsSelector';
export * from './FieldToggle';
export * from './Inspector';
export * from './InspectorButton';
export * from './InspectorRoot';