Skip to content

Instantly share code, notes, and snippets.

@austinmao
Last active January 13, 2016 05:04
Show Gist options
  • Save austinmao/6de8883ebcd0b2767b98 to your computer and use it in GitHub Desktop.
Save austinmao/6de8883ebcd0b2767b98 to your computer and use it in GitHub Desktop.
Higher Order Component and Higher Order Reducer example that creates a reusable, redux-connected photo gallery that will store a `selected` state in a unique, nested name + key object in the redux store. This differs from https://gist.github.com/austinmao/b2da974571d11e3f24f3 by allowing the key to be passed in via props from one component to an…
/**
* reduxGallery.js
* decorator used to take in props, add defaults, and then pass it along to the
* higher order component.
*/
import React, {Component} from 'react';
import reduxGalleryConnector from './reduxGalleryConnector';
import hoistStatics from 'hoist-non-react-statics';
/**
* The decorator that is the main API to redux-form
*/
export default (config, mapStateToProps, mapDispatchToProps) =>
WrappedComponent => {
const ReduxGalleryConnector = reduxGalleryConnector(WrappedComponent, mapStateToProps, mapDispatchToProps);
const configWithDefaults = {
// NOTE: add default configs here
...config
};
/** render ConnectedGallery with passed in props */
class ConnectedGallery extends Component {
render() {
return (<ReduxGalleryConnector
{...configWithDefaults}
{...this.props}/>);
}
}
return hoistStatics(ConnectedGallery, WrappedComponent);
};
/**
* reduxGalleryConnector.js
* memoized HoC that takes props passed in from the decorator in reduxGallery.js
* and passes that in to createHigherOrderGallery as params. This is necessary
* to make sure that params can be passed in as props.
*/
import React, {Component, PropTypes} from 'react';
import lazyCache from 'react-lazy-cache';
import createHigherOrderGallery from './createHigherOrderGallery';
/**
* This component tracks props that affect how the form is mounted to the store. Normally these should not change,
* but if they do, the connected components below it need to be redefined.
*/
export default (WrappedComponent, mapStateToProps, mapDispatchToProps) =>
class ReduxGalleryConnector extends Component {
constructor(props) {
super(props);
// cache and extract static gallery props. this allows us to pass galleryName
// and galleryKey dynamically
this.cache = lazyCache(this, {
ReduxGallery: {
// props that effect how reduxGallery connects to the redux store
params: [
'galleryName',
'galleryKey',
],
// function to cache and execute
fn: createHigherOrderGallery(props, WrappedComponent, mapStateToProps, mapDispatchToProps)
}
});
}
static propTypes = {
galleryName: PropTypes.string.isRequired,
gallerykey: PropTypes.string,
};
// WrappedComponent that will get hoisted
static WrappedComponent = WrappedComponent;
componentWillReceiveProps(nextProps) {
this.cache.componentWillReceiveProps(nextProps);
}
render() {
const {ReduxGallery} = this.cache; // set component to cached
return <ReduxGallery {...this.props}/>;
}
};
/**
* createHigherOrderGallery.js
* Creates a HOC that knows how to create redux-connected sub-components.
*/
/** boilerplate */
import React, {Component, PropTypes} from 'react';
import {connect} from 'react-redux';
import {pushState} from 'redux-router';
/** helpers */
import wrapMapDispatchToProps from 'helpers/wrapMapDispatchToProps';
import bindActionData from 'helpers/bindActionData';
/** reducers */
import {getGallery} from 'redux/modules/creative/gallery';
import * as actions from 'redux/modules/creative/gallery';
/** render components */
/**
* wrapped component inherits props from Gallery
* @type {function} - higher order component
*/
export default (config, WrappedComponent, mapStateToProps, mapDispatchToProps) =>
// where reduxGallery is mounted
(galleryName, galleryKey) =>
// connect to redux with passed in params
@connect(
(state, props) => ({
...mapStateToProps(state, props), // map passed in state and props
gallery: getGallery({galleryName, galleryKey}, state), // get gallery
location: state.router.location.pathname, // current url
}),
wrapMapDispatchToProps(
mapDispatchToProps, // dispatch action passed in
// bind gallery, key to actions so reducer can store data in proper node
bindActionData({
...actions,
pushState
}, {
galleryName, // name of nested key at store.gallery[name]. i.e., 'photoItems'
galleryKey, // name of nested key at store.gallery[name][key]. i.e., '[LayerId]'
})
)
)
class ReduxGallery extends Component {
static propTypes = {
/** states */
images: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
url: PropTypes.string.isRequired, // img src url
}).isRequired),
gallery: PropTypes.shape({
selected: PropTypes.objectOf(PropTypes.bool),
focused: PropTypes.arrayOf(PropTypes.string)
}),
location: PropTypes.string.isRequired, // current path
/** actions */
pushState: PropTypes.func.isRequired, // route to view
select: PropTypes.func.isRequired, // select photo if not selected
focus: PropTypes.func.isRequired, // focus on a photo
reset: PropTypes.func.isRequired, // reset selected states
}
/** route to selected if selected */
componentWillReceiveProps(next) {
// only redirect if config says to
if (config.redirectOnFocus && next.focused[0]) {
const {location, pushState} = this.props; // eslint-disable-line no-shadow
// get selected id
const id = next.focused[0];
// remove extra '/' in route
const route = location.slice(-1) === '/' ? location + id : location + '/' + id;
// route to /:photoItemId
return pushState(null, route);
}
}
/** remove selected states before unmounting */
componentWillUnmount() {
// only reset if config says to
if (config.resetOnUnmount) {
this.props.reset(); // reset selected states
}
}
render() {
const {gallery} = this.props;
return (
<WrappedComponent
{...config}
{...this.props}
selected={gallery.selected ? gallery.selected : {}}
focused={gallery.focused ? gallery.focused : []}
select={::this.props.select}
focus={::this.props.focus}/>
);
}
};
/**
* PhotoLayer.js
* loads and renders a photoLayer
*/
/** boilerplate */
import React, {Component, PropTypes} from 'react';
import {connect} from 'react-redux';
/** helpers */
import _ from 'lodash'; // eslint-disable-line id-length
/** reducers */
import * as imageEditorActions from 'redux/modules/creative/imageEditor';
import {getGallery} from 'redux/modules/creative/gallery';
/** render components */
import Radium from 'radium';
import {PhotoItemsGallery} from 'components';
@connect(
(state, props) => ({
changes: state.imageEditor.changes,
photoItems: state.data.entities.photoItems,
gallery: getGallery({
galleryName: 'photoItems', // set gallery state tree
galleryKey: props.layer.id // set gallery state node
}, state), // get gallery
}),
{...imageEditorActions})
@Radium
export default class PhotoLayerContainer extends Component {
static propTypes = {
/** states */
layer: PropTypes.shape({
id: PropTypes.string.isRequired,
_photoItem: PropTypes.string.isRequired, // id of photoItem
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
z: PropTypes.number.isRequired,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
alpha: PropTypes.number,
backgroundColor: PropTypes.string,
}).isRequired,
isActive: PropTypes.bool,
photoItems: PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})),
gallery: PropTypes.shape({
selected: PropTypes.objectOf(PropTypes.bool),
focused: PropTypes.arrayOf(PropTypes.string)
}),
/** actions */
addChange: PropTypes.func,
removeChange: PropTypes.func,
}
styles = {
layer: {
position: 'absolute',
left: this.props.layer.x + 'px', // eslint-disable-line id-length
top: this.props.layer.y + 'px', // eslint-disable-line id-length
zIndex: this.props.layer.z,
width: this.props.layer.width + 'px',
height: this.props.layer.height + 'px',
opacity: this.props.layer.alpha,
backgroundColor: this.props.layer.backgroundColor,
},
}
render() {
// const { isActive, activate, deactivate } = this.props; // eslint-disable-line no-shadow
const {
layer: {id, _photoItem},
isActive,
photoItems,
gallery: {focused},
} = this.props;
// set current image to the last focused item or the default photoItem
const photoId = focused && focused.length ? _.last(focused) : _photoItem;
const image = photoItems[photoId];
const photoLayer = (
<div key={'photo_' + image.id} ref="photoLayer" style={[this.styles.layer]}>
<img src={image.url} />
</div>
);
return (
<div>
<h1>PhotoLayer {id}</h1>
{isActive ? <h3>Active</h3> : null}
{photoLayer}
<PhotoItemsGallery galleryKey={id} />
</div>
);
}
}
/**
* PhotoItems.js
* loads and renders photoItems from the server and stores them in redux state.
*/
/** boilerplate */
import React, {Component, PropTypes} from 'react';
/** helpers */
import {api} from 'helpers/apis';
/** reducers */
import {getEntitiesByName, load} from 'redux/modules/data';
/** components */
import {Gallery} from 'components';
// import {Err} from 'components';
import reduxGallery from '../Gallery/reduxGallery'; // TODO: figure out why i can't import like i do above
@reduxGallery(
{galleryName: 'photoItems'}, // galleryKey is passed in as props from @parent
(state, props) => ({
isLoaded: state.data.isLoaded.photoItems,
isLoading: state.data.isLoading.photoItems,
// loadError: state.data.error.photoItems,
photoItems: getEntitiesByName('photoItems')(state, props), // array selector
}),
{load} // mapDispatchToProps
)
export default class PhotoItemsGallery extends Component {
static propTypes = {
/** states */
galleryKey: PropTypes.string.isRequired,
isLoading: PropTypes.bool,
isLoaded: PropTypes.bool,
// loadError: PropTypes.string,
photoItems: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
}).isRequired),
selected: PropTypes.objectOf(PropTypes.bool),
/** actions */
load: PropTypes.func.isRequired,
select: PropTypes.func.isRequired,
focus: PropTypes.func.isRequired,
};
/** load photoItems if needed */
componentWillMount() {
// load if not already loaded
const {isLoaded, isLoading, load} = this.props; // eslint-disable-line no-shadow
if (!isLoaded && !isLoading) {
load(api.photoItems);
}
}
render() {
const {
galleryKey,
// isLoading,
// loadError,
photoItems,
selected,
select,
focus,
} = this.props; // eslint-disable-line no-shadow
// const loading = isLoading && <ProgressBar type="circular" mode="indeterminate" />
// const loading = isLoading && <div>Loading...</div>;
// const error = loadError && <Err msg={loadError} />;
// pass props to gallery. the gallery will
const gallery = photoItems.length &&
<Gallery
galleryKey={galleryKey}
images={photoItems}
selected={selected}
handleClick={select}
handleMouseOver={focus} />;
return (
<div>
{gallery}
</div>
);
// return (
// <div>
// {loading || error}
// {gallery}
// </div>
// );
}
}
/**
* bindActionData.js
* binds action creators to keys that are used by Higher Order Reducers to route
* action results to the proper tree/node in a nested reducer.
* NOTE: this is prior art from https://github.com/erikras/redux-form
*/
import mapValues from './mapValues';
/**
* Adds additional properties to the results of the function or map of functions passed
*/
export default function bindActionData(action, data) {
if (typeof action === 'function') {
return (...args) => ({
...action(...args),
...data
});
}
if (typeof action === 'object') {
return mapValues(action, value => bindActionData(value, data));
}
return action;
}
/**
* wrapMapDispatchToProps.js
* takes the same mapDispatchToProps object from @connect and performs
* bindActionCreators on passed in dispatch actions.
* NOTE: this is prior art from https://github.com/erikras/redux-form
*/
import {bindActionCreators} from 'redux';
const wrapMapDispatchToProps = (mapDispatchToProps, actionCreators) => {
if (mapDispatchToProps) {
if (typeof mapDispatchToProps === 'function') {
if (mapDispatchToProps.length > 1) {
return (dispatch, ownProps) => ({
...mapDispatchToProps(dispatch, ownProps),
...bindActionCreators(actionCreators, dispatch),
dispatch
});
}
return dispatch => ({
...mapDispatchToProps(dispatch),
...bindActionCreators(actionCreators, dispatch),
dispatch
});
}
return dispatch => ({
...bindActionCreators(mapDispatchToProps, dispatch),
...bindActionCreators(actionCreators, dispatch),
dispatch
});
}
return dispatch => ({
...bindActionCreators(actionCreators, dispatch),
dispatch
});
};
export default wrapMapDispatchToProps;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment