Skip to content

Commit 1ff986f

Browse files
alex-okrushkobrandonroberts
authored andcommitted
feat(effects): add mapToAction operator (#1822)
Closes #1224
1 parent 873bc36 commit 1ff986f

File tree

11 files changed

+724
-5
lines changed

11 files changed

+724
-5
lines changed

modules/effects/spec/actions.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,4 +287,38 @@ describe('Actions', function() {
287287
dispatcher.next(multiply({ by: 2 }));
288288
dispatcher.complete();
289289
});
290+
291+
it('should support more than 5 actions', () => {
292+
const log = createAction('logarithm');
293+
const expected = [
294+
divide.type,
295+
ADD,
296+
square.type,
297+
SUBTRACT,
298+
multiply.type,
299+
log.type,
300+
];
301+
302+
actions$
303+
.pipe(
304+
// Mixing all of them, more than 5. It still works, but we loose the type info
305+
ofType(divide, ADD, square, SUBTRACT, multiply, log),
306+
map(update => update.type),
307+
toArray()
308+
)
309+
.subscribe({
310+
next(actual) {
311+
expect(actual).toEqual(expected);
312+
},
313+
});
314+
315+
// Actions under test, in specific order
316+
dispatcher.next(divide({ by: 1 }));
317+
dispatcher.next({ type: ADD });
318+
dispatcher.next(square());
319+
dispatcher.next({ type: SUBTRACT });
320+
dispatcher.next(multiply({ by: 2 }));
321+
dispatcher.next(log());
322+
dispatcher.complete();
323+
});
290324
});
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { cold, hot } from 'jasmine-marbles';
2+
import { mergeMap, take, switchMap } from 'rxjs/operators';
3+
import { createAction, Action } from '@ngrx/store';
4+
import { mapToAction } from '@ngrx/effects';
5+
import { throwError, Subject } from 'rxjs';
6+
7+
describe('mapToAction operator', () => {
8+
/**
9+
* Helper function that converts a string (or array of letters) into the
10+
* object, each property of which is a letter that is assigned an Action
11+
* with type as that letter.
12+
*
13+
* e.g. genActions('abc') would result in
14+
* {
15+
* 'a': {type: 'a'},
16+
* 'b': {type: 'b'},
17+
* 'c': {type: 'c'},
18+
* }
19+
*/
20+
function genActions(marbles: string): { [marble: string]: Action } {
21+
return marbles.split('').reduce(
22+
(acc, marble) => {
23+
return {
24+
...acc,
25+
[marble]: createAction(marble)(),
26+
};
27+
},
28+
{} as { [marble: string]: Action }
29+
);
30+
}
31+
32+
it('should call project functon', () => {
33+
const sources$ = hot('-a-b', genActions('ab'));
34+
35+
const actual$ = new Subject();
36+
const project = jasmine
37+
.createSpy('project')
38+
.and.callFake((...args: [Action, number]) => {
39+
actual$.next(args);
40+
return cold('(v|)', genActions('v'));
41+
});
42+
const error = () => createAction('e')();
43+
44+
sources$.pipe(mapToAction(project, error)).subscribe();
45+
46+
expect(actual$).toBeObservable(
47+
cold(' -a-b', {
48+
a: [createAction('a')(), 0],
49+
b: [createAction('b')(), 1],
50+
})
51+
);
52+
});
53+
54+
it('should emit output action', () => {
55+
const sources$ = hot(' -a', genActions('a'));
56+
const project = () => cold('(v|)', genActions('v'));
57+
const error = () => createAction('e')();
58+
const expected$ = cold('-v', genActions('v'));
59+
60+
const output$ = sources$.pipe(mapToAction(project, error));
61+
62+
expect(output$).toBeObservable(expected$);
63+
});
64+
65+
it('should take any type of Observable as an Input', () => {
66+
const sources$ = hot(' -a', { a: 'a string' });
67+
const project = () => cold('(v|)', genActions('v'));
68+
const error = () => createAction('e')();
69+
const expected$ = cold('-v', genActions('v'));
70+
71+
const output$ = sources$.pipe(mapToAction(project, error));
72+
73+
expect(output$).toBeObservable(expected$);
74+
});
75+
76+
it('should emit output action with config passed', () => {
77+
const sources$ = hot(' -a', genActions('a'));
78+
// Completes
79+
const project = () => cold('(v|)', genActions('v'));
80+
const error = () => createAction('e')();
81+
// offset by source delay and doesn't complete
82+
const expected$ = cold('-v--', genActions('v'));
83+
84+
const output$ = sources$.pipe(mapToAction({ project, error }));
85+
86+
expect(output$).toBeObservable(expected$);
87+
});
88+
89+
it('should call the error callback when error in the project occurs', () => {
90+
const sources$ = hot(' -a', genActions('a'));
91+
const project = () => throwError('error');
92+
const error = () => createAction('e')();
93+
const expected$ = cold('-e', genActions('e'));
94+
95+
const output$ = sources$.pipe(mapToAction(project, error));
96+
97+
expect(output$).toBeObservable(expected$);
98+
});
99+
100+
it('should continue listen to the sources actions after error occurs', () => {
101+
const sources$ = hot('-a--b', genActions('ab'));
102+
const project = (action: Action) =>
103+
action.type === 'a' ? throwError('error') : cold('(v|)', genActions('v'));
104+
const error = () => createAction('e')();
105+
// error handler action is dispatched and next action with type b is also
106+
// handled
107+
const expected$ = cold('-e--v', genActions('ev'));
108+
109+
const output$ = sources$.pipe(mapToAction(project, error));
110+
111+
expect(output$).toBeObservable(expected$);
112+
});
113+
114+
it('should emit multiple output actions when project produces many actions', () => {
115+
const sources$ = hot(' -a', genActions('a'));
116+
const project = () => cold('v-w-x-(y|)', genActions('vwxy'));
117+
const error = () => createAction('e')();
118+
// offset by source delay and doesn't complete
119+
const expected$ = cold('-v-w-x-y--', genActions('vwxy'));
120+
121+
const output$ = sources$.pipe(mapToAction(project, error));
122+
123+
expect(output$).toBeObservable(expected$);
124+
});
125+
126+
it('should emit multiple output actions when project produces many actions with config passed', () => {
127+
const sources$ = hot(' -a', genActions('a'));
128+
const project = () => cold('v-w-x-(y|)', genActions('vwxy'));
129+
const error = () => createAction('e')();
130+
// offset by source delay
131+
const expected$ = cold('-v-w-x-y', genActions('vwxy'));
132+
133+
const output$ = sources$.pipe(mapToAction({ project, error }));
134+
135+
expect(output$).toBeObservable(expected$);
136+
});
137+
138+
it('should emit multiple output actions when source produces many actions', () => {
139+
const sources$ = hot(' -a--b', genActions('ab'));
140+
const project = () => cold('(v|)', genActions('v'));
141+
const error = () => createAction('e')();
142+
143+
const expected$ = cold('-v--v-', genActions('v'));
144+
145+
const output$ = sources$.pipe(mapToAction(project, error));
146+
147+
expect(output$).toBeObservable(expected$);
148+
});
149+
150+
it('should emit multiple output actions when source produces many actions with config passed', () => {
151+
const sources$ = hot(' -a--b', genActions('ab'));
152+
const project = () => cold('(v|)', genActions('v'));
153+
const error = () => createAction('e')();
154+
155+
const expected$ = cold('-v--v-', genActions('v'));
156+
157+
const output$ = sources$.pipe(mapToAction(project, error));
158+
159+
expect(output$).toBeObservable(expected$);
160+
});
161+
162+
it('should flatten projects with concatMap by default', () => {
163+
const sources$ = hot(' -a--b', genActions('ab'));
164+
const project = () => cold('v------(w|)', genActions('vw'));
165+
const error = () => createAction('e')();
166+
167+
// Even thought source produced actions one right after another, operator
168+
// wait for the project to complete before handling second source action.
169+
const expected$ = cold('-v------(wv)---w', genActions('vw'));
170+
171+
const output$ = sources$.pipe(mapToAction(project, error));
172+
173+
expect(output$).toBeObservable(expected$);
174+
});
175+
176+
it('should flatten projects with concatMap by default with config passed', () => {
177+
const sources$ = hot(' -a--b', genActions('ab'));
178+
const project = () => cold('v------(w|)', genActions('vw'));
179+
const error = () => createAction('e')();
180+
181+
// Even thought source produced actions one right after another, operator
182+
// wait for the project to complete before handling second source action.
183+
const expected$ = cold('-v------(wv)---w', genActions('vw'));
184+
185+
const output$ = sources$.pipe(mapToAction({ project, error }));
186+
187+
expect(output$).toBeObservable(expected$);
188+
});
189+
190+
it('should use provided flattening operator', () => {
191+
const sources$ = hot(' -a--b', genActions('ab'));
192+
const project = () => cold('v------(w|)', genActions('vw'));
193+
const error = () => createAction('e')();
194+
195+
// Merge map starts project streams in parallel
196+
const expected$ = cold('-v--v---w--w', genActions('vw'));
197+
198+
const output$ = sources$.pipe(
199+
mapToAction({ project, error, operator: mergeMap })
200+
);
201+
202+
expect(output$).toBeObservable(expected$);
203+
});
204+
205+
it('should use provided complete callback', () => {
206+
const sources$ = hot(' -a', genActions('a'));
207+
const project = () => cold('v-|', genActions('v'));
208+
const error = () => createAction('e')();
209+
const complete = () => createAction('c')();
210+
211+
// Completed is the last action
212+
const expected$ = cold('-v-c', genActions('vc'));
213+
214+
const output$ = sources$.pipe(mapToAction({ project, error, complete }));
215+
216+
expect(output$).toBeObservable(expected$);
217+
});
218+
219+
it('should pass number of observables that project emitted and input action to complete callback', () => {
220+
const sources$ = hot('-a', genActions('a'));
221+
const project = () => cold('v-w-|', genActions('v'));
222+
const error = () => createAction('e')();
223+
224+
const actual$ = new Subject();
225+
226+
const complete = jasmine
227+
.createSpy('complete')
228+
.and.callFake((...args: [number, Action]) => {
229+
actual$.next(args);
230+
return createAction('c')();
231+
});
232+
233+
sources$.pipe(mapToAction({ project, error, complete })).subscribe();
234+
235+
expect(actual$).toBeObservable(
236+
cold('-----a', {
237+
a: [2, createAction('a')()],
238+
})
239+
);
240+
});
241+
242+
it('should use provided unsubscribe callback', () => {
243+
const sources$ = hot(' -a-b', genActions('ab'));
244+
const project = () => cold('v-----w|', genActions('vw'));
245+
const error = () => createAction('e')();
246+
const unsubscribe = () => createAction('u')();
247+
248+
// switchMap causes unsubscription
249+
const expected$ = cold('-v-(uv)--w', genActions('vuw'));
250+
251+
const output$ = sources$.pipe(
252+
mapToAction({ project, error, unsubscribe, operator: switchMap })
253+
);
254+
255+
expect(output$).toBeObservable(expected$);
256+
});
257+
258+
it(
259+
'should pass number of observables that project emitted before' +
260+
' unsubscribing and prior input action to unsubsubscribe callback',
261+
() => {
262+
const sources$ = hot('-a-b', genActions('ab'));
263+
const project = () => cold('vw----v|', genActions('vw'));
264+
const error = () => createAction('e')();
265+
266+
const actual$ = new Subject();
267+
268+
const unsubscribe = jasmine
269+
.createSpy('unsubscribe')
270+
.and.callFake((...args: [number, Action]) => {
271+
actual$.next(args);
272+
return createAction('u')();
273+
});
274+
275+
sources$
276+
.pipe(mapToAction({ project, error, unsubscribe, operator: switchMap }))
277+
.subscribe();
278+
279+
expect(actual$).toBeObservable(
280+
cold('---a', {
281+
a: [2, createAction('a')()],
282+
})
283+
);
284+
}
285+
);
286+
});

modules/effects/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { EffectsModule } from './effects_module';
88
export { EffectSources } from './effect_sources';
99
export { EffectNotification } from './effect_notification';
1010
export { ROOT_EFFECTS_INIT } from './effects_root_module';
11+
export { mapToAction } from './map_to_action';
1112
export {
1213
OnIdentifyEffects,
1314
OnRunEffects,

0 commit comments

Comments
 (0)