From 5bb5f98b1795911bb7b0d579d02bcec694eb57a4 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 13 Mar 2018 16:05:54 +0100 Subject: [PATCH] Refactor Slot Feature --- .../src/containers/ExtendableTabPanel.js | 93 ++++------ .../components/IfSlotIsEmpty.js | 104 ++--------- .../components/IfSlotIsNotEmpty.js | 104 ++--------- client/coral-framework/components/Slot.js | 150 +++------------ client/coral-framework/hocs/index.js | 2 + .../hocs/withCombatPassthrough.js | 53 ++++++ .../coral-framework/hocs/withSlotElements.js | 174 ++++++++++++++++++ 7 files changed, 316 insertions(+), 364 deletions(-) create mode 100644 client/coral-framework/hocs/withCombatPassthrough.js create mode 100644 client/coral-framework/hocs/withSlotElements.js diff --git a/client/coral-embed-stream/src/containers/ExtendableTabPanel.js b/client/coral-embed-stream/src/containers/ExtendableTabPanel.js index 66659d657..678981a8e 100644 --- a/client/coral-embed-stream/src/containers/ExtendableTabPanel.js +++ b/client/coral-embed-stream/src/containers/ExtendableTabPanel.js @@ -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 ( - - {React.cloneElement(el, { - active: this.props.activeTab === el.type.talkPluginName, - })} - - ); - }); - } + createPluginTabFactory = (props = this.props) => el => { + return ( + + {React.cloneElement(el, { + active: props.activeTab === el.type.talkPluginName, + })} + + ); + }; getTabElements(props = this.props) { const elements = [...this.getPluginTabElementsPrepend(props)]; @@ -91,14 +57,16 @@ class ExtendableTabPanelContainer extends React.Component { return elements; } + createPluginTabPane(el) { + return ( + + {el} + + ); + } + getPluginTabPaneElements(props = this.props) { - return this.getSlotElements(props.tabPaneSlot).map(el => { - return ( - - {el} - - ); - }); + 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); diff --git a/client/coral-framework/components/IfSlotIsEmpty.js b/client/coral-framework/components/IfSlotIsEmpty.js index fa8f2524d..599b21401 100644 --- a/client/coral-framework/components/IfSlotIsEmpty.js +++ b/client/coral-framework/components/IfSlotIsEmpty.js @@ -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); diff --git a/client/coral-framework/components/IfSlotIsNotEmpty.js b/client/coral-framework/components/IfSlotIsNotEmpty.js index 024877fbc..be67d4e4f 100644 --- a/client/coral-framework/components/IfSlotIsNotEmpty.js +++ b/client/coral-framework/components/IfSlotIsNotEmpty.js @@ -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); diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index 02f182dc3..a70b9ccf9 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -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 = ; - } - + let children = this.props.slotElements; if (childFactory) { children = children.map(childFactory); } @@ -140,7 +26,7 @@ class Slot extends React.Component { return ( ({ - 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); diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index 7ed114b73..a0f0a3e9c 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -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'; diff --git a/client/coral-framework/hocs/withCombatPassthrough.js b/client/coral-framework/hocs/withCombatPassthrough.js new file mode 100644 index 000000000..84dbdb492 --- /dev/null +++ b/client/coral-framework/hocs/withCombatPassthrough.js @@ -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), + })); diff --git a/client/coral-framework/hocs/withSlotElements.js b/client/coral-framework/hocs/withSlotElements.js new file mode 100644 index 000000000..c7aa9fe29 --- /dev/null +++ b/client/coral-framework/hocs/withSlotElements.js @@ -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(); + } + + 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 ; + } + }; + }); + +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)); +};