diff --git a/README.md b/README.md index f8d80b09..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,6 +207,8 @@ 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 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. (Internally, `handleActions()` works by applying multiple reducers in sequence using [reduce-reducers](https://github.com/acdlite/reduce-reducers).) diff --git a/src/__tests__/handleActions-test.js b/src/__tests__/handleActions-test.js index 6f75801d..5f02d8f6 100644 --- a/src/__tests__/handleActions-test.js +++ b/src/__tests__/handleActions-test.js @@ -260,4 +260,171 @@ 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}` + }), + throw: ({ counter, message }, { payload }) => ({ + counter: 0, + message: `${message}-x-${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' + }); + + 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 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'); + + 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 2d7fbf3f..142dd186 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) { +export default function handleActions(handlers, defaultState, { namespace } = {}) { invariant( isPlainObject(handlers), 'Expected handlers to be an plain object.' ); - const reducers = ownKeys(handlers).map(type => + const flattenedReducerMap = flattenReducerMap(handlers, namespace); + const reducers = ownKeys(flattenedReducerMap).map(type => handleAction( type, - handlers[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 3dc7ac39..810811ea 100644 --- a/src/namespaceActions.js +++ b/src/namespaceActions.js @@ -1,12 +1,14 @@ import camelCase from './camelCase'; +import ownKeys from './ownKeys'; +import hasGeneratorInterface from './hasGeneratorInterface'; import isPlainObject from 'lodash/isPlainObject'; const defaultNamespace = '/'; -function flattenActionMap( - actionMap, +const flattenWhenNode = predicate => function flatten( + map, namespace = defaultNamespace, - partialFlatActionMap = {}, + partialFlatMap = {}, partialFlatActionType = '' ) { function connectNamespace(type) { @@ -15,18 +17,24 @@ 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 = flattenWhenNode(isPlainObject); +const flattenReducerMap = flattenWhenNode( + node => isPlainObject(node) && !hasGeneratorInterface(node) +); function unflattenActionCreators(flatActionCreators, namespace = defaultNamespace) { function unflatten( @@ -54,4 +62,4 @@ function unflattenActionCreators(flatActionCreators, namespace = defaultNamespac return nestedActionCreators; } -export { flattenActionMap, unflattenActionCreators, defaultNamespace }; +export { flattenActionMap, flattenReducerMap, unflattenActionCreators, defaultNamespace };