From 0372c0ed7b91fa71040bb95dc852b51f4cdb9547 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Tue, 25 Aug 2015 00:09:45 -0400 Subject: [PATCH 01/14] Digest middleware + devTools in example --- examples/counter/components/counter.html | 1 + examples/counter/components/counter.js | 10 +++---- examples/counter/index.html | 7 +++-- examples/counter/index.js | 34 ++++++++++++++++++++-- examples/counter/package.json | 2 ++ src/components/connector.js | 36 ++++++++++++++++-------- src/components/digestMiddleware.js | 9 ++++++ src/components/ngRedux.js | 21 ++++++++------ src/utils/isPlainObject.js | 25 ++++++++++++++++ 9 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 src/components/digestMiddleware.js create mode 100644 src/utils/isPlainObject.js diff --git a/examples/counter/components/counter.html b/examples/counter/components/counter.html index 71de309..7912361 100644 --- a/examples/counter/components/counter.html +++ b/examples/counter/components/counter.html @@ -4,4 +4,5 @@ + diff --git a/examples/counter/components/counter.js b/examples/counter/components/counter.js index e5e0028..6214d2e 100644 --- a/examples/counter/components/counter.js +++ b/examples/counter/components/counter.js @@ -14,15 +14,13 @@ export default function counter() { class CounterController { constructor($ngRedux) { - this.counter = 0; - $ngRedux.connect(state => ({ - counter: state.counter - }), - ({counter}) => this.counter = counter); + $ngRedux.connect(state => ({counter: state.counter}), this); - let {increment, decrement, incrementIfOdd} = bindActionCreators(CounterActions, $ngRedux.dispatch); + let {increment, decrement, incrementIfOdd, incrementAsync} = bindActionCreators(CounterActions, $ngRedux.dispatch); this.increment = increment; this.decrement = decrement; this.incrementIfOdd = incrementIfOdd; + this.incrementAsync = incrementAsync; } + } \ No newline at end of file diff --git a/examples/counter/index.html b/examples/counter/index.html index 36923c2..f548f84 100644 --- a/examples/counter/index.html +++ b/examples/counter/index.html @@ -4,7 +4,10 @@ {%= o.htmlWebpackPlugin.options.title %} - - + +
+ +
+
\ No newline at end of file diff --git a/examples/counter/index.js b/examples/counter/index.js index 1e0e23b..a29d383 100644 --- a/examples/counter/index.js +++ b/examples/counter/index.js @@ -3,9 +3,39 @@ import 'ng-redux'; import rootReducer from './reducers'; import thunk from 'redux-thunk'; import counter from './components/counter'; +import { devTools, persistState } from 'redux-devtools'; +import { DevTools, DebugPanel, LogMonitor } from 'redux-devtools/lib/react'; +import React, { Component } from 'react'; +import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; angular.module('counter', ['ngRedux']) .config(($ngReduxProvider) => { - $ngReduxProvider.createStoreWith(rootReducer, [thunk]); + $ngReduxProvider.createStoreWith(rootReducer, [thunk], [devTools()]); }) - .directive('ngrCounter', counter); + .directive('ngrCounter', counter) +//------- DevTools specific code ---- + .run(($ngRedux, $rootScope) => { + React.render( + , + document.getElementById('devTools') + ); + //Hack to reflect state changes when disabling/enabling actions via the monitor + $ngRedux.subscribe(_ => { + setTimeout($rootScope.$apply, 100); + }); + }); + + +class App extends Component { + render() { + return ( +
+ + + +
+ ); + } +} + + diff --git a/examples/counter/package.json b/examples/counter/package.json index ebd89de..db869c8 100644 --- a/examples/counter/package.json +++ b/examples/counter/package.json @@ -21,6 +21,8 @@ "babel-loader": "^5.3.2", "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" }, diff --git a/src/components/connector.js b/src/components/connector.js index 2df9e09..6a7b9bc 100644 --- a/src/components/connector.js +++ b/src/components/connector.js @@ -1,25 +1,39 @@ import isFunction from '../utils/isFunction'; +import isPlainObject from '../utils/isPlainObject'; import shallowEqual from '../utils/shallowEqual'; import invariant from 'invariant'; export default function Connector(store) { - return (selector, callback) => { - invariant( - isFunction(callback), - 'The callback parameter passed to connect must be a Function. Instead received %s.', - typeof callback - ); + return (selector, target) => { //Initial update - let params = selector(store.getState()); - callback(params); + let params = getStateSlice(store.getState(), selector); + target = angular.merge(target, params); - return store.subscribe(() => { - let nextParams = selector(store.getState()); + let unsubscribe = store.subscribe(() => { + let nextParams = getStateSlice(store.getState(), selector); if (!shallowEqual(params, nextParams)) { - callback(nextParams); + target = angular.merge(target, nextParams); params = nextParams; } }); + + if(isFunction(target.$destroy)) { + target.$on('$destroy', () => { + unsubscribe(); + }); + } + + return unsubscribe; } } + +function getStateSlice(state, selector) { + let slice = selector(state); + invariant( + isPlainObject(slice), + '`selector` must return an object. Instead received %s.', + slice + ); + return slice; +} diff --git a/src/components/digestMiddleware.js b/src/components/digestMiddleware.js new file mode 100644 index 0000000..cbeb598 --- /dev/null +++ b/src/components/digestMiddleware.js @@ -0,0 +1,9 @@ +export default function digestMiddleware($rootScope) { + return store => next => action => { + if(!$rootScope.$$phase) { + $rootScope.$apply(next(action)); + } else { + next(action); + } + }; +} diff --git a/src/components/ngRedux.js b/src/components/ngRedux.js index 18128d5..9554ac2 100644 --- a/src/components/ngRedux.js +++ b/src/components/ngRedux.js @@ -1,14 +1,15 @@ import Connector from './connector'; import invariant from 'invariant'; import isFunction from '../utils/isFunction'; -import {createStore, applyMiddleware} from 'redux'; +import {createStore, applyMiddleware, compose} from 'redux'; +import digestMiddleware from './digestMiddleware'; export default function ngReduxProvider() { let _reducer = undefined; let _middlewares = []; - let _storeEnhancer = undefined; + let _storeEnhancers = undefined; - this.createStoreWith = (reducer, middlewares, storeEnhancer) => { + this.createStoreWith = (reducer, middlewares, storeEnhancers) => { invariant( isFunction(reducer), 'The reducer parameter passed to createStoreWith must be a Function. Instead received %s.', @@ -16,13 +17,13 @@ export default function ngReduxProvider() { ); invariant( - !storeEnhancer || isFunction(storeEnhancer), - 'The storeEnhancer parameter passed to createStoreWith must be a Function. Instead received %s.', - typeof storeEnhancer + !storeEnhancers || Array.isArray(storeEnhancers), + 'The storeEnhancers parameter passed to createStoreWith must be an Array. Instead received %s.', + typeof storeEnhancers ); _reducer = reducer; - _storeEnhancer = storeEnhancer || createStore; + _storeEnhancers = storeEnhancers _middlewares = middlewares; }; @@ -37,7 +38,11 @@ export default function ngReduxProvider() { } } - store = applyMiddleware(...resolvedMiddleware)(_storeEnhancer)(_reducer); + let finalCreateStore = _storeEnhancers ? compose(..._storeEnhancers, createStore) : createStore; + + resolvedMiddleware.push(digestMiddleware($injector.get('$rootScope'))); + + store = applyMiddleware(...resolvedMiddleware)(finalCreateStore)(_reducer); return { ...store, diff --git a/src/utils/isPlainObject.js b/src/utils/isPlainObject.js new file mode 100644 index 0000000..fafbe14 --- /dev/null +++ b/src/utils/isPlainObject.js @@ -0,0 +1,25 @@ +const fnToString = (fn) => Function.prototype.toString.call(fn); + +/** + * @param {any} obj The object to inspect. + * @returns {boolean} True if the argument appears to be a plain object. + */ +export default function isPlainObject(obj) { + if (!obj || typeof obj !== 'object') { + return false; + } + + const proto = typeof obj.constructor === 'function' ? + Object.getPrototypeOf(obj) : + Object.prototype; + + if (proto === null) { + return true; + } + + const constructor = proto.constructor; + + return typeof constructor === 'function' + && constructor instanceof constructor + && fnToString(constructor) === fnToString(Object); +} \ No newline at end of file From 58a8f2bf5ca3f8f2a48202b9a07c1f7d1f75bf62 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Tue, 25 Aug 2015 00:16:00 -0400 Subject: [PATCH 02/14] renamed params to slice --- src/components/connector.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/connector.js b/src/components/connector.js index 6a7b9bc..cd4ccb0 100644 --- a/src/components/connector.js +++ b/src/components/connector.js @@ -7,14 +7,14 @@ export default function Connector(store) { return (selector, target) => { //Initial update - let params = getStateSlice(store.getState(), selector); - target = angular.merge(target, params); + let slice = getStateSlice(store.getState(), selector); + target = angular.merge(target, slice); let unsubscribe = store.subscribe(() => { - let nextParams = getStateSlice(store.getState(), selector); - if (!shallowEqual(params, nextParams)) { - target = angular.merge(target, nextParams); - params = nextParams; + let nextSlice = getStateSlice(store.getState(), selector); + if (!shallowEqual(slice, nextSlice)) { + target = angular.merge(target, nextSlice); + slice = nextSlice; } }); From 00bdba7ce5d51de92fb9901e804b87c216224fb8 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Tue, 25 Aug 2015 22:15:53 -0400 Subject: [PATCH 03/14] Refactored tests and removed deprecated ones --- package.json | 1 + src/components/connector.js | 13 +++++-- src/components/ngRedux.js | 1 + test/components/connector.spec.js | 63 ++++++++++++------------------- 4 files changed, 36 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index 9be79ec..2241c16 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "invariant": "^2.1.0", + "lodash": "^3.10.1", "redux": "^1.0.1" } } diff --git a/src/components/connector.js b/src/components/connector.js index cd4ccb0..7e92034 100644 --- a/src/components/connector.js +++ b/src/components/connector.js @@ -2,18 +2,25 @@ import isFunction from '../utils/isFunction'; import isPlainObject from '../utils/isPlainObject'; import shallowEqual from '../utils/shallowEqual'; import invariant from 'invariant'; +import _ from 'lodash' -export default function Connector(store) { +export default function Connector(store, $injector) { return (selector, target) => { + invariant( + isPlainObject(target), + 'The target parameter passed to connect must be a plain object. Instead received %s.', + typeof target + ); + //Initial update let slice = getStateSlice(store.getState(), selector); - target = angular.merge(target, slice); + target = _.assign(target, slice); let unsubscribe = store.subscribe(() => { let nextSlice = getStateSlice(store.getState(), selector); if (!shallowEqual(slice, nextSlice)) { - target = angular.merge(target, nextSlice); + target = _.assign(target, nextSlice); slice = nextSlice; } }); diff --git a/src/components/ngRedux.js b/src/components/ngRedux.js index 9554ac2..2ca327b 100644 --- a/src/components/ngRedux.js +++ b/src/components/ngRedux.js @@ -40,6 +40,7 @@ export default function ngReduxProvider() { let finalCreateStore = _storeEnhancers ? compose(..._storeEnhancers, createStore) : createStore; + //digestMiddleware needs to be the last one. resolvedMiddleware.push(digestMiddleware($injector.get('$rootScope'))); store = applyMiddleware(...resolvedMiddleware)(finalCreateStore)(_reducer); diff --git a/test/components/connector.spec.js b/test/components/connector.spec.js index 72d8c96..ae3801c 100644 --- a/test/components/connector.spec.js +++ b/test/components/connector.spec.js @@ -10,34 +10,36 @@ describe('Connector', () => { store = createStore((state, action) => ({ foo: 'bar', baz: action.payload, - anotherState: 12 + anotherState: 12, + childObject: {child: true} })); connect = Connector(store); }); - it('Should throw when not passed a function as callback', () => { - expect(connect.bind(connect, () => {}, undefined)).toThrow(); - expect(connect.bind(connect, () => {}, {})).toThrow(); - expect(connect.bind(connect, () => {}, 15)).toThrow(); + it('Should throw when not passed a plain object as target', () => { + expect(connect.bind(connect, () => ({}), () => {})).toThrow(); + expect(connect.bind(connect, () => ({}), 15)).toThrow(); + expect(connect.bind(connect, () => ({}), undefined)).toThrow(); + expect(connect.bind(connect, () => ({}), {})).toNotThrow(); }); - it('Callback should be called once directly after creation to allow initialization', () => { - let counter = 0; - let callback = () => counter++; - connect(state => state, callback); - expect(counter).toBe(1); + it('target should be extended with state once directly after creation', () => { + let target = {}; + connect(() => ({test: 1}), target); + expect(target).toEqual({test: 1}); }); - it('Should call the callback passed to connect when the store updates', () => { - let counter = 0; - let callback = () => counter++; - connect(state => state, callback); + it('Should update the target passed to connect when the store updates', () => { + let target = {}; + connect(state => state, target); store.dispatch({type: 'ACTION', payload: 0}); + expect(target.baz).toBe(0); store.dispatch({type: 'ACTION', payload: 1}); - expect(counter).toBe(3); + expect(target.baz).toBe(1); }); - it('Should prevent unnecessary updates when state does not change (shallowly)', () => { + //does that still makes sense? + /*it('Should prevent unnecessary updates when state does not change (shallowly)', () => { let counter = 0; let callback = () => counter++; connect(state => ({baz: state.baz}), callback); @@ -45,33 +47,16 @@ describe('Connector', () => { store.dispatch({type: 'ACTION', payload: 0}); store.dispatch({type: 'ACTION', payload: 1}); expect(counter).toBe(3); - }); + });*/ - it('Should pass the selected state as argument to the callback', () => { - connect(state => ({ - myFoo: state.foo - }), newState => { - expect(newState).toEqual({myFoo: 'bar'}); - }); - }); - - it('Should allow multiple store slices to be selected', () => { - connect(state => ({ - foo: state.foo, - anotherState: state.anotherState - }), ({foo, anotherState}) => { - expect(foo).toBe('bar'); - expect(anotherState).toBe(12); - }); - }); it('Should return an unsubscribing function', () => { - let counter = 0; - let callback = () => counter++; - let unsubscribe = connect(state => state, callback); - store.dispatch({type: 'ACTION', payload: 0}); + let target = {}; + let unsubscribe = connect(state => state, target); + store.dispatch({type: 'ACTION', payload: 1}); + expect(target.baz).toBe(1); unsubscribe(); store.dispatch({type: 'ACTION', payload: 2}); - expect(counter).toBe(2); + expect(target.baz).toBe(1); }); }); From c231ee94a529df527da9fd72eb7620bec38a7307 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Tue, 25 Aug 2015 22:27:08 -0400 Subject: [PATCH 04/14] add test --- test/components/connector.spec.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/components/connector.spec.js b/test/components/connector.spec.js index ae3801c..d2a1f23 100644 --- a/test/components/connector.spec.js +++ b/test/components/connector.spec.js @@ -23,6 +23,11 @@ describe('Connector', () => { expect(connect.bind(connect, () => ({}), {})).toNotThrow(); }); + it('Should throw when selector does not return a plain object as target', () => { + expect(connect.bind(connect, state => state.foo, {})).toThrow(); + }); + + it('target should be extended with state once directly after creation', () => { let target = {}; connect(() => ({test: 1}), target); From bbc4beeb844bf0e001115178438c42d97084b2b4 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Tue, 25 Aug 2015 23:13:41 -0400 Subject: [PATCH 05/14] Update README.md --- README.md | 94 ++++++++++++------------------------------------------- 1 file changed, 20 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 9421c81..8a6b425 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ For Angular 2 see [ng2-redux](https://github.com/wbuchwalter/ng2-redux). [![build status](https://img.shields.io/travis/wbuchwalter/ng-redux/master.svg?style=flat-square)](https://travis-ci.org/wbuchwalter/ng-redux) [![npm version](https://img.shields.io/npm/v/ng-redux.svg?style=flat-square)](https://www.npmjs.com/package/ng-redux) + +*ngRedux lets you easily connect your angular components with Redux.* + ## Installation ```js npm install --save ng-redux @@ -15,41 +18,8 @@ npm install --save ng-redux ## Overview -ngRedux lets you easily connect your angular components with Redux. -the API is straightforward: - -```JS -$ngRedux.connect(selector, callback); -``` - -Where `selector` is a function that takes Redux's entire store state as argument and returns an object that contains the slices of store state that your component is interested in. -e.g: -```JS -state => ({todos: state.todos}) -``` -Note: if you are not familiar with this syntax, go and check out the [MDN Guide on fat arrow functions (ES2015)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) - -If you haven't, check out [reselect](https://github.com/faassen/reselect), an awesome tool to create and combine selectors. - - -This returned object will be passed as argument to the callback provided whenever the state changes. -ngRedux checks for shallow equality of the state's selected slice whenever the Store is updated, and will call the callback only if there is a change. -**Important: It is assumed that you never mutate your states, if you do mutate them, ng-redux will not execute the callback properly.** -See [Redux's doc](http://gaearon.github.io/redux/docs/basics/Reducers.html) to understand why you should not mutate your states. - - -## Getting Started - #### Initialization -```JS -$ngReduxProvider.createStoreWith(reducer, [middlewares], storeEnhancer); -``` -#### Parameters: -* reducer (Function): A single reducer composed of all other reducers (create with redux.combineReducer) -* [middleware] (Array of Function or String): An array containing all the middleware that should be applied. Functions and strings are both valid members. String will be resolved via Angular, allowing you to use dependency injection in your middlewares. -* storeEnhancer: Optional function that will be used to create the store, in most cases you don't need that, see [Store Enhancer official doc](http://rackt.github.io/redux/docs/Glossary.html#store-enhancer) - ```JS import reducers from './reducers'; import {combineReducers} from 'redux'; @@ -86,53 +56,29 @@ class TodoLoaderController { } ``` -**Note: The callback provided to `connect` will be called once directly after creation to allow initialization of your component states** +## API +### `createStoreWith([reducer], [middlewares], [storeEnhancers])` +Creates the Redux store, and allow `connect()` to access it. -You can also grab multiple slices of the state by passing an array of selectors: - -```JS -constructor($ngRedux) { - this.todos = []; - this.users = []; - $ngRedux.connect(state => ({ - todos: state.todos, - users: state.users - }), - ({todos, users}) => { - this.todos = todos - this.users = users; - }); - } -``` +#### Arguments: +* [`reducer`] \(*Function*): A single reducer composed of all other reducers (create with redux.combineReducer) +* [`middlewares`] \(*Function[]*): Optional, An array containing all the middleware that should be applied. Functions and strings are both valid members. String will be resolved via Angular, allowing you to use dependency injection in your middlewares. +* [`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 doc](http://rackt.github.io/redux/docs/Glossary.html#store-enhancer) -#### Unsubscribing +### `connect([mapStateToTarget], [target])`` -You can close a connection like this: +Connects an Angular component to Redux. -```JS - -constructor($ngRedux) { - this.todos = []; - this.unsubscribe = $ngRedux.connect(state => ({todos: state.todos}), ({todos}) => this.todos = todos); - } +#### Arguments +* [`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`. +* [`target`] \(*Object*): A plain object, this will be use as a target by `mapStateToTarget`. Read the Remarks below about the implication of passing `$scope` to `connect`. -destroy() { - this.unsubscribe(); -} - -``` - - -#### Accessing Redux's store methods -All of redux's store methods (i.e. `dispatch`, `subscribe` and `getState`) are exposed by $ngRedux and can be accessed directly. For example: - -```JS -redux.bindActionCreators(actionCreator, $ngRedux.dispatch); -``` -**Note:** If you choose to use `subscribe` directly, be sure to [unsubscribe](#unsubscribing) when your current scope is $destroyed. +#### Returns +(*Function*): A function that unsubscribes the change listener. -### Example: -An example can be found here (in TypeScript): [tsRedux](https://github.com/wbuchwalter/tsRedux/blob/master/src/components/regionLister.ts). +#### Remarks +If `$scope` is passed to `connect` as `target`, ngRedux will listen to the `$destroy` event and unsubscribe the change listener when it is triggered, you don't need to keep track of your subscribtions in this case. +If anything else than `$scope` is passed as target, the responsability to unsubscribe correctly is deferred to the user. From 4fe3a07270302e3aa848d574509e1fcf7b9f1111 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Tue, 25 Aug 2015 23:14:37 -0400 Subject: [PATCH 06/14] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a6b425..9c3891b 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,7 @@ angular.module('app', ['ngRedux']) class TodoLoaderController { constructor($ngRedux) { - this.todos = []; - $ngRedux.connect(state => ({todos: state.todos}), ({todos}) => this.todos = todos); + $ngRedux.connect(state => ({todos: state.todos}), this); } [...] From 8a76f1cd8aa2bf012e8d69bcf9f7c806b0371e04 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Tue, 25 Aug 2015 23:15:46 -0400 Subject: [PATCH 07/14] Update README.md --- README.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9c3891b..96335db 100644 --- a/README.md +++ b/README.md @@ -39,19 +39,14 @@ angular.module('app', ['ngRedux']) return { controllerAs: 'vm', controller: TodoLoaderController, - template: "
{{todo.text}}
", - - [...] + template: "
{{todo.text}}
" }; } class TodoLoaderController { - constructor($ngRedux) { $ngRedux.connect(state => ({todos: state.todos}), this); } - - [...] } ``` @@ -64,10 +59,10 @@ Creates the Redux store, and allow `connect()` to access it. #### Arguments: * [`reducer`] \(*Function*): A single reducer composed of all other reducers (create with redux.combineReducer) * [`middlewares`] \(*Function[]*): Optional, An array containing all the middleware that should be applied. Functions and strings are both valid members. String will be resolved via Angular, allowing you to use dependency injection in your middlewares. -* [`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 doc](http://rackt.github.io/redux/docs/Glossary.html#store-enhancer) +* [`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([mapStateToTarget], [target])`` +### `connect([mapStateToTarget], [target])` Connects an Angular component to Redux. From f7a13598b5edcadb64500443f7548647289bf110 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Tue, 25 Aug 2015 23:20:14 -0400 Subject: [PATCH 08/14] Update README.md --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 96335db..70d8a50 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,19 @@ For Angular 2 see [ng2-redux](https://github.com/wbuchwalter/ng2-redux). *ngRedux lets you easily connect your angular components with Redux.* + +## Table of Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [API](#api) + ## Installation ```js npm install --save ng-redux ``` -## Overview +## Quick Start #### Initialization @@ -76,3 +83,10 @@ Connects an Angular component to Redux. #### Remarks If `$scope` is passed to `connect` as `target`, ngRedux will listen to the `$destroy` event and unsubscribe the change listener when it is triggered, you don't need to keep track of your subscribtions in this case. If anything else than `$scope` is passed as target, the responsability to unsubscribe correctly is deferred to the user. + +## 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: + +```JS +redux.bindActionCreators(actionCreator, $ngRedux.dispatch); +``` From 2ee6b0fbc5b55f6b8c218e632fd3642faad2034a Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Tue, 25 Aug 2015 23:41:05 -0400 Subject: [PATCH 09/14] Update README.md --- README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 70d8a50..93ac2fb 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ For Angular 2 see [ng2-redux](https://github.com/wbuchwalter/ng2-redux). - [Installation](#installation) - [Quick Start](#quick-start) - [API](#api) +- [Using DevTools](#using-devtools) ## Installation ```js @@ -84,9 +85,58 @@ Connects an Angular component to Redux. If `$scope` is passed to `connect` as `target`, ngRedux will listen to the `$destroy` event and unsubscribe the change listener when it is triggered, you don't need to keep track of your subscribtions in this case. If anything else than `$scope` is passed as target, the responsability to unsubscribe correctly is deferred to the user. -## Store API +### 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: ```JS redux.bindActionCreators(actionCreator, $ngRedux.dispatch); ``` + + +## Using DevTools +In order to use Redux DevTools with your angular app, you need to install [react](https://www.npmjs.com/package/react), [react-redux](https://www.npmjs.com/package/react-redux) and [redux-devtools](https://www.npmjs.com/package/redux-devtools) as development dependencies. + +```JS +[...] +import { devTools, persistState } from 'redux-devtools'; +import { DevTools, DebugPanel, LogMonitor } from 'redux-devtools/lib/react'; +import React, { Component } from 'react'; + +angular.module('app', ['ngRedux']) + .config(($ngReduxProvider) => { + $ngReduxProvider.createStoreWith(rootReducer, [thunk], [devTools()]); + }) + .run(($ngRedux, $rootScope) => { + React.render( + , + document.getElementById('devTools') + ); + + //To reflect state changes when disabling/enabling actions via the monitor + //there is probably a smarter way to achieve that + $ngRedux.subscribe(_ => { + setTimeout($rootScope.$apply, 100); + }); + }); + + class App extends Component { + render() { + return ( +
+ + + +
+ ); + } +} +``` + +```HTML + +
+ [...] +
+
+ +``` From 9d68e56c7a609fcb71b07426eb6e2de01484b952 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Tue, 25 Aug 2015 23:42:32 -0400 Subject: [PATCH 10/14] Removed unused imports --- examples/counter/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/counter/index.js b/examples/counter/index.js index a29d383..8bc9e72 100644 --- a/examples/counter/index.js +++ b/examples/counter/index.js @@ -6,7 +6,6 @@ import counter from './components/counter'; import { devTools, persistState } from 'redux-devtools'; import { DevTools, DebugPanel, LogMonitor } from 'redux-devtools/lib/react'; import React, { Component } from 'react'; -import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; angular.module('counter', ['ngRedux']) .config(($ngReduxProvider) => { From f1065ef80d71bfb6ead2383a37cca02a5c337b17 Mon Sep 17 00:00:00 2001 From: wbuchwalter Date: Wed, 26 Aug 2015 13:12:53 -0400 Subject: [PATCH 11/14] Enforced $scope + use lodash --- src/components/connector.js | 28 +++++++-------------- src/components/ngRedux.js | 6 ++--- src/utils/isFunction.js | 3 --- src/utils/isPlainObject.js | 25 ------------------ test/components/connector.spec.js | 42 ++++++++++++------------------- test/utils/isFunction.spec.js | 14 ----------- 6 files changed, 28 insertions(+), 90 deletions(-) delete mode 100644 src/utils/isFunction.js delete mode 100644 src/utils/isPlainObject.js delete mode 100644 test/utils/isFunction.spec.js diff --git a/src/components/connector.js b/src/components/connector.js index 7e92034..8d0467b 100644 --- a/src/components/connector.js +++ b/src/components/connector.js @@ -1,44 +1,34 @@ -import isFunction from '../utils/isFunction'; -import isPlainObject from '../utils/isPlainObject'; import shallowEqual from '../utils/shallowEqual'; import invariant from 'invariant'; -import _ from 'lodash' +import _ from 'lodash'; export default function Connector(store, $injector) { - return (selector, target) => { + return (selector, scope) => { - invariant( - isPlainObject(target), - 'The target parameter passed to connect must be a plain object. Instead received %s.', - typeof target - ); + invariant(scope && _.isFunction(scope.$on) && _.isFunction(scope.$destroy), 'The scope parameter passed to connect must be an instance of $scope.'); //Initial update let slice = getStateSlice(store.getState(), selector); - target = _.assign(target, slice); + _.assign(scope, slice); let unsubscribe = store.subscribe(() => { let nextSlice = getStateSlice(store.getState(), selector); if (!shallowEqual(slice, nextSlice)) { - target = _.assign(target, nextSlice); slice = nextSlice; + _.assign(scope, slice); } }); - if(isFunction(target.$destroy)) { - target.$on('$destroy', () => { - unsubscribe(); - }); - } - - return unsubscribe; + scope.$on('$destroy', () => { + unsubscribe(); + }); } } function getStateSlice(state, selector) { let slice = selector(state); invariant( - isPlainObject(slice), + _.isPlainObject(slice), '`selector` must return an object. Instead received %s.', slice ); diff --git a/src/components/ngRedux.js b/src/components/ngRedux.js index 2ca327b..57f4977 100644 --- a/src/components/ngRedux.js +++ b/src/components/ngRedux.js @@ -1,8 +1,8 @@ import Connector from './connector'; import invariant from 'invariant'; -import isFunction from '../utils/isFunction'; import {createStore, applyMiddleware, compose} from 'redux'; import digestMiddleware from './digestMiddleware'; +import _ from 'lodash'; export default function ngReduxProvider() { let _reducer = undefined; @@ -11,13 +11,13 @@ export default function ngReduxProvider() { this.createStoreWith = (reducer, middlewares, storeEnhancers) => { invariant( - isFunction(reducer), + _.isFunction(reducer), 'The reducer parameter passed to createStoreWith must be a Function. Instead received %s.', typeof reducer ); invariant( - !storeEnhancers || Array.isArray(storeEnhancers), + !storeEnhancers || _.isArray(storeEnhancers), 'The storeEnhancers parameter passed to createStoreWith must be an Array. Instead received %s.', typeof storeEnhancers ); diff --git a/src/utils/isFunction.js b/src/utils/isFunction.js deleted file mode 100644 index 7f1a970..0000000 --- a/src/utils/isFunction.js +++ /dev/null @@ -1,3 +0,0 @@ - export default function isFunction(x) { - return typeof x === 'function' - } diff --git a/src/utils/isPlainObject.js b/src/utils/isPlainObject.js deleted file mode 100644 index fafbe14..0000000 --- a/src/utils/isPlainObject.js +++ /dev/null @@ -1,25 +0,0 @@ -const fnToString = (fn) => Function.prototype.toString.call(fn); - -/** - * @param {any} obj The object to inspect. - * @returns {boolean} True if the argument appears to be a plain object. - */ -export default function isPlainObject(obj) { - if (!obj || typeof obj !== 'object') { - return false; - } - - const proto = typeof obj.constructor === 'function' ? - Object.getPrototypeOf(obj) : - Object.prototype; - - if (proto === null) { - return true; - } - - const constructor = proto.constructor; - - return typeof constructor === 'function' - && constructor instanceof constructor - && fnToString(constructor) === fnToString(Object); -} \ No newline at end of file diff --git a/test/components/connector.spec.js b/test/components/connector.spec.js index d2a1f23..984f734 100644 --- a/test/components/connector.spec.js +++ b/test/components/connector.spec.js @@ -5,42 +5,43 @@ import Connector from '../../src/components/connector'; describe('Connector', () => { let store; let connect; + let scopeStub; beforeEach(() => { store = createStore((state, action) => ({ - foo: 'bar', - baz: action.payload, - anotherState: 12, - childObject: {child: true} + foo: 'bar', + baz: action.payload, + anotherState: 12, + childObject: {child: true} })); + scopeStub = { $on: () => {}, $destroy: () => {}}; connect = Connector(store); }); - it('Should throw when not passed a plain object as target', () => { + it('Should throw when not passed a $scope object as target', () => { expect(connect.bind(connect, () => ({}), () => {})).toThrow(); expect(connect.bind(connect, () => ({}), 15)).toThrow(); expect(connect.bind(connect, () => ({}), undefined)).toThrow(); - expect(connect.bind(connect, () => ({}), {})).toNotThrow(); + expect(connect.bind(connect, () => ({}), {})).toThrow(); + + expect(connect.bind(connect, () => ({}), scopeStub)).toNotThrow(); }); it('Should throw when selector does not return a plain object as target', () => { - expect(connect.bind(connect, state => state.foo, {})).toThrow(); + expect(connect.bind(connect, state => state.foo, scopeStub)).toThrow(); }); - it('target should be extended with state once directly after creation', () => { - let target = {}; - connect(() => ({test: 1}), target); - expect(target).toEqual({test: 1}); + connect(() => ({vm : {test: 1}}), scopeStub); + expect(scopeStub.vm).toEqual({test: 1}); }); it('Should update the target passed to connect when the store updates', () => { - let target = {}; - connect(state => state, target); + connect(state => state, scopeStub); store.dispatch({type: 'ACTION', payload: 0}); - expect(target.baz).toBe(0); + expect(scopeStub.baz).toBe(0); store.dispatch({type: 'ACTION', payload: 1}); - expect(target.baz).toBe(1); + expect(scopeStub.baz).toBe(1); }); //does that still makes sense? @@ -53,15 +54,4 @@ describe('Connector', () => { store.dispatch({type: 'ACTION', payload: 1}); expect(counter).toBe(3); });*/ - - - it('Should return an unsubscribing function', () => { - let target = {}; - let unsubscribe = connect(state => state, target); - store.dispatch({type: 'ACTION', payload: 1}); - expect(target.baz).toBe(1); - unsubscribe(); - store.dispatch({type: 'ACTION', payload: 2}); - expect(target.baz).toBe(1); - }); }); diff --git a/test/utils/isFunction.spec.js b/test/utils/isFunction.spec.js deleted file mode 100644 index d39180d..0000000 --- a/test/utils/isFunction.spec.js +++ /dev/null @@ -1,14 +0,0 @@ -import expect from 'expect'; -import isFunction from '../../src/utils/isFunction'; - -describe('isFunction', () => { - it('should return true only if function', () => { - expect(isFunction('')).toBe(false); - expect(isFunction(undefined)).toBe(false); - expect(isFunction(null)).toBe(false); - expect(isFunction()).toBe(false); - expect(isFunction({a: 1})).toBe(false); - - expect(isFunction(() => {})).toBe(true); - }) -}); \ No newline at end of file From e3167be3153c9347b96e4568f695c0a7a9ecbfc7 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Wed, 26 Aug 2015 22:13:27 -0400 Subject: [PATCH 12/14] Added mapDispatchToScope + propertyKey --- examples/counter/components/counter.js | 16 ++-- examples/counter/devTools.js | 30 ++++++ examples/counter/index.js | 32 +------ examples/counter/webpack.config.js | 4 +- src/components/connector.js | 62 ++++++++---- src/utils/wrapActionCreators.js | 5 + test/components/connector.spec.js | 127 ++++++++++++++++--------- test/utils/wrapActionCreators.js | 31 ++++++ 8 files changed, 205 insertions(+), 102 deletions(-) create mode 100644 examples/counter/devTools.js create mode 100644 src/utils/wrapActionCreators.js create mode 100644 test/utils/wrapActionCreators.js diff --git a/examples/counter/components/counter.js b/examples/counter/components/counter.js index 6214d2e..1e9e4f3 100644 --- a/examples/counter/components/counter.js +++ b/examples/counter/components/counter.js @@ -13,14 +13,14 @@ export default function counter() { class CounterController { - constructor($ngRedux) { - $ngRedux.connect(state => ({counter: state.counter}), this); - - let {increment, decrement, incrementIfOdd, incrementAsync} = bindActionCreators(CounterActions, $ngRedux.dispatch); - this.increment = increment; - this.decrement = decrement; - this.incrementIfOdd = incrementIfOdd; - this.incrementAsync = incrementAsync; + constructor($ngRedux, $scope) { + $ngRedux.connect($scope, this.mapStateToScope, CounterActions, 'vm'); } + // Which part of the Redux global state does our component want to receive on $scope? + mapStateToScope(state) { + return { + counter: state.counter + }; + } } \ No newline at end of file diff --git a/examples/counter/devTools.js b/examples/counter/devTools.js new file mode 100644 index 0000000..892a090 --- /dev/null +++ b/examples/counter/devTools.js @@ -0,0 +1,30 @@ +import { persistState } from 'redux-devtools'; +import { DevTools, DebugPanel, LogMonitor } from 'redux-devtools/lib/react'; +import React, { Component } from 'react'; + +angular.module('counter') + .run(($ngRedux, $rootScope) => { + React.render( + , + document.getElementById('devTools') + ); + //Hack to reflect state changes when disabling/enabling actions via the monitor + $ngRedux.subscribe(_ => { + setTimeout($rootScope.$apply, 100); + }); + }); + + +class App extends Component { + render() { + return ( +
+ + + +
+ ); + } +} + + diff --git a/examples/counter/index.js b/examples/counter/index.js index 8bc9e72..0bcbed0 100644 --- a/examples/counter/index.js +++ b/examples/counter/index.js @@ -3,38 +3,10 @@ import 'ng-redux'; import rootReducer from './reducers'; import thunk from 'redux-thunk'; import counter from './components/counter'; -import { devTools, persistState } from 'redux-devtools'; -import { DevTools, DebugPanel, LogMonitor } from 'redux-devtools/lib/react'; -import React, { Component } from 'react'; +import { devTools } from 'redux-devtools'; angular.module('counter', ['ngRedux']) .config(($ngReduxProvider) => { $ngReduxProvider.createStoreWith(rootReducer, [thunk], [devTools()]); }) - .directive('ngrCounter', counter) -//------- DevTools specific code ---- - .run(($ngRedux, $rootScope) => { - React.render( - , - document.getElementById('devTools') - ); - //Hack to reflect state changes when disabling/enabling actions via the monitor - $ngRedux.subscribe(_ => { - setTimeout($rootScope.$apply, 100); - }); - }); - - -class App extends Component { - render() { - return ( -
- - - -
- ); - } -} - - + .directive('ngrCounter', counter); diff --git a/examples/counter/webpack.config.js b/examples/counter/webpack.config.js index 46ec43e..278d13c 100644 --- a/examples/counter/webpack.config.js +++ b/examples/counter/webpack.config.js @@ -5,7 +5,9 @@ var HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { entry: [ 'webpack/hot/dev-server', - './index.js' + './index.js', + //Remove the following line to remove devTools + './devTools.js' ], output: { path: path.join(__dirname, 'dist'), diff --git a/src/components/connector.js b/src/components/connector.js index 8d0467b..af9aef6 100644 --- a/src/components/connector.js +++ b/src/components/connector.js @@ -1,36 +1,64 @@ import shallowEqual from '../utils/shallowEqual'; +import wrapActionCreators from '../utils/wrapActionCreators'; import invariant from 'invariant'; import _ from 'lodash'; -export default function Connector(store, $injector) { - return (selector, scope) => { +export default function Connector(store) { + return (scope, mapStateToScope, mapDispatchToScope = {}, propertyKey) => { - invariant(scope && _.isFunction(scope.$on) && _.isFunction(scope.$destroy), 'The scope parameter passed to connect must be an instance of $scope.'); + 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 + ); + invariant( + _.isPlainObject(mapDispatchToScope) || _.isFunction(mapDispatchToScope), + 'mapDispatchToScope must be a plain Object or a Function. Instead received $s.', mapDispatchToScope + ); + + let slice = getStateSlice(store.getState(), mapStateToScope); + let target = propertyKey ? scope[propertyKey] : scope; + if(!target) { + target = scope[propertyKey] = {}; + } + + const finalMapDispatchToScope = _.isPlainObject(mapDispatchToScope) ? + wrapActionCreators(mapDispatchToScope) : + mapDispatchToScope; //Initial update - let slice = getStateSlice(store.getState(), selector); - _.assign(scope, slice); + _.assign(target, slice, finalMapDispatchToScope(store.dispatch)); - let unsubscribe = store.subscribe(() => { - let nextSlice = getStateSlice(store.getState(), selector); + subscribe(scope, store, () => { + const nextSlice = getStateSlice(store.getState(), mapStateToScope); if (!shallowEqual(slice, nextSlice)) { slice = nextSlice; - _.assign(scope, slice); + _.assign(target, slice); } }); - - scope.$on('$destroy', () => { - unsubscribe(); - }); + } } -function getStateSlice(state, selector) { - let slice = selector(state); +function subscribe(scope, store, callback) { + const unsubscribe = store.subscribe(callback); + + scope.$on('$destroy', () => { + unsubscribe(); + }); +} + +function getStateSlice(state, mapStateToScope) { + const slice = mapStateToScope(state); + invariant( _.isPlainObject(slice), - '`selector` must return an object. Instead received %s.', + '`mapStateToScope` must return an object. Instead received %s.', slice - ); + ); + return slice; -} +} \ No newline at end of file diff --git a/src/utils/wrapActionCreators.js b/src/utils/wrapActionCreators.js new file mode 100644 index 0000000..983fbe6 --- /dev/null +++ b/src/utils/wrapActionCreators.js @@ -0,0 +1,5 @@ +import { bindActionCreators } from 'redux'; + +export default function wrapActionCreators(actionCreators) { + return dispatch => bindActionCreators(actionCreators, dispatch); +} diff --git a/test/components/connector.spec.js b/test/components/connector.spec.js index 984f734..5f98c60 100644 --- a/test/components/connector.spec.js +++ b/test/components/connector.spec.js @@ -1,57 +1,92 @@ import expect from 'expect'; -import {createStore} from 'redux'; +import { createStore } from 'redux'; import Connector from '../../src/components/connector'; +import _ from 'lodash'; describe('Connector', () => { - let store; - let connect; - let scopeStub; - - beforeEach(() => { - store = createStore((state, action) => ({ - foo: 'bar', - baz: action.payload, - anotherState: 12, - childObject: {child: true} - })); - scopeStub = { $on: () => {}, $destroy: () => {}}; - connect = Connector(store); - }); + let store; + let connect; + let scopeStub; - it('Should throw when not passed a $scope object as target', () => { - expect(connect.bind(connect, () => ({}), () => {})).toThrow(); - expect(connect.bind(connect, () => ({}), 15)).toThrow(); - expect(connect.bind(connect, () => ({}), undefined)).toThrow(); - expect(connect.bind(connect, () => ({}), {})).toThrow(); + beforeEach(() => { + store = createStore((state, action) => ({ + foo: 'bar', + baz: action.payload + })); + scopeStub = { + $on: () => { }, + $destroy: () => { } + }; + connect = Connector(store); + }); - expect(connect.bind(connect, () => ({}), scopeStub)).toNotThrow(); - }); + 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 selector does not return a plain object as target', () => { - expect(connect.bind(connect, state => state.foo, scopeStub)).toThrow(); + expect(connect.bind(connect, scopeStub, () => ({}))).toNotThrow(); }); - it('target should be extended with state once directly after creation', () => { - connect(() => ({vm : {test: 1}}), scopeStub); - expect(scopeStub.vm).toEqual({test: 1}); - }); + it('Should throw when selector does not return a plain object as target', () => { + expect(connect.bind(connect, scopeStub, state => state.foo)).toThrow(); + }); - it('Should update the target passed to connect when the store updates', () => { - connect(state => state, scopeStub); - store.dispatch({type: 'ACTION', payload: 0}); - expect(scopeStub.baz).toBe(0); - store.dispatch({type: 'ACTION', payload: 1}); - expect(scopeStub.baz).toBe(1); - }); + it('Should extend scope with selected state once directly after creation', () => { + connect( + scopeStub, + () => ({ + vm: { test: 1 } + })); + + expect(scopeStub.vm).toEqual({ test: 1 }); + }); + + it('Should extend scope[propertyKey] if propertyKey is passed', () => { + connect( + scopeStub, + () => ({ test: 1 }), + () => { }, + 'vm' + ); + + expect(scopeStub.vm).toEqual({ test: 1 }); + }); + + it('Should update the scope passed to connect when the store updates', () => { + connect(scopeStub, state => state); + store.dispatch({ type: 'ACTION', payload: 0 }); + expect(scopeStub.baz).toBe(0); + store.dispatch({ type: 'ACTION', payload: 1 }); + expect(scopeStub.baz).toBe(1); + }); + + it('Should prevent unnecessary updates when state does not change (shallowly)', () => { + connect(scopeStub, state => state); + store.dispatch({ type: 'ACTION', payload: 5 }); + + expect(scopeStub.baz).toBe(5); + + scopeStub.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); + + }); + + it('Should extend scope with actionCreators', () => { + connect(scopeStub, () => ({}), { ac1: () => { }, ac2: () => { } }); + expect(_.isFunction(scopeStub.ac1)).toBe(true); + expect(_.isFunction(scopeStub.ac2)).toBe(true); + }); + + it('Should provide dispatch to mapDispatchToScope when receiving a Function', () => { + let receivedDispatch; + connect(scopeStub, () => ({}), dispatch => { receivedDispatch = dispatch }); + expect(receivedDispatch).toBe(store.dispatch); + }); - //does that still makes sense? - /*it('Should prevent unnecessary updates when state does not change (shallowly)', () => { - let counter = 0; - let callback = () => counter++; - connect(state => ({baz: state.baz}), callback); - store.dispatch({type: 'ACTION', payload: 0}); - store.dispatch({type: 'ACTION', payload: 0}); - store.dispatch({type: 'ACTION', payload: 1}); - expect(counter).toBe(3); - });*/ -}); +}); \ No newline at end of file diff --git a/test/utils/wrapActionCreators.js b/test/utils/wrapActionCreators.js new file mode 100644 index 0000000..11fbad9 --- /dev/null +++ b/test/utils/wrapActionCreators.js @@ -0,0 +1,31 @@ +import expect from 'expect'; +import wrapActionCreators from '../../src/utils/wrapActionCreators'; + +describe('Utils', () => { + describe('wrapActionCreators', () => { + it('should return a function that wraps argument in a call to bindActionCreators', () => { + + function dispatch(action) { + return { + dispatched: action + }; + } + + const actionResult = {an: 'action'}; + + const actionCreators = { + action: () => actionResult + }; + + const wrapped = wrapActionCreators(actionCreators); + expect(wrapped).toBeA(Function); + expect(() => wrapped(dispatch)).toNotThrow(); + expect(() => wrapped().action()).toThrow(); + + const bound = wrapped(dispatch); + expect(bound.action).toNotThrow(); + expect(bound.action().dispatched).toBe(actionResult); + + }); + }); +}); \ No newline at end of file From 86de39a77ee92bbecbbf44cb9e1c274e1c4e89b2 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Wed, 26 Aug 2015 22:37:24 -0400 Subject: [PATCH 13/14] Update README.md --- README.md | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index acb0451..08e63b9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ For Angular 2 see [ng2-redux](https://github.com/wbuchwalter/ng2-redux). -**Warning: The API will be subject to breaking changes until `1.0.0` is released. You can follow progress on the `bc-1.0.0` branch** +**Warning: The API will be subject to breaking changes until `1.0.0` is released.** [![build status](https://img.shields.io/travis/wbuchwalter/ng-redux/master.svg?style=flat-square)](https://travis-ci.org/wbuchwalter/ng-redux) [![npm version](https://img.shields.io/npm/v/ng-redux.svg?style=flat-square)](https://www.npmjs.com/package/ng-redux) @@ -20,6 +20,8 @@ For Angular 2 see [ng2-redux](https://github.com/wbuchwalter/ng2-redux). - [Using DevTools](#using-devtools) ## Installation + +**The current npm version is outdated, and will be updated once 1.0.0 is finished** ```js npm install --save ng-redux ``` @@ -30,7 +32,7 @@ npm install --save ng-redux ```JS import reducers from './reducers'; -import {combineReducers} from 'redux'; +import { combineReducers } from 'redux'; import loggingMiddleware from './loggingMiddleware'; import 'ng-redux'; @@ -42,18 +44,21 @@ angular.module('app', ['ngRedux']) ``` #### Usage +*Note: this sample is using the ControllerAs syntax, usage is slightly different without ControllerAs, see API section for more details* + ```JS - export default function todoLoader() { - return { - controllerAs: 'vm', - controller: TodoLoaderController, - template: "
{{todo.text}}
" - }; -} +import * as CounterActions from '../actions/counter'; -class TodoLoaderController { - constructor($ngRedux) { - $ngRedux.connect(state => ({todos: state.todos}), this); +class CounterController { + constructor($ngRedux, $scope) { + $ngRedux.connect($scope, this.mapStateToScope, CounterActions, 'vm'); + } + + // Which part of the Redux global state does our component want to receive on $scope? + mapStateToScope(state) { + return { + counter: state.counter + }; } } ``` @@ -70,20 +75,18 @@ 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([mapStateToTarget], [target])` +### `connect([scope], [mapStateToScope], [mapDispatchToScope], [propertyKey])` Connects an Angular component to Redux. #### Arguments -* [`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`. -* [`target`] \(*Object*): A plain object, this will be use as a target by `mapStateToTarget`. Read the Remarks below about the implication of passing `$scope` to `connect`. - -#### Returns -(*Function*): A function that unsubscribes the change listener. +* [`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.). +* [`propertyKey`] \(*string*): If provided, `mapStateToScope` and `mapDispatchToScope` will merge onto `$scope[propertyKey]`. This is needed especially when using the `ControllerAs` syntax: in this case you should provide the same value than the value provided to controllerAs (e.g: `'vm'`). When not using `ControllerAs` syntax, you are free to omit this argument, everything will be merged directly onto `$scope`. #### Remarks -If `$scope` is passed to `connect` as `target`, ngRedux will listen to the `$destroy` event and unsubscribe the change listener when it is triggered, you don't need to keep track of your subscribtions in this case. -If anything else than `$scope` is passed as target, the responsability to unsubscribe correctly is deferred to the user. +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. ### 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: @@ -92,6 +95,8 @@ All of redux's store methods (i.e. `dispatch`, `subscribe` and `getState`) are e redux.bindActionCreators(actionCreator, $ngRedux.dispatch); ``` +This means that you are free to use Redux basic API in advanced cases where `connect`'s API would not fill your needs. + ## Using DevTools In order to use Redux DevTools with your angular app, you need to install [react](https://www.npmjs.com/package/react), [react-redux](https://www.npmjs.com/package/react-redux) and [redux-devtools](https://www.npmjs.com/package/redux-devtools) as development dependencies. From 89c3f82f739e1b31a4545d2ee5a897cbaf5da710 Mon Sep 17 00:00:00 2001 From: William Buchwalter Date: Wed, 26 Aug 2015 22:43:16 -0400 Subject: [PATCH 14/14] Update README.md --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 08e63b9..1e1e1c4 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ import * as CounterActions from '../actions/counter'; class CounterController { constructor($ngRedux, $scope) { + /* ngRedux will merge the requested state's slice and actions onto the $scope, + you don't need to redefine them in your controller */ + $ngRedux.connect($scope, this.mapStateToScope, CounterActions, 'vm'); } @@ -63,6 +66,16 @@ class CounterController { } ``` +```HTML +
+

Clicked: {{vm.counter}} times

+ + + + +
+``` + ## API ### `createStoreWith([reducer], [middlewares], [storeEnhancers])` @@ -92,7 +105,10 @@ As `$scope` is passed to `connect`, ngRedux will listen to the `$destroy` event All of redux's store methods (i.e. `dispatch`, `subscribe` and `getState`) are exposed by $ngRedux and can be accessed directly. For example: ```JS -redux.bindActionCreators(actionCreator, $ngRedux.dispatch); +$ngRedux.subscribe(() => { + let state = $ngRedux.getState(); + //... +}) ``` This means that you are free to use Redux basic API in advanced cases where `connect`'s API would not fill your needs.