diff --git a/README.md b/README.md index 88c404a..1e1e1c4 100644 --- a/README.md +++ b/README.md @@ -3,56 +3,36 @@ 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) -## Installation -```js -npm install --save ng-redux -``` -## Overview +*ngRedux lets you easily connect your angular components with Redux.* -ngRedux lets you easily connect your angular components with Redux. -the API is straightforward: -```JS -$ngRedux.connect(selector, callback); -``` +## Table of Contents -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. +- [Installation](#installation) +- [Quick Start](#quick-start) +- [API](#api) +- [Using DevTools](#using-devtools) +## Installation -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. - +**The current npm version is outdated, and will be updated once 1.0.0 is finished** +```js +npm install --save ng-redux +``` -## Getting Started +## Quick Start #### 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'; +import { combineReducers } from 'redux'; import loggingMiddleware from './loggingMiddleware'; import 'ng-redux'; @@ -64,75 +44,120 @@ angular.module('app', ['ngRedux']) ``` #### Usage -```JS - export default function todoLoader() { - return { - controllerAs: 'vm', - controller: TodoLoaderController, - template: "
{{todo.text}}
", - - [...] - }; -} +*Note: this sample is using the ControllerAs syntax, usage is slightly different without ControllerAs, see API section for more details* -class TodoLoaderController { - - constructor($ngRedux) { - this.todos = []; - $ngRedux.connect(state => ({todos: state.todos}), ({todos}) => this.todos = todos); +```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, + you don't need to redefine them in your controller */ + + $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 + }; + } } ``` -**Note: The callback provided to `connect` will be called once directly after creation to allow initialization of your component states** +```HTML +
+

Clicked: {{vm.counter}} times

