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

[RFR] Add tabs prop to <TabbedForm> to allow injecting custom Tabs component #3288

Merged
merged 7 commits into from
Jun 13, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/CreateEdit.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,27 @@ To style the tabs, the `<FormTab>` component accepts two props:
- `className` is passed to the tab *header*
- `contentClassName` is passed to the tab *content*

### TabbedFormTabs
By default `<TabbedForm>` uses `<TabbedFormTabs>`, an internal react-admin component to renders tabs. You can pass a custom component as the `tabs` prop to override the default component. Besides, props from `<TabbedFormTabs>` are passed to material-ui's `<Tabs>` component inside `<TabbedFormTabs>`.

The following example shows how to make use of scrollable `<Tabs>`. Pass the `scrollable` prop to `<TabbedFormTabs>` and pass that as the `tabs` prop to `<TabbedForm>`

```jsx
import {
Edit,
TabbedForm,
TabbedFormTabs,
} from 'react-admin';

export const PostEdit = (props) => (
<Edit {...props}>
<TabbedForm tabs={<TabbedFormTabs scrollable={true} />}>
...
</TabbedForm>
</Edit>
);
```

## Default Values

To define default values, you can add a `defaultValue` prop to form components (`<SimpleForm>`, `<Tabbedform>`, etc.), or add a `defaultValue` to individual input components. Let's see each of these options.
Expand Down
60 changes: 16 additions & 44 deletions packages/ra-ui-materialui/src/form/TabbedForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import { connect } from 'react-redux';
import { withRouter, Route } from 'react-router-dom';
import compose from 'recompose/compose';
import Divider from '@material-ui/core/Divider';
import Tabs from '@material-ui/core/Tabs';
import { withStyles, createStyles } from '@material-ui/core/styles';
import { getDefaultValues, translate, REDUX_FORM_NAME } from 'ra-core';

import Toolbar from './Toolbar';
import CardContentInner from '../layout/CardContentInner';
import TabbedFormTabs from './TabbedFormTabs';

