Skip to content

Commit

Permalink
Allow nested reducer map in handleActions (#218)
Browse files Browse the repository at this point in the history
Close #215
  • Loading branch information
zcei authored and yangmillstheory committed Jul 2, 2017
1 parent c9ea804 commit a71aa96
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 16 deletions.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,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)`

Expand All @@ -204,14 +204,16 @@ 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';
```

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).)
Expand Down
167 changes: 167 additions & 0 deletions src/__tests__/handleActions-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
});
});
});
8 changes: 5 additions & 3 deletions src/handleActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
);
Expand Down
7 changes: 7 additions & 0 deletions src/hasGeneratorInterface.js
Original file line number Diff line number Diff line change
@@ -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);
}
30 changes: 19 additions & 11 deletions src/namespaceActions.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -54,4 +62,4 @@ function unflattenActionCreators(flatActionCreators, namespace = defaultNamespac
return nestedActionCreators;
}

export { flattenActionMap, unflattenActionCreators, defaultNamespace };
export { flattenActionMap, flattenReducerMap, unflattenActionCreators, defaultNamespace };

0 comments on commit a71aa96

Please sign in to comment.