+ + + + +
+``` +## API +### `createStoreWith([reducer], [middlewares], [storeEnhancers])` -You can also grab multiple slices of the state by passing an array of selectors: +Creates the Redux store, and allow `connect()` to access it. -```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 documentation.](http://rackt.github.io/redux/docs/Glossary.html#store-enhancer) -#### Unsubscribing +### `connect([scope], [mapStateToScope], [mapDispatchToScope], [propertyKey])` -You can close a connection like this: +Connects an Angular component to Redux. -```JS +#### 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.). +* [`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`. -constructor($ngRedux) { - this.todos = []; - this.unsubscribe = $ngRedux.connect(state => ({todos: state.todos}), ({todos}) => this.todos = todos); - } +#### 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. -destroy() { - this.unsubscribe(); -} +### 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 +$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. -#### 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: + +## 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 -redux.bindActionCreators(actionCreator, $ngRedux.dispatch); +[...] +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 ( +
+ + + +
+ ); + } +} ``` -**Note:** If you choose to use `subscribe` directly, be sure to [unsubscribe](#unsubscribing) when your current scope is $destroyed. -### Example: -An example can be found here (in TypeScript): [tsRedux](https://github.com/wbuchwalter/tsRedux/blob/master/src/components/regionLister.ts). +```HTML + +
+ [...] +
+
+ +``` 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..1e9e4f3 100644 --- a/examples/counter/components/counter.js +++ b/examples/counter/components/counter.js @@ -13,16 +13,14 @@ export default function counter() { class CounterController { - constructor($ngRedux) { - this.counter = 0; - $ngRedux.connect(state => ({ - counter: state.counter - }), - ({counter}) => this.counter = counter); + constructor($ngRedux, $scope) { + $ngRedux.connect($scope, this.mapStateToScope, CounterActions, 'vm'); + } - let {increment, decrement, incrementIfOdd} = bindActionCreators(CounterActions, $ngRedux.dispatch); - this.increment = increment; - this.decrement = decrement; - this.incrementIfOdd = incrementIfOdd; + // 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.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..0bcbed0 100644 --- a/examples/counter/index.js +++ b/examples/counter/index.js @@ -3,9 +3,10 @@ import 'ng-redux'; import rootReducer from './reducers'; import thunk from 'redux-thunk'; import counter from './components/counter'; +import { devTools } from 'redux-devtools'; angular.module('counter', ['ngRedux']) .config(($ngReduxProvider) => { - $ngReduxProvider.createStoreWith(rootReducer, [thunk]); + $ngReduxProvider.createStoreWith(rootReducer, [thunk], [devTools()]); }) .directive('ngrCounter', counter); 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/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/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 2df9e09..af9aef6 100644 --- a/src/components/connector.js +++ b/src/components/connector.js @@ -1,25 +1,64 @@ -import isFunction from '../utils/isFunction'; import shallowEqual from '../utils/shallowEqual'; +import wrapActionCreators from '../utils/wrapActionCreators'; import invariant from 'invariant'; +import _ from 'lodash'; export default function Connector(store) { - return (selector, callback) => { + return (scope, mapStateToScope, mapDispatchToScope = {}, propertyKey) => { + invariant( - isFunction(callback), - 'The callback parameter passed to connect must be a Function. Instead received %s.', - typeof callback - ); + 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 params = selector(store.getState()); - callback(params); - - return store.subscribe(() => { - let nextParams = selector(store.getState()); - if (!shallowEqual(params, nextParams)) { - callback(nextParams); - params = nextParams; + _.assign(target, slice, finalMapDispatchToScope(store.dispatch)); + + 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 getStateSlice(state, mapStateToScope) { + const slice = mapStateToScope(state); + + invariant( + _.isPlainObject(slice), + '`mapStateToScope` must return an object. Instead received %s.', + slice + ); + + return slice; +} \ No newline at end of file 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..57f4977 100644 --- a/src/components/ngRedux.js +++ b/src/components/ngRedux.js @@ -1,28 +1,29 @@ 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'; +import _ from 'lodash'; 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), + _.isFunction(reducer), 'The reducer parameter passed to createStoreWith must be a Function. Instead received %s.', typeof reducer ); invariant( - !storeEnhancer || isFunction(storeEnhancer), - 'The storeEnhancer parameter passed to createStoreWith must be a Function. Instead received %s.', - typeof storeEnhancer + !storeEnhancers || _.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,12 @@ export default function ngReduxProvider() { } } - store = applyMiddleware(...resolvedMiddleware)(_storeEnhancer)(_reducer); + 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); return { ...store, 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/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 72d8c96..5f98c60 100644 --- a/test/components/connector.spec.js +++ b/test/components/connector.spec.js @@ -1,77 +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 store; + let connect; + let scopeStub; - beforeEach(() => { - store = createStore((state, action) => ({ + beforeEach(() => { + store = createStore((state, action) => ({ foo: 'bar', - baz: action.payload, - anotherState: 12 - })); - connect = Connector(store); - }); + baz: action.payload + })); + scopeStub = { + $on: () => { }, + $destroy: () => { } + }; + 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 $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('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); + expect(connect.bind(connect, scopeStub, () => ({}))).toNotThrow(); }); - it('Should call the callback passed to connect when the store updates', () => { - let counter = 0; - let callback = () => counter++; - connect(state => state, callback); - store.dispatch({type: 'ACTION', payload: 0}); - store.dispatch({type: 'ACTION', payload: 1}); - expect(counter).toBe(3); - }); + it('Should throw when selector does not return a plain object as target', () => { + expect(connect.bind(connect, scopeStub, state => state.foo)).toThrow(); + }); - 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); - }); + it('Should extend scope with selected state once directly after creation', () => { + connect( + scopeStub, + () => ({ + vm: { test: 1 } + })); - it('Should pass the selected state as argument to the callback', () => { - connect(state => ({ - myFoo: state.foo - }), newState => { - expect(newState).toEqual({myFoo: 'bar'}); - }); - }); + expect(scopeStub.vm).toEqual({ test: 1 }); + }); - 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 extend scope[propertyKey] if propertyKey is passed', () => { + connect( + scopeStub, + () => ({ test: 1 }), + () => { }, + 'vm' + ); - it('Should return an unsubscribing function', () => { - let counter = 0; - let callback = () => counter++; - let unsubscribe = connect(state => state, callback); - store.dispatch({type: 'ACTION', payload: 0}); - unsubscribe(); - store.dispatch({type: 'ACTION', payload: 2}); - expect(counter).toBe(2); - }); -}); + 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); + }); + +}); \ No newline at end of file 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 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