diff --git a/README.md b/README.md index c9ed837..c072ad0 100644 --- a/README.md +++ b/README.md @@ -42,19 +42,21 @@ angular.module('app', ['ngRedux']) #### Usage +*Using controllerAs syntax* ```JS import * as CounterActions from '../actions/counter'; class CounterController { constructor($ngRedux, $scope) { - /* ngRedux will merge the requested state's slice and actions onto the $scope, + /* ngRedux will merge the requested state's slice and actions onto this, you don't need to redefine them in your controller */ - $ngRedux.connect($scope, this.mapStateToScope, CounterActions); + let unsubscribe = $ngRedux.connect(this.mapStateToTarget, CounterActions)(this); + $scope.$on('$destroy', unsubscribe); } // Which part of the Redux global state does our component want to receive on $scope? - mapStateToScope(state) { + mapStateToTarget(state) { return { counter: state.counter }; @@ -84,17 +86,29 @@ Creates the Redux store, and allow `connect()` to access it. * [`storeEnhancers`] \(*Function[]*): Optional, this will be used to create the store, in most cases you don't need to pass anything, see [Store Enhancer official documentation.](http://rackt.github.io/redux/docs/Glossary.html#store-enhancer) -### `connect([scope], [mapStateToScope], [mapDispatchToScope])` +### `connect([scope], [mapStateToTarget], [mapDispatchToTarget])([target])` Connects an Angular component to Redux. #### Arguments -* [`scope`] \(*Object*): The `$scope` of your controller. -* [`mapStateToScope`] \(*Function*): connect will subscribe to Redux store updates. Any time it updates, mapStateToTarget will be called. Its result must be a plain object, and it will be merged into `target`. -* [`mapDispatchToScope`] \(*Object* or *Function*): If an object is passed, each function inside it will be assumed to be a Redux action creator. An object with the same function names, but bound to a Redux store, will be merged into your component `$scope`. If a function is passed, it will be given `dispatch`. It’s up to you to return an object that somehow uses `dispatch` to bind action creators in your own way. (Tip: you may use the [`bindActionCreators()`](http://gaearon.github.io/redux/docs/api/bindActionCreators.html) helper from Redux.). +* [`mapStateToTarget`] \(*Function*): connect will subscribe to Redux store updates. Any time it updates, mapStateToTarget will be called. Its result must be a plain object, and it will be merged into `target`. +* [`mapDispatchToTarget`] \(*Object* or *Function*): Optional. If an object is passed, each function inside it will be assumed to be a Redux action creator. An object with the same function names, but bound to a Redux store, will be merged onto `target`. If a function is passed, it will be given `dispatch`. It’s up to you to return an object that somehow uses `dispatch` to bind action creators in your own way. (Tip: you may use the [`bindActionCreators()`](http://gaearon.github.io/redux/docs/api/bindActionCreators.html) helper from Redux.). + +*You then need to invoke the function a second time, with `target` as parameter:* +* [`target`] \(*Object* or *Function*): If passed an object, the results of `mapStateToTarget` and `mapDispatchToTarget` will be merged onto it. If passed a function, the function will receive the results of `mapStateToTarget` and `mapDispatchToTarget` as parameters. + +e.g: +```JS +connect(this.mapState, this.mapDispatch)(this); +//Or +connect(this.mapState, this.mapDispatch)((selectedState, actions) => {/* ... */}); +``` + #### Remarks -* As `$scope` is passed to `connect`, ngRedux will listen to the `$destroy` event and unsubscribe the change listener itself, you don't need to keep track of your subscribtions. +* The `mapStateToTarget` function takes a single argument of the entire Redux store’s state and returns an object to be passed as props. It is often called a selector. Use reselect to efficiently compose selectors and compute derived data. + + ### Store API All of redux's store methods (i.e. `dispatch`, `subscribe` and `getState`) are exposed by $ngRedux and can be accessed directly. For example: diff --git a/examples/counter/components/counter.js b/examples/counter/components/counter.js index 540db83..f6d69c2 100644 --- a/examples/counter/components/counter.js +++ b/examples/counter/components/counter.js @@ -13,7 +13,8 @@ export default function counter() { class CounterController { constructor($ngRedux, $scope) { - $ngRedux.connect($scope, this.mapStateToScope, CounterActions, 'vm'); + const unsubscribe = $ngRedux.connect(this.mapStateToScope, CounterActions)(this); + $scope.$on('$destroy', unsubscribe); } // Which part of the Redux global state does our component want to receive on $scope? diff --git a/examples/counter/package.json b/examples/counter/package.json index 52c803a..674815f 100644 --- a/examples/counter/package.json +++ b/examples/counter/package.json @@ -22,14 +22,13 @@ "html-loader": "^0.3.0", "html-webpack-plugin": "^1.6.1", "react": "^0.13.3", - "redux-devtools": "^1.1.1", "webpack": "^1.11.0", "webpack-dev-server": "^1.10.1" }, "dependencies": { "angular": "^1.4.4", - "ng-redux": "^1.0.0-rc.2", - "redux": "^1.0.1", + "ng-redux": "2.0.0", + "redux": "^2.0.0", "redux-thunk": "^0.1.0" } } diff --git a/package.json b/package.json index e6acccb..f65448a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ng-redux", - "version": "1.0.0-rc.4", + "version": "2.0.0", "description": "Redux bindings for Angular.js", "main": "./lib/index.js", "scripts": { @@ -23,14 +23,15 @@ "babel-loader": "^5.3.2", "expect": "^1.8.0", "mocha": "^2.2.5", + "sinon": "^1.16.1", "webpack": "^1.10.5" }, "peerDependencies": { - "redux": "^1.0.0" + "redux": "^2.0.0" }, "dependencies": { "invariant": "^2.1.0", "lodash": "^3.10.1", - "redux": "^1.0.1" + "redux": "^2.0.0" } } diff --git a/src/components/connector.js b/src/components/connector.js index c43b623..ae31855 100644 --- a/src/components/connector.js +++ b/src/components/connector.js @@ -1,53 +1,63 @@ import shallowEqual from '../utils/shallowEqual'; import wrapActionCreators from '../utils/wrapActionCreators'; -import findControllerAsKey from '../utils/findControllerAsKey'; import invariant from 'invariant'; import _ from 'lodash'; +const defaultMapStateToTarget = () => ({}); +const defaultMapDispatchToTarget = dispatch => ({dispatch}); + export default function Connector(store) { - return (scope, mapStateToScope, mapDispatchToScope = {}) => { + return (mapStateToTarget, mapDispatchToTarget) => { + + const finalMapStateToTarget = mapStateToTarget || defaultMapStateToTarget; + + const finalMapDispatchToTarget = _.isPlainObject(mapDispatchToTarget) ? + wrapActionCreators(mapDispatchToTarget) : + mapDispatchToTarget || defaultMapDispatchToTarget; invariant( - scope && _.isFunction(scope.$on) && _.isFunction(scope.$destroy), - 'The scope parameter passed to connect must be an instance of $scope.' - ); - invariant( - _.isFunction(mapStateToScope), - 'mapStateToScope must be a Function. Instead received $s.', mapStateToScope + _.isFunction(finalMapStateToTarget), + 'mapStateToTarget must be a Function. Instead received $s.', finalMapStateToTarget ); + invariant( - _.isPlainObject(mapDispatchToScope) || _.isFunction(mapDispatchToScope), - 'mapDispatchToScope must be a plain Object or a Function. Instead received $s.', mapDispatchToScope + _.isPlainObject(finalMapDispatchToTarget) || _.isFunction(finalMapDispatchToTarget), + 'mapDispatchToTarget must be a plain Object or a Function. Instead received $s.', finalMapDispatchToTarget ); - const propertyKey = findControllerAsKey(scope); + let slice = getStateSlice(store.getState(), finalMapStateToTarget); + + const boundActionCreators = finalMapDispatchToTarget(store.dispatch); + + return (target) => { - let slice = getStateSlice(store.getState(), mapStateToScope); - let target = propertyKey ? scope[propertyKey] : scope; + invariant( + _.isFunction(target) || _.isObject(target), + 'The target parameter passed to connect must be a Function or a plain object.' + ); - const finalMapDispatchToScope = _.isPlainObject(mapDispatchToScope) ? - wrapActionCreators(mapDispatchToScope) : - mapDispatchToScope; + //Initial update + updateTarget(target, slice, boundActionCreators); - //Initial update - _.assign(target, slice, finalMapDispatchToScope(store.dispatch)); + const unsubscribe = store.subscribe(() => { + const nextSlice = getStateSlice(store.getState(), finalMapStateToTarget); + if (!shallowEqual(slice, nextSlice)) { + slice = nextSlice; + updateTarget(target, slice, boundActionCreators); + } + }); + return unsubscribe; + } - subscribe(scope, store, () => { - const nextSlice = getStateSlice(store.getState(), mapStateToScope); - if (!shallowEqual(slice, nextSlice)) { - slice = nextSlice; - _.assign(target, slice); - } - }); } } -function subscribe(scope, store, callback) { - const unsubscribe = store.subscribe(callback); - - scope.$on('$destroy', () => { - unsubscribe(); - }); +function updateTarget(target, StateSlice, dispatch) { + if(_.isFunction(target)) { + target(StateSlice, dispatch); + } else { + _.assign(target, StateSlice, dispatch); + } } function getStateSlice(state, mapStateToScope) { diff --git a/src/components/ngRedux.js b/src/components/ngRedux.js index 57f4977..92097c7 100644 --- a/src/components/ngRedux.js +++ b/src/components/ngRedux.js @@ -38,7 +38,7 @@ export default function ngReduxProvider() { } } - let finalCreateStore = _storeEnhancers ? compose(..._storeEnhancers, createStore) : createStore; + let finalCreateStore = _storeEnhancers ? compose(..._storeEnhancers)(createStore) : createStore; //digestMiddleware needs to be the last one. resolvedMiddleware.push(digestMiddleware($injector.get('$rootScope'))); diff --git a/src/utils/findControllerAsKey.js b/src/utils/findControllerAsKey.js deleted file mode 100644 index 92c833a..0000000 --- a/src/utils/findControllerAsKey.js +++ /dev/null @@ -1,11 +0,0 @@ -import _ from 'lodash'; - -export default function findControllerAsKey(scope) { - let propertyKey; - _.forOwn(scope, (v, k) => { - if (scope[k] && scope[k].constructor && scope[k].constructor.$inject) { - propertyKey = k; - } - }); - return propertyKey; -} diff --git a/test/components/connector.spec.js b/test/components/connector.spec.js index 7cfd82e..b32eb74 100644 --- a/test/components/connector.spec.js +++ b/test/components/connector.spec.js @@ -1,4 +1,5 @@ import expect from 'expect'; +let sinon = require('sinon'); import { createStore } from 'redux'; import Connector from '../../src/components/connector'; import _ from 'lodash'; @@ -6,75 +7,90 @@ import _ from 'lodash'; describe('Connector', () => { let store; let connect; - let scopeStub; + let targetObj; + let defaultState; beforeEach(() => { - store = createStore((state, action) => ({ + defaultState = { foo: 'bar', - baz: action.payload - })); - scopeStub = { - $on: () => {}, - $destroy: () => {} + baz: -1 }; + store = createStore((state = defaultState, action) => { + return {...state, baz: action.payload}; + }); + targetObj = {}; connect = Connector(store); }); - it('Should throw when not passed a $scope object', () => { - expect(connect.bind(connect, () => { }, () => ({}))).toThrow(); - expect(connect.bind(connect, 15, () => ({}))).toThrow(); - expect(connect.bind(connect, undefined, () => ({}))).toThrow(); - expect(connect.bind(connect, {}, () => ({}))).toThrow(); + it('Should throw when target is not a Function or a plain object', () => { + expect(connect(() => ({})).bind(connect, 15)).toThrow(); + expect(connect(() => ({})).bind(connect, undefined)).toThrow(); + expect(connect(() => ({})).bind(connect, 'test')).toThrow(); + + expect(connect(() => ({})).bind(connect, {})).toNotThrow(); + expect(connect(() => ({})).bind(connect, () => {})).toNotThrow(); - expect(connect.bind(connect, scopeStub, () => ({}))).toNotThrow(); }); - it('Should throw when selector does not return a plain object as target', () => { - expect(connect.bind(connect, scopeStub, state => state.foo)).toThrow(); + it('Should throw when selector does not return a plain object', () => { + expect(connect.bind(connect, state => state.foo)).toThrow(); }); - it('Should extend scope with selected state once directly after creation', () => { - connect( - scopeStub, + it('Should extend target (Object) with selected state once directly after creation', () => { + connect( () => ({ vm: { test: 1 } - })); + }))(targetObj); - expect(scopeStub.vm).toEqual({ test: 1 }); + expect(targetObj.vm).toEqual({ test: 1 }); }); - it('Should update the scope passed to connect when the store updates', () => { - connect(scopeStub, state => state); + it('Should update the target (Object) passed to connect when the store updates', () => { + connect(state => state)(targetObj); store.dispatch({ type: 'ACTION', payload: 0 }); - expect(scopeStub.baz).toBe(0); - store.dispatch({ type: 'ACTION', payload: 1 }); - expect(scopeStub.baz).toBe(1); + expect(targetObj.baz).toBe(0); + store.dispatch({ type: 'ACTION', payload: 7 }); + expect(targetObj.baz).toBe(7); }); it('Should prevent unnecessary updates when state does not change (shallowly)', () => { - connect(scopeStub, state => state); + connect(state => state)(targetObj); store.dispatch({ type: 'ACTION', payload: 5 }); - expect(scopeStub.baz).toBe(5); + expect(targetObj.baz).toBe(5); - scopeStub.baz = 0; + targetObj.baz = 0; //this should not replace our mutation, since the state didn't change store.dispatch({ type: 'ACTION', payload: 5 }); - expect(scopeStub.baz).toBe(0); + expect(targetObj.baz).toBe(0); + + }); + it('Should extend target (object) with actionCreators', () => { + connect(() => ({}), { ac1: () => { }, ac2: () => { } })(targetObj); + expect(_.isFunction(targetObj.ac1)).toBe(true); + expect(_.isFunction(targetObj.ac2)).toBe(true); }); - it('Should extend scope with actionCreators', () => { - connect(scopeStub, () => ({}), { ac1: () => { }, ac2: () => { } }); - expect(_.isFunction(scopeStub.ac1)).toBe(true); - expect(_.isFunction(scopeStub.ac2)).toBe(true); + it('Should return an unsubscribing function', () => { + const unsubscribe = connect(state => state)(targetObj); + store.dispatch({ type: 'ACTION', payload: 5 }); + + expect(targetObj.baz).toBe(5); + + unsubscribe(); + + store.dispatch({ type: 'ACTION', payload: 7 }); + + expect(targetObj.baz).toBe(5); + }); - it('Should provide dispatch to mapDispatchToScope when receiving a Function', () => { + it('Should provide dispatch to mapDispatchToTarget when receiving a Function', () => { let receivedDispatch; - connect(scopeStub, () => ({}), dispatch => { receivedDispatch = dispatch }); + connect(() => ({}), dispatch => { receivedDispatch = dispatch })(targetObj); expect(receivedDispatch).toBe(store.dispatch); }); diff --git a/test/utils/findControllerAs.spec.js b/test/utils/findControllerAs.spec.js deleted file mode 100644 index 5eb41f7..0000000 --- a/test/utils/findControllerAs.spec.js +++ /dev/null @@ -1,21 +0,0 @@ -import expect from 'expect'; -import findControllerAsKey from '../../src/utils/findControllerAsKey'; - -describe('Utils', () => { - describe('findControllerAsKey', () => { - it('Should return the property key of the controller', () => { - - let controllerStub = () => {}; - controllerStub.constructor.$inject = ['$scope', '$ngRedux']; - - let propertyKey = findControllerAsKey({ - $apply: () => {}, - $on: () => {}, - $$id: 2, - vm: controllerStub - }); - - expect(propertyKey).toBe('vm'); - }); - }); -}); \ No newline at end of file