const styles = theme =>
createStyles({
Expand Down Expand Up @@ -67,7 +67,7 @@ const sanitizeRestProps = ({
...props
}) => props;

const getTabFullPath = (tab, index, baseUrl) =>
export const getTabFullPath = (tab, index, baseUrl) =>
`${baseUrl}${
tab.props.path ? `/${tab.props.path}` : index > 0 ? `/${index}` : ''
}`;
Expand All @@ -91,62 +91,32 @@ export class TabbedForm extends Component {
resource,
saving,
submitOnEnter,
tabs,
tabsWithErrors,
toolbar,
translate,
undoable,
value,
version,
...rest
} = this.props;

const validTabPaths = Children.toArray(children).map((tab, index) =>
getTabFullPath(tab, index, match.url)
);

// This ensure we don't get warnings from material-ui Tabs component when
// the current location pathname targets a dynamically added Tab
// In the case the targeted Tab is not present at first render (when
// using permissions for example) we temporarily switch to the first
// available tab. The current location will be applied again on the
// first render containing the targeted tab. This is almost transparent
// for the user who may just see an short tab selection animation
const tabsValue = validTabPaths.includes(location.pathname)
? location.pathname
: validTabPaths[0];
} = this.props;

return (
<form
className={classnames('tabbed-form', className)}
key={version}
{...sanitizeRestProps(rest)}
>
<Tabs
// The location pathname will contain the page path including the current tab path
// so we can use it as a way to determine the current tab
value={tabsValue}
indicatorColor="primary"
>
{Children.map(children, (tab, index) => {
if (!isValidElement(tab)) return null;

// Builds the full tab tab which is the concatenation of the last matched route in the
// TabbedShowLayout hierarchy (ex: '/posts/create', '/posts/12', , '/posts/12/show')
// and the tab path.
// This will be used as the Tab's value
const tabPath = getTabFullPath(tab, index, match.url);

return React.cloneElement(tab, {
intent: 'header',
value: tabPath,
className:
tabsWithErrors.includes(tab.props.label) &&
location.pathname !== tabPath
? classes.errorTabButton
: null,
});
})}
</Tabs>
{React.cloneElement(
tabs,
{
classes,
currentLocationPath: location.pathname,
match,
tabsWithErrors,
},
children,
)}
<Divider />
<CardContentInner>
{/* All tabs are rendered (not only the one in focus), to allow validation
Expand Down Expand Up @@ -230,6 +200,7 @@ TabbedForm.propTypes = {
save: PropTypes.func, // the handler defined in the parent, which triggers the REST submission
saving: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]),
submitOnEnter: PropTypes.bool,
tabs: PropTypes.element.isRequired,
tabsWithErrors: PropTypes.arrayOf(PropTypes.string),
toolbar: PropTypes.element,
translate: PropTypes.func,
Expand All @@ -241,6 +212,7 @@ TabbedForm.propTypes = {

TabbedForm.defaultProps = {
submitOnEnter: true,
tabs: <TabbedFormTabs />,
toolbar: <Toolbar />,
};

Expand Down
46 changes: 0 additions & 46 deletions packages/ra-ui-materialui/src/form/TabbedForm.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,52 +64,6 @@ describe('<TabbedForm />', () => {
assert.strictEqual(button2.prop('submitOnEnter'), true);
});

it('should set the style of an inactive Tab button with errors', () => {
const wrapper = shallow(
<TabbedForm
location={{ pathname: '/posts/12' }}
match={{ url: '/posts/12' }}
translate={translate}
muiTheme={muiTheme}
tabsWithErrors={['tab2']}
classes={{ errorTabButton: 'error' }}
>
<FormTab label="tab1" />
<FormTab label="tab2" />
</TabbedForm>
);

const tabs = wrapper.find('translate(FormTab)');
const tab1 = tabs.at(0);
const tab2 = tabs.at(1);

assert.equal(tab1.prop('className'), null);
assert.equal(tab2.prop('className'), 'error');
});

it('should not set the style of an active Tab button with errors', () => {
const wrapper = shallow(
<TabbedForm
location={{ pathname: '/posts/12' }}
match={{ url: '/posts/12' }}
translate={translate}
muiTheme={muiTheme}
tabsWithErrors={['tab1']}
classes={{ errorTabButton: 'error' }}
>
<FormTab label="tab1" />
<FormTab label="tab2" />
</TabbedForm>
);

const tabs = wrapper.find('translate(FormTab)');
const tab1 = tabs.at(0);
const tab2 = tabs.at(1);

assert.equal(tab1.prop('className'), null);
assert.equal(tab2.prop('className'), null);
});

describe('findTabsWithErrors', () => {
it('should find the tabs containing errors', () => {
const collectErrors = () => ({
Expand Down
61 changes: 61 additions & 0 deletions packages/ra-ui-materialui/src/form/TabbedFormTabs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React, { Children, cloneElement, isValidElement } from 'react';
import PropTypes from 'prop-types';
import Tabs from '@material-ui/core/Tabs';
import compose from 'recompose/compose';

const getTabFullPath = (tab, index, baseUrl) =>
`${baseUrl}${
tab.props.path ? `/${tab.props.path}` : index > 0 ? `/${index}` : ''
}`;

const TabbedFormTabs = ({ children, classes, currentLocationPath, match, tabsWithErrors, ...rest }) => {

const validTabPaths = Children.toArray(children).map((tab, index) =>
getTabFullPath(tab, index, match.url)
);

// This ensure we don't get warnings from material-ui Tabs component when
// the current location pathname targets a dynamically added Tab
// In the case the targeted Tab is not present at first render (when
// using permissions for example) we temporarily switch to the first
// available tab. The current location will be applied again on the
// first render containing the targeted tab. This is almost transparent
// for the user who may just see an short tab selection animation
const tabValue = validTabPaths.includes(currentLocationPath)
? currentLocationPath
: validTabPaths[0];

return (
<Tabs value={tabValue} indicatorColor="primary" {...rest} >
{Children.map(children, (tab, index) => {
if (!isValidElement(tab)) return null;

// Builds the full tab tab which is the concatenation of the last matched route in the
// TabbedShowLayout hierarchy (ex: '/posts/create', '/posts/12', , '/posts/12/show')
// and the tab path.
// This will be used as the Tab's value
const tabPath = getTabFullPath(tab, index, match.url);

return cloneElement(tab, {
intent: 'header',
value: tabPath,
className:
tabsWithErrors.includes(tab.props.label) &&
currentLocationPath !== tabPath
? classes.errorTabButton
: null,
});
})}
</Tabs>
);
};

TabbedFormTabs.propTypes = {
children: PropTypes.node,
classes: PropTypes.object,
currentLocationPath: PropTypes.string,
match: PropTypes.object,
tabsWithErrors: PropTypes.arrayOf(PropTypes.string),
};

export default TabbedFormTabs;
50 changes: 50 additions & 0 deletions packages/ra-ui-materialui/src/form/TabbedFormTabs.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import { shallow } from 'enzyme';
import assert from 'assert';

import TabbedFormTabs from './TabbedFormTabs';
import FormTab from './FormTab';

describe('<TabbedFormTabs />', () => {
it('should set the style of an inactive Tab button with errors', () => {
const wrapper = shallow(
<TabbedFormTabs
classes={{ errorTabButton: 'error' }}
currentLocationPath={'/posts/12'}
match={{ url: '/posts/12' }}
tabsWithErrors={['tab2']}
>
<FormTab label="tab1" />
<FormTab label="tab2" />
</TabbedFormTabs>
);

const tabs = wrapper.find(FormTab);
const tab1 = tabs.at(0);
const tab2 = tabs.at(1);

assert.equal(tab1.prop('className'), null);
assert.equal(tab2.prop('className'), 'error');
});

it('should not set the style of an active Tab button with errors', () => {
const wrapper = shallow(
<TabbedFormTabs
classes={{ errorTabButton: 'error' }}
currentLocationPath={'/posts/12'}
match={{ url: '/posts/12' }}
tabsWithErrors={['tab1']}
>
<FormTab label="tab1" />
<FormTab label="tab2" />
</TabbedFormTabs>
);

const tabs = wrapper.find(FormTab);
const tab1 = tabs.at(0);
const tab2 = tabs.at(1);

assert.equal(tab1.prop('className'), null);
assert.equal(tab2.prop('className'), null);
});
});
2 changes: 2 additions & 0 deletions packages/ra-ui-materialui/src/form/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import FormTab from './FormTab';
import SimpleForm from './SimpleForm';
import SimpleFormIterator from './SimpleFormIterator';
import TabbedForm from './TabbedForm';
import TabbedFormTabs from './TabbedFormTabs';
import Toolbar from './Toolbar';

export {
Expand All @@ -11,5 +12,6 @@ export {
SimpleForm,
SimpleFormIterator,
TabbedForm,
TabbedFormTabs,
Toolbar,
};