Last active
January 13, 2016 05:04
-
-
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…
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* gallery.duck.js | |
* reducers for gallery | |
*/ | |
const SELECT = 'aditive/creative/gallery/SELECT'; | |
const FOCUS = 'aditive/creative/gallery/FOCUS'; | |
const RESET = 'aditive/creative/gallery/RESET'; | |
const initialState = { | |
selected: {}, // default to first layer to focus on | |
focused: [], // history of images focused on | |
}; | |
/** reducer that is passed to the Higher Order Reducer */ | |
function reducer(state = initialState, action = {}) { | |
switch (action.type) { | |
case SELECT: | |
return { | |
...state, | |
selected: { | |
...state.selected, | |
// add id as key if selected. remove if not. | |
[action.id]: state.selected[action.id] ? undefined : true | |
} | |
}; | |
case RESET: | |
return { | |
...state, | |
...initialState, | |
}; | |
case FOCUS: | |
return { | |
...state, | |
focused: [ | |
...state.focused, | |
action.id | |
] | |
}; | |
default: | |
return state; | |
} | |
} | |
/** | |
* higher order reducer that passes galleryName and key in to nest states | |
* @return {function} - decorated reducer | |
*/ | |
export default function galleryReducer(state = {}, action = {}) { | |
const {galleryName, galleryKey, ...rest} = action; // eslint-disable-line no-redeclare | |
if (!galleryName) { | |
return state; | |
} | |
if (galleryKey) { // unique identifier for each instance of gallery of the same galleryName | |
return { | |
...state, | |
[galleryName]: { // tree | |
...state[galleryName], | |
[galleryKey]: reducer((state[galleryName] || {})[galleryKey], rest) // call reducer at galleryKey | |
} | |
}; | |
} | |
return { // do not nest under galleryName if no unique galleryKey is passed | |
...state, | |
[galleryName]: reducer(state[galleryName], rest) | |
}; | |
} | |
/** | |
* action creators | |
*/ | |
/** select a photo */ | |
export function select(id) { | |
return { | |
type: SELECT, | |
id, | |
}; | |
} | |
export function focus(id) { | |
return { | |
type: FOCUS, | |
id, | |
}; | |
} | |
/** reset selections */ | |
export function reset() { | |
return { | |
type: RESET, | |
}; | |
} | |
/** | |
* selectors | |
*/ | |
/** | |
* memoized selector to get the selected state of the currenty gallery.galleryKey | |
* @param {object} config | |
* @param {object} config.galleryName - id of tree | |
* @param {object} config.galleryKey - id of node | |
* @return {object} - selected object of { [id]: [bool] } | |
*/ | |
export function getGallery({galleryName, galleryKey}, state) { | |
const name = galleryName; | |
const key = galleryKey; | |
const tree = state.gallery[name]; // set tree | |
// return node results if node exists | |
if (name && key && tree) { | |
const node = tree[key]; | |
return node ? node : {...initialState}; // return node if it exists | |
} | |
// return tree if no node exists or initalState if all is empty | |
return (name && tree) ? tree : {...initialState}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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}/>; | |
} | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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}/> | |
); | |
} | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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> | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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> | |
// ); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Gallery.js | |
* dumb component that renders a gallery using material-ui | |
*/ | |
/** boilerplate */ | |
import React, {Component, PropTypes} from 'react'; | |
/** helpers */ | |
import Radium from 'radium'; | |
/** render components */ | |
import {GridList, GridTile} from 'material-ui-updated'; | |
@Radium | |
export default class Gallery extends Component { | |
static propTypes = { | |
/** states */ | |
images: PropTypes.array.isRequired, | |
selected: PropTypes.object, | |
/** actions */ | |
handleClick: PropTypes.func, | |
handleMouseOver: PropTypes.func, | |
}; | |
state = { | |
gridList: { | |
cols: 2, | |
// padding: 1, | |
// cellHeight: 200, | |
style: { | |
width: 400, | |
height: 400, | |
// overflowY: 'auto', | |
}, | |
}, | |
}; | |
styles = { | |
img: { | |
} | |
}; | |
render() { | |
const {images, selected, handleClick, handleMouseOver} = this.props; | |
const {styles} = this; | |
// render gallery with image and selected state. handle onClick and onMouseOver | |
// events with passed in props | |
const gallery = images.length && images.map(image => | |
<GridTile key={image.id}> | |
<img | |
ref={'img_' + image.id} | |
src={image.url} | |
onClick={handleClick && handleClick.bind(this, image.id)} | |
onMouseOver={handleMouseOver && handleMouseOver.bind(this, image.id)} | |
styles={[styles.img]} /> | |
{/* TODO: make this prettier */} | |
{ selected && selected[image.id] && <i className="fa fa-check" /> } | |
</GridTile> | |
); | |
return ( | |
<GridList {...this.state.gridList}> | |
{gallery} | |
</GridList> | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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