From a8c6addeac93fda8d3e6faf42a606530505beb0b Mon Sep 17 00:00:00 2001 From: Stephan Schneider Date: Sun, 25 Jun 2017 03:33:19 +0200 Subject: [PATCH 1/7] Allow nested reducer map in handleActions --- src/__tests__/handleActions-test.js | 56 +++++++++++++++++++++++++++++ src/handleActions.js | 6 ++-- src/namespaceActions.js | 36 +++++++++++++------ 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/__tests__/handleActions-test.js b/src/__tests__/handleActions-test.js index 6f75801d..2fc70030 100644 --- a/src/__tests__/handleActions-test.js +++ b/src/__tests__/handleActions-test.js @@ -260,4 +260,60 @@ describe('handleActions', () => { ).to.throw(Error, 'Expected handlers to be an plain object.'); }); }); + + it('should work with nested reducerMap', () => { + const { + app: { + counter: { + increment, + decrement + }, + notify + } + } = createActions({ + APP: { + COUNTER: { + INCREMENT: [ + amount => ({ amount }), + amount => ({ key: 'value', amount }) + ], + DECREMENT: amount => ({ amount: -amount }) + }, + NOTIFY: [ + (username, message) => ({ message: `${username}: ${message}` }), + (username, message) => ({ username, message }) + ] + } + }); + + // note: we should be using combineReducers in production, but this is just a test + const reducer = handleActions({ + [combineActions(increment, decrement)]: ({ counter, message }, { payload: { amount } }) => ({ + counter: counter + amount, + message + }), + + APP: { + NOTIFY: { + next: ({ counter, message }, { payload }) => ({ + counter, + message: `${message}---${payload.message}` + }) + } + } + }, { counter: 0, message: '' }); + + expect(reducer({ counter: 3, message: 'hello' }, increment(2))).to.deep.equal({ + counter: 5, + message: 'hello' + }); + expect(reducer({ counter: 10, message: 'hello' }, decrement(3))).to.deep.equal({ + counter: 7, + message: 'hello' + }); + expect(reducer({ counter: 10, message: 'hello' }, notify('me', 'goodbye'))).to.deep.equal({ + counter: 10, + message: 'hello---me: goodbye' + }); + }); }); diff --git a/src/handleActions.js b/src/handleActions.js index 2d7fbf3f..217205fd 100644 --- a/src/handleActions.js +++ b/src/handleActions.js @@ -3,16 +3,18 @@ import reduceReducers from 'reduce-reducers'; import invariant from 'invariant'; import handleAction from './handleAction'; import ownKeys from './ownKeys'; +import { flattenReducerMap } from './namespaceActions'; export default function handleActions(handlers, defaultState) { invariant( isPlainObject(handlers), 'Expected handlers to be an plain object.' ); - const reducers = ownKeys(handlers).map(type => + const flatHandlers = flattenReducerMap(handlers); + const reducers = ownKeys(flatHandlers).map(type => handleAction( type, - handlers[type], + flatHandlers[type], defaultState ) ); diff --git a/src/namespaceActions.js b/src/namespaceActions.js index 3dc7ac39..4570abba 100644 --- a/src/namespaceActions.js +++ b/src/namespaceActions.js @@ -1,12 +1,22 @@ import camelCase from './camelCase'; +import ownKeys from './ownKeys'; import isPlainObject from 'lodash/isPlainObject'; +import includes from 'lodash/includes'; const defaultNamespace = '/'; -function flattenActionMap( - actionMap, +function hasGeneratorInterface(handler) { + const generatorFnNames = ['next', 'throw']; + const keys = Object.getOwnPropertyNames(handler); + const onlyInterfaceFns = keys.every((fnName) => includes(generatorFnNames, fnName)); + return (keys.length && keys.length <= 2 && onlyInterfaceFns); +} + +const flattenBy = (predicate) => +function flatten( + map, namespace = defaultNamespace, - partialFlatActionMap = {}, + partialFlatMap = {}, partialFlatActionType = '' ) { function connectNamespace(type) { @@ -15,18 +25,22 @@ function flattenActionMap( : type; } - Object.getOwnPropertyNames(actionMap).forEach(type => { + ownKeys(map).forEach(type => { const nextNamespace = connectNamespace(type); - const actionMapValue = actionMap[type]; + const mapValue = map[type]; - if (!isPlainObject(actionMapValue)) { - partialFlatActionMap[nextNamespace] = actionMap[type]; + if (!predicate(mapValue)) { + partialFlatMap[nextNamespace] = map[type]; } else { - flattenActionMap(actionMap[type], namespace, partialFlatActionMap, nextNamespace); + flatten(map[type], namespace, partialFlatMap, nextNamespace); } }); - return partialFlatActionMap; -} + + return partialFlatMap; +}; + +const flattenActionMap = flattenBy((node) => isPlainObject(node)); +const flattenReducerMap = flattenBy((node) => isPlainObject(node) && !hasGeneratorInterface(node)); function unflattenActionCreators(flatActionCreators, namespace = defaultNamespace) { function unflatten( @@ -54,4 +68,4 @@ function unflattenActionCreators(flatActionCreators, namespace = defaultNamespac return nestedActionCreators; } -export { flattenActionMap, unflattenActionCreators, defaultNamespace }; +export { flattenActionMap, flattenReducerMap, unflattenActionCreators, defaultNamespace }; From e3df1aed9ac783b032e46c659d3152203069acb9 Mon Sep 17 00:00:00 2001 From: Stephan Schneider Date: Mon, 26 Jun 2017 12:58:25 +0200 Subject: [PATCH 2/7] Update documentation for handleActions --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index f8d80b09..31281df5 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,9 @@ import { handleActions } from 'redux-actions'; Creates multiple reducers using `handleAction()` and combines them into a single reducer that handles multiple actions. Accepts a map where the keys are passed as the first parameter to `handleAction()` (the action type), and the values are passed as the second parameter (either a reducer or reducer map). The map must not be empty. +If `reducerMap` has a recursive structure, its leaves are used as reducers, and the action type for each leaf is the combined path to that leaf. +If a nodes only children are `next()` and `throw()`, the node will be treated as a reducer leaf. + The second parameter `defaultState` is required, and is used when `undefined` is passed to the reducer. (Internally, `handleActions()` works by applying multiple reducers in sequence using [reduce-reducers](https://github.com/acdlite/reduce-reducers).) From 880bef8d86b53d0227f3a010a0443ba88cd513af Mon Sep 17 00:00:00 2001 From: Victor Alvarez Date: Sat, 1 Jul 2017 10:37:11 -0700 Subject: [PATCH 3/7] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 31281df5..6f219063 100644 --- a/README.md +++ b/README.md @@ -207,8 +207,7 @@ import { handleActions } from 'redux-actions'; Creates multiple reducers using `handleAction()` and combines them into a single reducer that handles multiple actions. Accepts a map where the keys are passed as the first parameter to `handleAction()` (the action type), and the values are passed as the second parameter (either a reducer or reducer map). The map must not be empty. -If `reducerMap` has a recursive structure, its leaves are used as reducers, and the action type for each leaf is the combined path to that leaf. -If a nodes only children are `next()` and `throw()`, the node will be treated as a reducer leaf. +If `reducerMap` has a recursive structure, its leaves are used as reducers, and the action type for each leaf is the path to that leaf. If a node's only children are `next()` and `throw()`, the node will be treated as a reducer. The second parameter `defaultState` is required, and is used when `undefined` is passed to the reducer. From 75e51aa132758cc7598886e6cc4885ad4034ae6f Mon Sep 17 00:00:00 2001 From: Stephan Schneider Date: Sun, 2 Jul 2017 20:33:34 +0200 Subject: [PATCH 4/7] fixup! Allow nested reducer map in handleActions --- src/__tests__/handleActions-test.js | 43 +++++++++++++++++++++++++++++ src/handleActions.js | 6 ++-- src/hasGeneratorInterface.js | 7 +++++ src/namespaceActions.js | 11 ++------ 4 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 src/hasGeneratorInterface.js diff --git a/src/__tests__/handleActions-test.js b/src/__tests__/handleActions-test.js index 2fc70030..12a2fd06 100644 --- a/src/__tests__/handleActions-test.js +++ b/src/__tests__/handleActions-test.js @@ -298,6 +298,10 @@ describe('handleActions', () => { next: ({ counter, message }, { payload }) => ({ counter, message: `${message}---${payload.message}` + }), + throw: ({ counter, message }, { payload }) => ({ + counter: 0, + message: `${message}-x-${payload.message}` }) } } @@ -315,5 +319,44 @@ describe('handleActions', () => { counter: 10, message: 'hello---me: goodbye' }); + + const error = new Error('no notification'); + expect(reducer({ counter: 10, message: 'hello' }, notify(error))).to.deep.equal({ + counter: 0, + message: 'hello-x-no notification' + }); + }); + + it('should work with nested reducerMap and identity handlers', () => { + const noop = createAction('APP/NOOP'); + const increment = createAction('APP/INCREMENT'); + + const reducer = handleActions({ + APP: { + NOOP: undefined, + INCREMENT: { + next: (state, { payload }) => ({ + ...state, + counter: state.counter + payload + }), + throw: null + } + } + }, { counter: 0, message: '' }); + + expect(reducer({ counter: 3, message: 'hello' }, noop('anything'))).to.deep.equal({ + counter: 3, + message: 'hello' + }); + expect(reducer({ counter: 3, message: 'hello' }, increment(2))).to.deep.equal({ + counter: 5, + message: 'hello' + }); + + const error = new Error('cannot increment by Infinity'); + expect(reducer({ counter: 3, message: 'hello' }, increment(error))).to.deep.equal({ + counter: 3, + message: 'hello' + }); }); }); diff --git a/src/handleActions.js b/src/handleActions.js index 217205fd..face9035 100644 --- a/src/handleActions.js +++ b/src/handleActions.js @@ -10,11 +10,11 @@ export default function handleActions(handlers, defaultState) { isPlainObject(handlers), 'Expected handlers to be an plain object.' ); - const flatHandlers = flattenReducerMap(handlers); - const reducers = ownKeys(flatHandlers).map(type => + const flattenedReducerMap = flattenReducerMap(handlers); + const reducers = ownKeys(flattenedReducerMap).map(type => handleAction( type, - flatHandlers[type], + flattenedReducerMap[type], defaultState ) ); diff --git a/src/hasGeneratorInterface.js b/src/hasGeneratorInterface.js new file mode 100644 index 00000000..f26d94c6 --- /dev/null +++ b/src/hasGeneratorInterface.js @@ -0,0 +1,7 @@ +import ownKeys from './ownKeys'; + +export default function hasGeneratorInterface(handler) { + const keys = ownKeys(handler); + const hasOnlyInterfaceNames = keys.every((ownKey) => ownKey === 'next' || ownKey === 'throw'); + return (keys.length && keys.length <= 2 && hasOnlyInterfaceNames); +} diff --git a/src/namespaceActions.js b/src/namespaceActions.js index 4570abba..3ac0ab78 100644 --- a/src/namespaceActions.js +++ b/src/namespaceActions.js @@ -1,17 +1,10 @@ import camelCase from './camelCase'; import ownKeys from './ownKeys'; +import hasGeneratorInterface from './hasGeneratorInterface'; import isPlainObject from 'lodash/isPlainObject'; -import includes from 'lodash/includes'; const defaultNamespace = '/'; -function hasGeneratorInterface(handler) { - const generatorFnNames = ['next', 'throw']; - const keys = Object.getOwnPropertyNames(handler); - const onlyInterfaceFns = keys.every((fnName) => includes(generatorFnNames, fnName)); - return (keys.length && keys.length <= 2 && onlyInterfaceFns); -} - const flattenBy = (predicate) => function flatten( map, @@ -39,7 +32,7 @@ function flatten( return partialFlatMap; }; -const flattenActionMap = flattenBy((node) => isPlainObject(node)); +const flattenActionMap = flattenBy(isPlainObject); const flattenReducerMap = flattenBy((node) => isPlainObject(node) && !hasGeneratorInterface(node)); function unflattenActionCreators(flatActionCreators, namespace = defaultNamespace) { From 9ed6baf76659b0283297ea0bf34ff46c850f34cb Mon Sep 17 00:00:00 2001 From: Victor Alvarez Date: Sun, 2 Jul 2017 12:27:40 -0700 Subject: [PATCH 5/7] minor naming --- src/namespaceActions.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/namespaceActions.js b/src/namespaceActions.js index 3ac0ab78..25944c9c 100644 --- a/src/namespaceActions.js +++ b/src/namespaceActions.js @@ -5,8 +5,7 @@ import isPlainObject from 'lodash/isPlainObject'; const defaultNamespace = '/'; -const flattenBy = (predicate) => -function flatten( +const flattenWhenNode = predicate => function flatten( map, namespace = defaultNamespace, partialFlatMap = {}, @@ -32,8 +31,8 @@ function flatten( return partialFlatMap; }; -const flattenActionMap = flattenBy(isPlainObject); -const flattenReducerMap = flattenBy((node) => isPlainObject(node) && !hasGeneratorInterface(node)); +const flattenActionMap = flattenWhenNode(isPlainObject); +const flattenReducerMap = flattenWhenNode(node => isPlainObject(node) && !hasGeneratorInterface(node)); function unflattenActionCreators(flatActionCreators, namespace = defaultNamespace) { function unflatten( From b92c77868e248153e2142932623a1730fbab8782 Mon Sep 17 00:00:00 2001 From: Stephan Schneider Date: Sun, 2 Jul 2017 22:27:32 +0200 Subject: [PATCH 6/7] Allow namespace in handleActions --- src/__tests__/handleActions-test.js | 68 +++++++++++++++++++++++++++++ src/handleActions.js | 4 +- src/namespaceActions.js | 4 +- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/__tests__/handleActions-test.js b/src/__tests__/handleActions-test.js index 12a2fd06..5f02d8f6 100644 --- a/src/__tests__/handleActions-test.js +++ b/src/__tests__/handleActions-test.js @@ -327,6 +327,74 @@ describe('handleActions', () => { }); }); + it('should work with nested reducerMap and namespace', () => { + const { + app: { + counter: { + increment, + decrement + }, + notify + } + } = createActions({ + APP: { + COUNTER: { + INCREMENT: [ + amount => ({ amount }), + amount => ({ key: 'value', amount }) + ], + DECREMENT: amount => ({ amount: -amount }) + }, + NOTIFY: [ + (username, message) => ({ message: `${username}: ${message}` }), + (username, message) => ({ username, message }) + ] + } + }, { namespace: ':' }); + + // note: we should be using combineReducers in production, but this is just a test + const reducer = handleActions({ + [combineActions(increment, decrement)]: ({ counter, message }, { payload: { amount } }) => ({ + counter: counter + amount, + message + }), + + APP: { + NOTIFY: { + next: ({ counter, message }, { payload }) => ({ + counter, + message: `${message}---${payload.message}` + }), + throw: ({ counter, message }, { payload }) => ({ + counter: 0, + message: `${message}-x-${payload.message}` + }) + } + } + }, { counter: 0, message: '' }, { namespace: ':' }); + + expect(String(increment)).to.equal('APP:COUNTER:INCREMENT'); + + expect(reducer({ counter: 3, message: 'hello' }, increment(2))).to.deep.equal({ + counter: 5, + message: 'hello' + }); + expect(reducer({ counter: 10, message: 'hello' }, decrement(3))).to.deep.equal({ + counter: 7, + message: 'hello' + }); + expect(reducer({ counter: 10, message: 'hello' }, notify('me', 'goodbye'))).to.deep.equal({ + counter: 10, + message: 'hello---me: goodbye' + }); + + const error = new Error('no notification'); + expect(reducer({ counter: 10, message: 'hello' }, notify(error))).to.deep.equal({ + counter: 0, + message: 'hello-x-no notification' + }); + }); + it('should work with nested reducerMap and identity handlers', () => { const noop = createAction('APP/NOOP'); const increment = createAction('APP/INCREMENT'); diff --git a/src/handleActions.js b/src/handleActions.js index face9035..142dd186 100644 --- a/src/handleActions.js +++ b/src/handleActions.js @@ -5,12 +5,12 @@ import handleAction from './handleAction'; import ownKeys from './ownKeys'; import { flattenReducerMap } from './namespaceActions'; -export default function handleActions(handlers, defaultState) { +export default function handleActions(handlers, defaultState, { namespace } = {}) { invariant( isPlainObject(handlers), 'Expected handlers to be an plain object.' ); - const flattenedReducerMap = flattenReducerMap(handlers); + const flattenedReducerMap = flattenReducerMap(handlers, namespace); const reducers = ownKeys(flattenedReducerMap).map(type => handleAction( type, diff --git a/src/namespaceActions.js b/src/namespaceActions.js index 25944c9c..810811ea 100644 --- a/src/namespaceActions.js +++ b/src/namespaceActions.js @@ -32,7 +32,9 @@ const flattenWhenNode = predicate => function flatten( }; const flattenActionMap = flattenWhenNode(isPlainObject); -const flattenReducerMap = flattenWhenNode(node => isPlainObject(node) && !hasGeneratorInterface(node)); +const flattenReducerMap = flattenWhenNode( + node => isPlainObject(node) && !hasGeneratorInterface(node) +); function unflattenActionCreators(flatActionCreators, namespace = defaultNamespace) { function unflatten( From 8299102c3c8ea872b300347f6e08049f9c830464 Mon Sep 17 00:00:00 2001 From: Victor Alvarez Date: Sun, 2 Jul 2017 14:28:01 -0700 Subject: [PATCH 7/7] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6f219063..2b86e56c 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ expect(actionCreators.app.notify('yangmillstheory', 'Hello World')).to.deep.equa meta: { username: 'yangmillstheory', message: 'Hello World' } }); ``` -When using this form, you can pass an object with key `namespace` as the last positional argument, instead of the default `/`. +When using this form, you can pass an object with key `namespace` as the last positional argument (the default is `/`). ### `handleAction(type, reducer | reducerMap = Identity, defaultState)` @@ -199,7 +199,7 @@ If the reducer argument (`reducer | reducerMap`) is `undefined`, then the identi The third parameter `defaultState` is required, and is used when `undefined` is passed to the reducer. -### `handleActions(reducerMap, defaultState)` +### `handleActions(reducerMap, defaultState, )` ```js import { handleActions } from 'redux-actions'; @@ -207,7 +207,7 @@ import { handleActions } from 'redux-actions'; Creates multiple reducers using `handleAction()` and combines them into a single reducer that handles multiple actions. Accepts a map where the keys are passed as the first parameter to `handleAction()` (the action type), and the values are passed as the second parameter (either a reducer or reducer map). The map must not be empty. -If `reducerMap` has a recursive structure, its leaves are used as reducers, and the action type for each leaf is the path to that leaf. If a node's only children are `next()` and `throw()`, the node will be treated as a reducer. +If `reducerMap` has a recursive structure, its leaves are used as reducers, and the action type for each leaf is the path to that leaf. If a node's only children are `next()` and `throw()`, the node will be treated as a reducer. If the leaf is `undefined` or `null`, the identity function is used as the reducer. Otherwise, the leaf should be the reducer function. When using this form, you can pass an object with key `namespace` as the last positional argument (the default is `/`). The second parameter `defaultState` is required, and is used when `undefined` is passed to the reducer.