Skip to content

Commit a71aa96

Browse files
zceiyangmillstheory
authored andcommitted
Allow nested reducer map in handleActions (#218)
Close #215
1 parent c9ea804 commit a71aa96

File tree

5 files changed

+202
-16
lines changed

5 files changed

+202
-16
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ expect(actionCreators.app.notify('yangmillstheory', 'Hello World')).to.deep.equa
177177
meta: { username: 'yangmillstheory', message: 'Hello World' }
178178
});
179179
```
180-
When using this form, you can pass an object with key `namespace` as the last positional argument, instead of the default `/`.
180+
When using this form, you can pass an object with key `namespace` as the last positional argument (the default is `/`).
181181

182182
### `handleAction(type, reducer | reducerMap = Identity, defaultState)`
183183

@@ -204,14 +204,16 @@ If the reducer argument (`reducer | reducerMap`) is `undefined`, then the identi
204204

205205
The third parameter `defaultState` is required, and is used when `undefined` is passed to the reducer.
206206

207-
### `handleActions(reducerMap, defaultState)`
207+
### `handleActions(reducerMap, defaultState, )`
208208

209209
```js
210210
import { handleActions } from 'redux-actions';
211211
```
212212

213213
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.
214214

215+
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 `/`).
216+
215217
The second parameter `defaultState` is required, and is used when `undefined` is passed to the reducer.
216218

217219
(Internally, `handleActions()` works by applying multiple reducers in sequence using [reduce-reducers](https://github.com/acdlite/reduce-reducers).)

src/__tests__/handleActions-test.js

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,4 +260,171 @@ describe('handleActions', () => {
260260
).to.throw(Error, 'Expected handlers to be an plain object.');
261261
});
262262
});
263+
264+
it('should work with nested reducerMap', () => {
265+
const {
266+
app: {
267+
counter: {
268+
increment,
269+
decrement
270+
},
271+
notify
272+
}
273+
} = createActions({
274+
APP: {
275+
COUNTER: {
276+
INCREMENT: [
277+
amount => ({ amount }),
278+
amount => ({ key: 'value', amount })
279+
],
280+
DECREMENT: amount => ({ amount: -amount })
281+
},
282+
NOTIFY: [
283+
(username, message) => ({ message: `${username}: ${message}` }),
284+
(username, message) => ({ username, message })
285+
]
286+
}
287+
});
288+
289+
// note: we should be using combineReducers in production, but this is just a test
290+
const reducer = handleActions({
291+
[combineActions(increment, decrement)]: ({ counter, message }, { payload: { amount } }) => ({
292+
counter: counter + amount,
293+
message
294+
}),
295+
296+
APP: {
297+
NOTIFY: {
298+
next: ({ counter, message }, { payload }) => ({
299+
counter,
300+
message: `${message}---${payload.message}`
301+
}),
302+
throw: ({ counter, message }, { payload }) => ({
303+
counter: 0,
304+
message: `${message}-x-${payload.message}`
305+
})
306+
}
307+
}
308+
}, { counter: 0, message: '' });
309+
310+
expect(reducer({ counter: 3, message: 'hello' }, increment(2))).to.deep.equal({
311+
counter: 5,
312+
message: 'hello'
313+
});
314+
expect(reducer({ counter: 10, message: 'hello' }, decrement(3))).to.deep.equal({
315+
counter: 7,
316+
message: 'hello'
317+
});
318+
expect(reducer({ counter: 10, message: 'hello' }, notify('me', 'goodbye'))).to.deep.equal({
319+
counter: 10,
320+
message: 'hello---me: goodbye'
321+
});
322+
323+
const error = new Error('no notification');
324+
expect(reducer({ counter: 10, message: 'hello' }, notify(error))).to.deep.equal({
325+
counter: 0,
326+
message: 'hello-x-no notification'
327+
});
328+
});
329+
330+
it('should work with nested reducerMap and namespace', () => {
331+
const {
332+
app: {
333+
counter: {
334+
increment,
335+
decrement
336+
},
337+
notify
338+
}
339+
} = createActions({
340+
APP: {
341+
COUNTER: {
342+
INCREMENT: [
343+
amount => ({ amount }),
344+
amount => ({ key: 'value', amount })
345+
],
346+
DECREMENT: amount => ({ amount: -amount })
347+
},
348+
NOTIFY: [
349+
(username, message) => ({ message: `${username}: ${message}` }),
350+
(username, message) => ({ username, message })
351+
]
352+
}
353+
}, { namespace: ':' });
354+
355+
// note: we should be using combineReducers in production, but this is just a test
356+
const reducer = handleActions({
357+
[combineActions(increment, decrement)]: ({ counter, message }, { payload: { amount } }) => ({
358+
counter: counter + amount,
359+
message
360+
}),
361+
362+
APP: {
363+
NOTIFY: {
364+
next: ({ counter, message }, { payload }) => ({
365+
counter,
366+
message: `${message}---${payload.message}`
367+
}),
368+
throw: ({ counter, message }, { payload }) => ({
369+
counter: 0,
370+
message: `${message}-x-${payload.message}`
371+
})
372+
}
373+
}
374+
}, { counter: 0, message: '' }, { namespace: ':' });
375+
376+
expect(String(increment)).to.equal('APP:COUNTER:INCREMENT');
377+
378+
expect(reducer({ counter: 3, message: 'hello' }, increment(2))).to.deep.equal({
379+
counter: 5,
380+
message: 'hello'
381+
});
382+
expect(reducer({ counter: 10, message: 'hello' }, decrement(3))).to.deep.equal({
383+
counter: 7,
384+
message: 'hello'
385+
});
386+
expect(reducer({ counter: 10, message: 'hello' }, notify('me', 'goodbye'))).to.deep.equal({
387+
counter: 10,
388+
message: 'hello---me: goodbye'
389+
});
390+
391+
const error = new Error('no notification');
392+
expect(reducer({ counter: 10, message: 'hello' }, notify(error))).to.deep.equal({
393+
counter: 0,
394+
message: 'hello-x-no notification'
395+
});
396+
});
397+
398+
it('should work with nested reducerMap and identity handlers', () => {
399+
const noop = createAction('APP/NOOP');
400+
const increment = createAction('APP/INCREMENT');
401+
402+
const reducer = handleActions({
403+
APP: {
404+
NOOP: undefined,
405+
INCREMENT: {
406+
next: (state, { payload }) => ({
407+
...state,
408+
counter: state.counter + payload
409+
}),
410+
throw: null
411+
}
412+
}
413+
}, { counter: 0, message: '' });
414+
415+
expect(reducer({ counter: 3, message: 'hello' }, noop('anything'))).to.deep.equal({
416+
counter: 3,
417+
message: 'hello'
418+
});
419+
expect(reducer({ counter: 3, message: 'hello' }, increment(2))).to.deep.equal({
420+
counter: 5,
421+
message: 'hello'
422+
});
423+
424+
const error = new Error('cannot increment by Infinity');
425+
expect(reducer({ counter: 3, message: 'hello' }, increment(error))).to.deep.equal({
426+
counter: 3,
427+
message: 'hello'
428+
});
429+
});
263430
});

src/handleActions.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@ import reduceReducers from 'reduce-reducers';
33
import invariant from 'invariant';
44
import handleAction from './handleAction';
55
import ownKeys from './ownKeys';
6+
import { flattenReducerMap } from './namespaceActions';
67

7-
export default function handleActions(handlers, defaultState) {
8+
export default function handleActions(handlers, defaultState, { namespace } = {}) {
89
invariant(
910
isPlainObject(handlers),
1011
'Expected handlers to be an plain object.'
1112
);
12-
const reducers = ownKeys(handlers).map(type =>
13+
const flattenedReducerMap = flattenReducerMap(handlers, namespace);
14+
const reducers = ownKeys(flattenedReducerMap).map(type =>
1315
handleAction(
1416
type,
15-
handlers[type],
17+
flattenedReducerMap[type],
1618
defaultState
1719
)
1820
);

src/hasGeneratorInterface.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import ownKeys from './ownKeys';
2+
3+
export default function hasGeneratorInterface(handler) {
4+
const keys = ownKeys(handler);
5+
const hasOnlyInterfaceNames = keys.every((ownKey) => ownKey === 'next' || ownKey === 'throw');
6+
return (keys.length && keys.length <= 2 && hasOnlyInterfaceNames);
7+
}

src/namespaceActions.js

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import camelCase from './camelCase';
2+
import ownKeys from './ownKeys';
3+
import hasGeneratorInterface from './hasGeneratorInterface';
24
import isPlainObject from 'lodash/isPlainObject';
35

46
const defaultNamespace = '/';
57

6-
function flattenActionMap(
7-
actionMap,
8+
const flattenWhenNode = predicate => function flatten(
9+
map,
810
namespace = defaultNamespace,
9-
partialFlatActionMap = {},
11+
partialFlatMap = {},
1012
partialFlatActionType = ''
1113
) {
1214
function connectNamespace(type) {
@@ -15,18 +17,24 @@ function flattenActionMap(
1517
: type;
1618
}
1719

18-
Object.getOwnPropertyNames(actionMap).forEach(type => {
20+
ownKeys(map).forEach(type => {
1921
const nextNamespace = connectNamespace(type);
20-
const actionMapValue = actionMap[type];
22+
const mapValue = map[type];
2123

22-
if (!isPlainObject(actionMapValue)) {
23-
partialFlatActionMap[nextNamespace] = actionMap[type];
24+
if (!predicate(mapValue)) {
25+
partialFlatMap[nextNamespace] = map[type];
2426
} else {
25-
flattenActionMap(actionMap[type], namespace, partialFlatActionMap, nextNamespace);
27+
flatten(map[type], namespace, partialFlatMap, nextNamespace);
2628
}
2729
});
28-
return partialFlatActionMap;
29-
}
30+
31+
return partialFlatMap;
32+
};
33+
34+
const flattenActionMap = flattenWhenNode(isPlainObject);
35+
const flattenReducerMap = flattenWhenNode(
36+
node => isPlainObject(node) && !hasGeneratorInterface(node)
37+
);
3038

3139
function unflattenActionCreators(flatActionCreators, namespace = defaultNamespace) {
3240
function unflatten(
@@ -54,4 +62,4 @@ function unflattenActionCreators(flatActionCreators, namespace = defaultNamespac
5462
return nestedActionCreators;
5563
}
5664

57-
export { flattenActionMap, unflattenActionCreators, defaultNamespace };
65+
export { flattenActionMap, flattenReducerMap, unflattenActionCreators, defaultNamespace };

0 commit comments

Comments
 (0)