Refactor Slot Feature

This commit is contained in:
Chi Vinh Le
2018-03-13 16:05:54 +01:00
parent 0425c39bfe
commit 5bb5f98b17
7 changed files with 316 additions and 364 deletions
@@ -1,17 +1,12 @@
import React from 'react';
import ExtendableTabPanel from '../components/ExtendableTabPanel';
import { connect } from 'react-redux';
import { TabPane } from 'coral-ui';
import ExtendableTab from '../components/ExtendableTab';
import { getShallowChanges } from 'coral-framework/utils';
import isEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
import { withSlotElements } from 'coral-framework/hocs';
import { compose } from 'recompose';
class ExtendableTabPanelContainer extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
componentDidMount() {
this.handleFallback();
}
@@ -20,24 +15,6 @@ class ExtendableTabPanelContainer extends React.Component {
this.handleFallback(next);
}
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change of slot children.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
const prevKeys = this.getSlotElements(this.props.tabSlot, this.props).map(
el => el.key
);
const nextKeys = this.getSlotElements(next.tabSlot, next).map(
el => el.key
);
return !isEqual(prevKeys, nextKeys);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
handleFallback(props = this.props) {
if (this.getTabNames(props).indexOf(props.activeTab) === -1) {
props.setActiveTab(props.fallbackTab);
@@ -48,37 +25,26 @@ class ExtendableTabPanelContainer extends React.Component {
return this.getTabElements(props).map(el => el.props.tabId);
}
getSlotElements(slot, props = this.props) {
const { plugins } = this.context;
return plugins.getSlotElements(
slot,
props.reduxState,
props.slotPassthrough
);
}
getPluginTabElements(props = this.props) {
return this.getSlotTabElements(props.tabSlot);
return props.slotElements[0].map(this.createPluginTabFactory(props));
}
getPluginTabElementsPrepend(props = this.props) {
return this.getSlotTabElements(props.tabSlotPrepend);
return props.slotElements[1].map(this.createPluginTabFactory(props));
}
getSlotTabElements(slot) {
return this.getSlotElements(slot).map(el => {
return (
<ExtendableTab
tabId={el.type.talkPluginName}
key={el.type.talkPluginName}
>
{React.cloneElement(el, {
active: this.props.activeTab === el.type.talkPluginName,
})}
</ExtendableTab>
);
});
}
createPluginTabFactory = (props = this.props) => el => {
return (
<ExtendableTab
tabId={el.type.talkPluginName}
key={el.type.talkPluginName}
>
{React.cloneElement(el, {
active: props.activeTab === el.type.talkPluginName,
})}
</ExtendableTab>
);
};
getTabElements(props = this.props) {
const elements = [...this.getPluginTabElementsPrepend(props)];
@@ -91,14 +57,16 @@ class ExtendableTabPanelContainer extends React.Component {
return elements;
}
createPluginTabPane(el) {
return (
<TabPane tabId={el.type.talkPluginName} key={el.type.talkPluginName}>
{el}
</TabPane>
);
}
getPluginTabPaneElements(props = this.props) {
return this.getSlotElements(props.tabPaneSlot).map(el => {
return (
<TabPane tabId={el.type.talkPluginName} key={el.type.talkPluginName}>
{el}
</TabPane>
);
});
return props.slotElements[2].map(this.createPluginTabPane);
}
render() {
@@ -137,8 +105,9 @@ ExtendableTabPanelContainer.propTypes = {
loading: PropTypes.bool,
};
const mapStateToProps = state => ({
reduxState: state,
});
export default connect(mapStateToProps, null)(ExtendableTabPanelContainer);
export default compose(
withSlotElements({
slot: props => [props.tabSlot, props.tabSlotPrepend, props.tabPaneSlot],
passthroughPropName: 'slotPassthrough',
})
)(ExtendableTabPanelContainer);
@@ -1,93 +1,14 @@
import React, { Children } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { getShallowChanges } from 'coral-framework/utils';
import omit from 'lodash/omit';
import { withSlotElements, withCombatPassthrough } from '../hocs';
import { compose } from 'recompose';
class IfSlotIsEmpty extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
const changes = getShallowChanges(this.props, next);
// Handle special `passthrough` props.
const passthroughIndex = changes.indexOf('passthrough');
if (passthroughIndex !== -1) {
if (!this.props.passthrough || next.passthrough) {
return true;
}
if (
getShallowChanges(this.props.passthrough, next.passthrough).lenght === 0
) {
changes.splice(passthroughIndex, 1);
}
}
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change.
if (changes.length === 1 && changes[0] === 'reduxState') {
return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
getPassthrough(props = this.props) {
const slotProps = omit(props, [
'slot',
'children',
'reduxState',
'passthrough',
'dispatch',
]);
// @Deprecated
if (process.env.NODE_ENV !== 'production') {
if (Object.keys(slotProps).length) {
/* eslint-disable no-console */
console.warn(
`IfSlotIsEmpty '${
props.fill
}' passing through unknown props is deprecated, please use 'passthrough' instead`,
slotProps
);
/* eslint-enable no-console */
}
}
if (props.passthrough) {
return props.passthrough;
}
if (props.queryData) {
if (process.env.NODE_ENV !== 'production') {
/* eslint-disable no-console */
console.warn(
`Slot '${
props.fill
}' property 'queryData' is deprecated, please use 'passthrough' instead`
);
/* eslint-enable no-console */
}
return {
...props.queryData,
...slotProps,
};
}
return slotProps;
}
isSlotEmpty(props = this.props) {
const { slot, reduxState } = props;
const passthrough = this.getPassthrough(props);
const slots = Array.isArray(slot) ? slot : [slot];
return slots.every(slot =>
this.context.plugins.isSlotEmpty(slot, reduxState, passthrough)
);
const { slotElements } = props;
return slotElements.length === 0
? false
: slotElements.every(elements => elements.length === 0);
}
render() {
@@ -99,11 +20,14 @@ class IfSlotIsEmpty extends React.Component {
IfSlotIsEmpty.propTypes = {
slot: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
children: PropTypes.node.isRequired,
passthrough: PropTypes.object,
passthrough: PropTypes.object.isRequired,
};
const mapStateToProps = state => ({
reduxState: state,
});
const omitProps = ['slot', 'children'];
export default connect(mapStateToProps, null)(IfSlotIsEmpty);
export default compose(
withCombatPassthrough(omitProps),
withSlotElements({
slot: props => props.slot,
})
)(IfSlotIsEmpty);
@@ -1,93 +1,14 @@
import React, { Children } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { getShallowChanges } from 'coral-framework/utils';
import omit from 'lodash/omit';
import { withSlotElements, withCombatPassthrough } from '../hocs';
import { compose } from 'recompose';
class IfSlotIsNotEmpty extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
const changes = getShallowChanges(this.props, next);
// Handle special `passthrough` props.
const passthroughIndex = changes.indexOf('passthrough');
if (passthroughIndex !== -1) {
if (!this.props.passthrough || next.passthrough) {
return true;
}
if (
getShallowChanges(this.props.passthrough, next.passthrough).lenght === 0
) {
changes.splice(passthroughIndex, 1);
}
}
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change.
if (changes.length === 1 && changes[0] === 'reduxState') {
return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
getPassthrough(props = this.props) {
const slotProps = omit(props, [
'slot',
'children',
'reduxState',
'passthrough',
'dispatch',
]);
// @Deprecated
if (process.env.NODE_ENV !== 'production') {
if (Object.keys(slotProps).length) {
/* eslint-disable no-console */
console.warn(
`IfSlotIsEmpty '${
props.fill
}' passing through unknown props is deprecated, please use 'passthrough' instead`,
slotProps
);
/* eslint-enable no-console */
}
}
if (props.passthrough) {
return props.passthrough;
}
if (props.queryData) {
if (process.env.NODE_ENV !== 'production') {
/* eslint-disable no-console */
console.warn(
`Slot '${
props.fill
}' property 'queryData' is deprecated, please use 'passthrough' instead`
);
/* eslint-enable no-console */
}
return {
...props.queryData,
...slotProps,
};
}
return slotProps;
}
isSlotEmpty(props = this.props) {
const { slot, reduxState } = props;
const passthrough = this.getPassthrough(props);
const slots = Array.isArray(slot) ? slot : [slot];
return slots.every(slot =>
this.context.plugins.isSlotEmpty(slot, reduxState, passthrough)
);
const { slotElements } = props;
return slotElements.length === 0
? false
: slotElements.every(elements => elements.length === 0);
}
render() {
@@ -99,11 +20,14 @@ class IfSlotIsNotEmpty extends React.Component {
IfSlotIsNotEmpty.propTypes = {
slot: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
children: PropTypes.node.isRequired,
passthrough: PropTypes.object,
passthrough: PropTypes.object.isRequired,
};
const mapStateToProps = state => ({
reduxState: state,
});
const omitProps = ['slot', 'children'];
export default connect(mapStateToProps, null)(IfSlotIsNotEmpty);
export default compose(
withCombatPassthrough(omitProps),
withSlotElements({
slot: props => props.slot,
})
)(IfSlotIsNotEmpty);
+28 -122
View File
@@ -4,135 +4,21 @@ import styles from './Slot.css';
import { connect } from 'react-redux';
import kebabCase from 'lodash/kebabCase';
import PropTypes from 'prop-types';
import isEqual from 'lodash/isEqual';
import get from 'lodash/get';
import { getShallowChanges } from 'coral-framework/utils';
import omit from 'lodash/omit';
const emptyConfig = {};
import { withSlotElements, withCombatPassthrough } from '../hocs';
import { compose } from 'recompose';
class Slot extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change of slot children.
const changes = getShallowChanges(this.props, next);
// Handle special `passthrough` props.
const passthroughIndex = changes.indexOf('passthrough');
if (passthroughIndex !== -1) {
if (!this.props.passthrough || next.passthrough) {
return true;
}
if (
getShallowChanges(this.props.passthrough, next.passthrough).lenght === 0
) {
changes.splice(passthroughIndex, 1);
}
}
if (changes.length === 1 && changes[0] === 'reduxState') {
const prevChildrenKeys = this.getChildren(this.props).map(
child => child.key
);
const nextChildrenKeys = this.getChildren(next).map(child => child.key);
return !isEqual(prevChildrenKeys, nextChildrenKeys);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
getPassthrough(props = this.props) {
const slotProps = omit(props, [
'fill',
'inline',
'className',
'reduxState',
'size',
'defaultComponent',
'queryData',
'childFactory',
'component',
'passthrough',
'dispatch',
]);
// @Deprecated
if (process.env.NODE_ENV !== 'production') {
if (Object.keys(slotProps).length) {
/* eslint-disable no-console */
console.warn(
`Slot '${
props.fill
}' passing through unknown props is deprecated, please use 'passthrough' instead`,
slotProps
);
/* eslint-enable no-console */
}
}
if (props.passthrough) {
return props.passthrough;
}
if (props.queryData) {
if (process.env.NODE_ENV !== 'production') {
/* eslint-disable no-console */
console.warn(
`Slot '${
props.fill
}' property 'queryData' is deprecated, please use 'passthrough' instead`
);
/* eslint-enable no-console */
}
return {
...props.queryData,
...slotProps,
};
}
return slotProps;
}
getChildren(props = this.props) {
const { size = 0 } = props;
const { plugins } = this.context;
return plugins.getSlotElements(
props.fill,
props.reduxState,
this.getPassthrough(props),
{ size }
);
}
render() {
const {
inline = false,
className,
reduxState,
debug,
component: Component,
childFactory,
defaultComponent: DefaultComponent,
fill,
} = this.props;
const { plugins } = this.context;
let children = this.getChildren();
const pluginConfig =
get(reduxState, 'config.plugins_config') || emptyConfig;
if (children.length === 0 && DefaultComponent) {
const props = plugins.getSlotComponentProps(
DefaultComponent,
reduxState,
this.getPassthrough()
);
children = <DefaultComponent {...props} />;
}
let children = this.props.slotElements;
if (childFactory) {
children = children.map(childFactory);
}
@@ -140,7 +26,7 @@ class Slot extends React.Component {
return (
<Component
className={cn(
{ [styles.inline]: inline, [styles.debug]: pluginConfig.debug },
{ [styles.inline]: inline, [styles.debug]: debug },
className,
`talk-slot-${kebabCase(fill)}`
)}
@@ -159,7 +45,8 @@ Slot.propTypes = {
fill: PropTypes.string.isRequired,
inline: PropTypes.bool,
className: PropTypes.string,
reduxState: PropTypes.object,
debug: PropTypes.bool,
slotElements: PropTypes.arrayOf(PropTypes.element).isRequired,
defaultComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
/**
@@ -193,8 +80,27 @@ Slot.propTypes = {
childFactory: PropTypes.func,
};
const omitProps = [
'fill',
'inline',
'className',
'size',
'defaultComponent',
'queryData',
'childFactory',
'component',
];
const mapStateToProps = state => ({
reduxState: state,
debug: get(state, 'config.plugins_config.debug'),
});
export default connect(mapStateToProps, null)(Slot);
export default compose(
withCombatPassthrough(omitProps),
withSlotElements({
slot: props => props.fill,
size: props => props.size,
defaultComponent: props => props.defaultComponent,
}),
connect(mapStateToProps, null)
)(Slot);
+2
View File
@@ -12,6 +12,8 @@ export { default as withForgotPassword } from './withForgotPassword';
export { default as withSetUsername } from './withSetUsername';
export { default as withPopupAuthHandler } from './withPopupAuthHandler';
export { default as withEnumValues } from './withEnumValues';
export { default as withCombatPassthrough } from './withCombatPassthrough';
export { default as withSlotElements } from './withSlotElements';
export {
default as withResendEmailConfirmation,
} from './withResendEmailConfirmation';
@@ -0,0 +1,53 @@
import withProps from 'recompose/withProps';
import omit from 'lodash/omit';
function getPassthrough(props, omitProps) {
const slotProps = omit(props, [...omitProps, 'passthrough']);
// @Deprecated
if (process.env.NODE_ENV !== 'production') {
if (Object.keys(slotProps).length) {
/* eslint-disable no-console */
console.warn(
`Slot '${
props.fill
}' passing through unknown props is deprecated, please use 'passthrough' instead`,
slotProps
);
/* eslint-enable no-console */
}
}
if (props.passthrough) {
return props.passthrough;
}
if (props.queryData) {
if (process.env.NODE_ENV !== 'production') {
/* eslint-disable no-console */
console.warn(
`Slot '${
props.fill
}' property 'queryData' is deprecated, please use 'passthrough' instead`
);
/* eslint-enable no-console */
}
return {
...props.queryData,
...slotProps,
};
}
return slotProps;
}
/**
* @Deprecated
* withCombatPassthrough is a compatibility HOC that supports our old
* API which puts unknown props and `queryData` to `passhtrough` to be
* used with HOC `withSlotElements`.
*/
export default omitProps =>
withProps(props => ({
passthrough: getPassthrough(props, omitProps),
}));
@@ -0,0 +1,174 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import isEqual from 'lodash/isEqual';
import { getShallowChanges } from 'coral-framework/utils';
import { compose } from 'recompose';
import hoistStatics from 'recompose/hoistStatics';
import isFunction from 'lodash/isFunction';
function resolvePrimitiveOrFunction(primitiveOrFunction, props) {
if (isFunction(primitiveOrFunction)) {
return primitiveOrFunction(props);
}
return primitiveOrFunction;
}
const createHOC = ({
slot,
defaultComponent = null,
passthroughPropName = 'passthrough',
size = null,
}) =>
hoistStatics(WrappedComponent => {
return class withSlotElements extends React.Component {
static contextTypes = {
plugins: PropTypes.object,
};
static propTypes = {
reduxState: PropTypes.object,
};
getSlots(props = this.props) {
const tmp = resolvePrimitiveOrFunction(slot, props);
if (Array.isArray(tmp)) {
return tmp;
}
return [tmp];
}
getDefaultComponents(props = this.props, fill = 1) {
const tmp = resolvePrimitiveOrFunction(defaultComponent, props);
if (Array.isArray(tmp)) {
return tmp;
}
return new Array(fill).fill(tmp);
}
getSizes(props = this.props, fill = 1) {
const tmp = resolvePrimitiveOrFunction(size, props);
if (Array.isArray(tmp)) {
return tmp;
}
return new Array(fill).fill(tmp);
}
getPassthrough(props = this.props) {
return passthroughPropName ? props[passthroughPropName] : null;
}
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change of slot children.
const changes = getShallowChanges(this.props, next);
// Handle special `passthrough` props.
if (passthroughPropName) {
const passthroughIndex = changes.indexOf(passthroughPropName);
if (passthroughIndex !== -1) {
if (!this.props[passthroughPropName] || next[passthroughPropName]) {
return true;
}
if (
getShallowChanges(
this.props[passthroughPropName],
next[passthroughPropName]
).lenght === 0
) {
changes.splice(passthroughIndex, 1);
}
}
}
if (changes.length === 1 && changes[0] === 'reduxState') {
const prevChildrenKeys = this.getSlotElements(this.props).map(
child => child.key
);
const nextChildrenKeys = this.getSlotElements(next).map(
child => child.key
);
return !isEqual(prevChildrenKeys, nextChildrenKeys);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
/**
* Returns slot elements for configured slots. If only one slot is given
* slot elements are returned directly. If more than one slot is specified
* returns an array of slot elements.
*/
getSlotElements(props = this.props) {
const { plugins } = this.context;
const slots = this.getSlots(props);
const sizes = this.getSizes(props, slots.length);
const defaultComponents = this.getSizes(props, slots.length);
const elements = [];
slots.forEach((s, i) => {
const DefaultComponent = defaultComponents[i];
const size = sizes[i];
const slotElements = plugins.getSlotElements(
s,
props.reduxState,
this.getPassthrough(props),
{ size }
);
if (slotElements.length === 0 && DefaultComponent) {
const p = plugins.getSlotComponentProps(
DefaultComponent,
props.reduxState,
this.getPassthrough(props)
);
slotElements.push(<DefaultComponent key="default" {...p} />);
}
elements.push(slotElements);
});
if (elements.length === 1) {
return elements[0];
}
return elements;
}
render() {
const { reduxState: _a, ...rest } = this.props;
const slotElements = this.getSlotElements();
const props = {
slotElements,
...rest,
};
return <WrappedComponent {...props} />;
}
};
});
const mapStateToProps = state => ({
reduxState: state,
});
/**
* Exports a HOC that provides a property `slotElements`.
* @param {Object} [options] configuration
* @param {string|array} [options.slot] Slot name or Array of Slot Names
* @param {element|array} [options.defaultComponent] Default Components or Array of such
* @param {number|array} [options.size] Slot size or an Array of slot size
* @param {string} [options.passthroughPropName] The property to find the passthrough prop
*
* @return {func} Returns a HOC that provides the property `slotElements` with an Array of
* Slot Elements or in case of multiple slots, an Array of Slot Element Arrays.
*
* Example:
* withSlotElements({
* slot: 'awesomeSlot',
* size: 1,
* })(MyComponent);
*/
export default settings => {
return compose(connect(mapStateToProps, null), createHOC(settings));
};