Home Reference Source

src/decorators/connect.js

import connectToStores from 'alt-utils/lib/connectToStores';
import React, { createElement } from 'react';

// TODO: deprecate connectAlternative asap!


/* eslint-disable */
/**
 * A component decorator for connecting to immutable stores.
 *
 * Basically a wrapper around `alt/utils/connectToStores`.  
 * Adds the necessary static methods `getStores()` and `getPropsFromStores()` to the decorated component.
 *
 * - Supports multiple stores.
 * - Supports a simplified, string-based access to stores, with optional renaming of props.
 * - Supports more flexible, redux-like access to stores using mapper functions.
 *
 * ### String notation
 *
 * @example
 * @connect([{store: MyStore, props: ['myValue', 'anotherValue']}])
 * export default class MyComponent extends React.Component {
 *      render() {
 *          const {myValue, anotherValue} = this.props;
 *          ...
 *      }
 * }
 *
 * You can rename props using the ` as ` alias syntax
 *
 * @example
 * @connect([{
 *      store: PeopleStore,
 *      props: ['items as people']
 * }, {
 *      store: ProductStore,
 *      props: ['items as products']
 * }])
 * export default class MyComponent extends React.Component {
 *      render() {
 *          // this.props.people, this.props.products, ...
 *      }
 * }
 *
 * ### Function notation
 *
 * Use mapper functions instead of strings in order to manually retrieve store values.
 * The function receives the store state and the component props.
 *
 * @example
 * @connect([{
 *      store: MyStore,
 *      props: (state, props) => {
 *          return {
 *              item: state.get('items').filter(item => item.get('id') === props.id)
 *          }
 *      }
 * }])
 * export default class MyComponent extends React.Component {
 *      render() {
 *          const item = this.props.item;
 *      }
 * }
 *
 * Technically, you could also mix all access methods, but this defeats the purpose of simple access:
 *
 * @example
 * @connect([{
 *      store: MyStore,
 *      props: ['someProp', 'anotherProp', (state, props) => {
 *          return {
 *              item: state.get('items').filter(item => item.get('id') === props.id)
 *          }
 *      }, 'some.nested.value as foo']
 * }])
 * export default class MyComponent extends React.Component {
 *      ...
 * }
 *
 * There are however valid usecase for mixing access methods. For example, you might have keys that themselves contain dots.
 * For example, that is the case when using `validate.js` with nested constraints and keeping validation results in the store.
 * There might be an `errors` map in your storewith keys like `user.address.street`. In such a case you wouldn't be able to access those values because the dots do not
 * represent the actual keyPath in the tree:
 *
 * @example
 * @connect([{
 *   store,
 *   props: ['user.address.street', (state) => ({errors: state.getIn(['errors', 'user.address.street'])})]
 * }])
 *
 * @see https://github.com/goatslacker/alt/blob/master/docs/utils/immutable.md
 * @see https://github.com/goatslacker/alt/blob/master/src/utils/connectToStores.js
 *
 * @param {Array<{store: AltStore, props: Array<string>}>} definitions - A list of objects that each define a store connection
 */
/* eslint-enable */
export default function connect(definitions) {
    return function(targetClass) {
        targetClass.getStores = function() {
            return definitions.map((def) => def.store);
        };
        targetClass.getPropsFromStores = function(componentProps) {
            return definitions.reduce((result, def) => {
                if (typeof def.props === 'function') {
                    // the props definition is itself a function. return with its result.
                    return Object.assign(result, def.props(def.store.state, componentProps));
                }
                // the props definition is an array. evaluate and reduce each of its elements
                return def.props.reduce((result, accessor) => {
                    return Object.assign(result, mapProps(accessor, def.store.state, componentProps));
                }, result);
            }, {});
        };
        return connectToStores(targetClass);
    };
}

function mapProps(accessor, state, props) {
    switch (typeof accessor) {
        case 'function':
            return mapFuncAccessor(accessor, state, props);
        case 'string':
            return mapStringAccessor(accessor, state);
    }
}

function mapFuncAccessor(accessor, state, props) {
    return accessor(state, props);
}

function mapStringAccessor(accessor, state) {
    const {keyPath, propName} = parseAccessor(accessor);
    return {
        [propName]: state.getIn(keyPath)
    };
}

/**
 * Takes the accessor defined by the component and retrieves `keyPath` and `propName`
 * The accessor may be the name of a top-level value in the store, or a path to a nested value.
 * Nested values can be accessed using a dot-separated syntax (e.g. `some.nested.value`).
 *
 * The name of the prop received by the component is the last part of the accessor in case of
 * a nested syntax, or the accessor itself in case of a simple top-level accessor.
 *
 * If you need to pass the value using a different prop name, you can use the ` as ` alias syntax,
 * e.g. `someProp as myProp` or `some.prop as myProp`.
 *
 * examples:
 *
 *      'someValue' // {keyPath: ['someValue'], propName: 'someValue'}
 *      'someValue as foo' // {keyPath: ['someValue'], propName: 'foo'}
 *      'some.nested.value' // {keyPath: ['some', 'nested', 'value'], propName: 'value'}
 *      'some.nested.value as foo' // {keyPath: ['some', 'nested', 'value'], propName: 'foo'}
 *
 * @param {string} string - The value accessor passed by the component decorator.
 * @return {object} result - A `{storeName, propName}` object
 * @return {string} result.keyPath - An immutablejs keyPath array to the value in the store
 * @return {string} result.propName - name for the prop as expected by the component
 */
function parseAccessor(accessor) {
    let keyPath, propName;
    if (accessor.indexOf(' as ') > -1) {
        // e.g. 'foo as bar' or 'some.foo as bar'
        const parts = accessor.split(' as ');
        keyPath = parts[0].split('.');
        propName = parts[1];
    }
    else {
        // e.g. 'foo' or 'some.foo'
        keyPath = accessor.split('.');
        propName = keyPath[keyPath.length - 1];
    }
    return {keyPath, propName};
}


function connectAlternative(store, mapStateToProps, WrappedComponent) {
    return class Connect extends React.Component {

        constructor(props, context) {
            super(props, context);
            const storeState = store.getState();
            this.state = { storeState: mapStateToProps(storeState, props) };
        }

        componentDidMount() {
            this._isMounted = true;
            this.storeSubscription = store.listen(this.handleStoreUpdate);
        }

        componentWillUnmount() {
            this._isMounted = false;
            if (this.storeSubscription) {
                store.unlisten(this.storeSubscription);
            }
        }

        // if we use props in mapStateToProps,
        // we need to run it again when props have changed
        componentWillReceiveProps(nextProps) {
            //untested! should work though
            if(mapStateToProps.length > 1) {
                this.setState({storeState: mapStateToProps(store.getState(), nextProps)});
            }
        }

        handleStoreUpdate = state => {
            if(this._isMounted) {
                this.setState({ storeState: mapStateToProps(state, this.props) });
            }
        }

        render() {
            const mergedProps = { ...this.props, ...this.state.storeState };
            return createElement(WrappedComponent, mergedProps);
        }

    };
}

export {connectAlternative};