Skip to content

Allow nested reducer map in handleActions #218

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`

Expand All @@ -199,14 +199,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 };