diff --git a/docs/SimpleForm.md b/docs/SimpleForm.md index 480d6166428..8bf0b8cb948 100644 --- a/docs/SimpleForm.md +++ b/docs/SimpleForm.md @@ -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. + + + +```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>`. diff --git a/docs/img/SimpleFormConfigurable.gif b/docs/img/SimpleFormConfigurable.gif new file mode 100644 index 00000000000..46192fe033e Binary files /dev/null and b/docs/img/SimpleFormConfigurable.gif differ diff --git a/examples/simple/src/comments/CommentCreate.tsx b/examples/simple/src/comments/CommentCreate.tsx index 854c69f2ca3..3a75ad9dc71 100644 --- a/examples/simple/src/comments/CommentCreate.tsx +++ b/examples/simple/src/comments/CommentCreate.tsx @@ -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> ); diff --git a/examples/simple/src/posts/PostCreate.tsx b/examples/simple/src/posts/PostCreate.tsx index 50c9a480bf0..40d9f1c1b8d 100644 --- a/examples/simple/src/posts/PostCreate.tsx +++ b/examples/simple/src/posts/PostCreate.tsx @@ -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> ); }; diff --git a/examples/simple/src/tags/TagCreate.tsx b/examples/simple/src/tags/TagCreate.tsx index 1e96b259b70..3c8537aebc1 100644 --- a/examples/simple/src/tags/TagCreate.tsx +++ b/examples/simple/src/tags/TagCreate.tsx @@ -2,8 +2,7 @@ import * as React from 'react'; import { Create, - SimpleForm, - TextField, + SimpleFormConfigurable, TextInput, required, TranslatableInputs, @@ -11,12 +10,11 @@ import { const TagCreate = () => ( <Create redirect="list"> - <SimpleForm> - <TextField source="id" /> + <SimpleFormConfigurable> <TranslatableInputs locales={['en', 'fr']}> <TextInput source="name" validate={[required()]} /> </TranslatableInputs> - </SimpleForm> + </SimpleFormConfigurable> </Create> ); diff --git a/examples/simple/src/tags/TagEdit.tsx b/examples/simple/src/tags/TagEdit.tsx index 7b7804bb3d6..3def44f7f07 100644 --- a/examples/simple/src/tags/TagEdit.tsx +++ b/examples/simple/src/tags/TagEdit.tsx @@ -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 diff --git a/packages/ra-core/src/i18n/TranslationMessages.ts b/packages/ra-core/src/i18n/TranslationMessages.ts index b48afdf9bff..8636df93e8d 100644 --- a/packages/ra-core/src/i18n/TranslationMessages.ts +++ b/packages/ra-core/src/i18n/TranslationMessages.ts @@ -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; diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index d7c35cf0d7c..83d12eed79e 100644 --- a/packages/ra-language-english/src/index.ts +++ b/packages/ra-language-english/src/index.ts @@ -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', diff --git a/packages/ra-language-french/src/index.ts b/packages/ra-language-french/src/index.ts index f07e287ddc7..960c7aa949b 100644 --- a/packages/ra-language-french/src/index.ts +++ b/packages/ra-language-french/src/index.ts @@ -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', diff --git a/packages/ra-ui-materialui/src/form/SimpleFormConfigurable.spec.tsx b/packages/ra-ui-materialui/src/form/SimpleFormConfigurable.spec.tsx new file mode 100644 index 00000000000..877722aa3b8 --- /dev/null +++ b/packages/ra-ui-materialui/src/form/SimpleFormConfigurable.spec.tsx @@ -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 + ); + }); + }); +}); diff --git a/packages/ra-ui-materialui/src/form/SimpleFormConfigurable.stories.tsx b/packages/ra-ui-materialui/src/form/SimpleFormConfigurable.stories.tsx new file mode 100644 index 00000000000..e9991171e8b --- /dev/null +++ b/packages/ra-ui-materialui/src/form/SimpleFormConfigurable.stories.tsx @@ -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> +); diff --git a/packages/ra-ui-materialui/src/form/SimpleFormConfigurable.tsx b/packages/ra-ui-materialui/src/form/SimpleFormConfigurable.tsx new file mode 100644 index 00000000000..adaf0f2da69 --- /dev/null +++ b/packages/ra-ui-materialui/src/form/SimpleFormConfigurable.tsx @@ -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', + }, + }} + > + <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> + ); +}; diff --git a/packages/ra-ui-materialui/src/form/SimpleFormEditor.tsx b/packages/ra-ui-materialui/src/form/SimpleFormEditor.tsx new file mode 100644 index 00000000000..521dc759b44 --- /dev/null +++ b/packages/ra-ui-materialui/src/form/SimpleFormEditor.tsx @@ -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" />; +}; diff --git a/packages/ra-ui-materialui/src/form/index.tsx b/packages/ra-ui-materialui/src/form/index.tsx index df2af5c602e..4ae6349a3c5 100644 --- a/packages/ra-ui-materialui/src/form/index.tsx +++ b/packages/ra-ui-materialui/src/form/index.tsx @@ -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'; diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleListConfigurable.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleListConfigurable.tsx index 3f4c81c8018..af12fa48329 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleListConfigurable.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleListConfigurable.tsx @@ -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> diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleListEditor.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleListEditor.tsx index 52f2a18b737..2804571d1a9 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleListEditor.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleListEditor.tsx @@ -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( diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.spec.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.spec.tsx index b9b41281e3d..2f2e534a00a 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.spec.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.spec.tsx @@ -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); diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx index df6e4401be2..36acb54ed5c 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx @@ -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> diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridEditor.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridEditor.tsx index 7c35df32294..68b0ce05ff8 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridEditor.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridEditor.tsx @@ -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[]>( - '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" />; }; diff --git a/packages/ra-ui-materialui/src/list/datagrid/SelectColumnsButton.tsx b/packages/ra-ui-materialui/src/list/datagrid/SelectColumnsButton.tsx index a578159f6dd..eeabac918b3 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/SelectColumnsButton.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/SelectColumnsButton.tsx @@ -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} diff --git a/packages/ra-ui-materialui/src/preferences/Configurable.spec.tsx b/packages/ra-ui-materialui/src/preferences/Configurable.spec.tsx index 1e08e06b3bf..fbf5c71d984 100644 --- a/packages/ra-ui-materialui/src/preferences/Configurable.spec.tsx +++ b/packages/ra-ui-materialui/src/preferences/Configurable.spec.tsx @@ -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,7 +9,8 @@ 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'); }); @@ -17,7 +18,8 @@ describe('Configurable', () => { 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(() => { diff --git a/packages/ra-ui-materialui/src/preferences/Configurable.tsx b/packages/ra-ui-materialui/src/preferences/Configurable.tsx index aae736ec00e..488792831a0 100644 --- a/packages/ra-ui-materialui/src/preferences/Configurable.tsx +++ b/packages/ra-ui-materialui/src/preferences/Configurable.tsx @@ -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 + 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,7 +169,6 @@ const PREFIX = 'RaConfigurable'; export const ConfigurableClasses = { editMode: `${PREFIX}-editMode`, - button: `${PREFIX}-button`, editorActive: `${PREFIX}-editorActive`, }; @@ -136,25 +176,16 @@ 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`, }, })); diff --git a/packages/ra-ui-materialui/src/list/datagrid/FieldEditor.tsx b/packages/ra-ui-materialui/src/preferences/FieldToggle.tsx similarity index 92% rename from packages/ra-ui-materialui/src/list/datagrid/FieldEditor.tsx rename to packages/ra-ui-materialui/src/preferences/FieldToggle.tsx index e82eb4a4379..f0175d44962 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/FieldEditor.tsx +++ b/packages/ra-ui-materialui/src/preferences/FieldToggle.tsx @@ -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 ( diff --git a/packages/ra-ui-materialui/src/preferences/FieldsSelector.tsx b/packages/ra-ui-materialui/src/preferences/FieldsSelector.tsx new file mode 100644 index 00000000000..2c450dbef99 --- /dev/null +++ b/packages/ra-ui-materialui/src/preferences/FieldsSelector.tsx @@ -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; +} diff --git a/packages/ra-ui-materialui/src/preferences/index.ts b/packages/ra-ui-materialui/src/preferences/index.ts index 5e93b7c3c16..6c8c8475bf3 100644 --- a/packages/ra-ui-materialui/src/preferences/index.ts +++ b/packages/ra-ui-materialui/src/preferences/index.ts @@ -1,4 +1,6 @@ export * from './Configurable'; +export * from './FieldsSelector'; +export * from './FieldToggle'; export * from './Inspector'; export * from './InspectorButton'; export * from './InspectorRoot';