diff --git a/modules/effects/spec/actions.spec.ts b/modules/effects/spec/actions.spec.ts index 045c9961a5..e17d6d8246 100644 --- a/modules/effects/spec/actions.spec.ts +++ b/modules/effects/spec/actions.spec.ts @@ -4,7 +4,7 @@ import 'rxjs/add/operator/map'; import 'rxjs/add/observable/of'; import { ReflectiveInjector } from '@angular/core'; import { Action, StoreModule, ScannedActionsSubject, ActionsSubject } from '@ngrx/store'; -import { Actions } from '../src/actions'; +import { Actions } from '../'; describe('Actions', function() { diff --git a/modules/effects/spec/effect_sources.spec.ts b/modules/effects/spec/effect_sources.spec.ts new file mode 100644 index 0000000000..1f9627f445 --- /dev/null +++ b/modules/effects/spec/effect_sources.spec.ts @@ -0,0 +1,111 @@ +import 'rxjs/add/operator/concat'; +import 'rxjs/add/operator/catch'; +import { cold } from 'jasmine-marbles'; +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; +import { _throw } from 'rxjs/observable/throw'; +import { never } from 'rxjs/observable/never'; +import { empty } from 'rxjs/observable/empty'; +import { TestBed } from '@angular/core/testing'; +import { ErrorReporter } from '../src/error_reporter'; +import { CONSOLE } from '../src/tokens'; +import { Effect, EffectSources } from '../'; + +describe('EffectSources', () => { + let mockErrorReporter: ErrorReporter; + let effectSources: EffectSources; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + EffectSources, + ErrorReporter, + { + provide: CONSOLE, + useValue: console, + }, + ], + }); + + mockErrorReporter = TestBed.get(ErrorReporter); + effectSources = TestBed.get(EffectSources); + + spyOn(mockErrorReporter, 'report'); + }); + + it('should have an "addEffects" method to push new source instances', () => { + const effectSource = {}; + spyOn(effectSources, 'next'); + + effectSources.addEffects(effectSource); + + expect(effectSources.next).toHaveBeenCalledWith(effectSource); + }); + + describe('toActions() Operator', () => { + const a = { type: 'From Source A' }; + const b = { type: 'From Source B' }; + const c = { type: 'From Source C that completes' }; + const d = { not: 'a valid action' }; + const error = new Error('An Error'); + + class SourceA { + @Effect() a$ = alwaysOf(a); + } + + class SourceB { + @Effect() b$ = alwaysOf(b); + } + + class SourceC { + @Effect() c$ = of(c); + } + + class SourceD { + @Effect() d$ = alwaysOf(d); + } + + class SourceE { + @Effect() e$ = _throw(error); + } + + it('should resolve effects from instances', () => { + const sources$ = cold('--a--', { a: new SourceA() }); + const expected = cold('--a--', { a }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should ignore duplicate sources', () => { + const sources$ = cold('--a--b--c--', { + a: new SourceA(), + b: new SourceA(), + c: new SourceA(), + }); + const expected = cold('--a--------', { a }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should report an error if an effect dispatches an invalid action', () => { + const sources$ = of(new SourceD()); + + toActions(sources$).subscribe(); + + expect(mockErrorReporter.report).toHaveBeenCalled(); + }); + + function toActions(source: any): Observable { + source['errorReporter'] = mockErrorReporter; + return effectSources.toActions.call(source); + } + }); + + function alwaysOf(value: T) { + return of(value).concat(never()); + } +}); diff --git a/modules/effects/spec/effects-subscription.spec.ts b/modules/effects/spec/effects-subscription.spec.ts deleted file mode 100644 index 0cf0e02927..0000000000 --- a/modules/effects/spec/effects-subscription.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ReflectiveInjector } from '@angular/core'; -import { of } from 'rxjs/observable/of'; -import { Effect } from '../src/effects'; -import { EffectsSubscription } from '../src/effects-subscription'; -import { SingletonEffectsService } from '../src/singleton-effects.service'; - - -describe('Effects Subscription', () => { - it('should add itself to a parent subscription if one exists', () => { - const observer: any = { next() { } }; - const singletonEffectsService = new SingletonEffectsService(); - const root = new EffectsSubscription(observer, singletonEffectsService, undefined, undefined); - - spyOn(root, 'add'); - const child = new EffectsSubscription(observer, singletonEffectsService, root, undefined); - - expect(root.add).toHaveBeenCalledWith(child); - }); - - it('should unsubscribe for all effects when destroyed', () => { - const observer: any = { next() { } }; - const singletonEffectsService = new SingletonEffectsService(); - const subscription = new EffectsSubscription(observer, singletonEffectsService, undefined, undefined); - - spyOn(subscription, 'unsubscribe'); - subscription.ngOnDestroy(); - - expect(subscription.unsubscribe).toHaveBeenCalled(); - }); - - it('should merge effects instances and subscribe them to the observer', () => { - class Source { - @Effect() a$ = of('a'); - @Effect() b$ = of('b'); - @Effect() c$ = of('c'); - } - const instance = new Source(); - const observer: any = { next: jasmine.createSpy('next') }; - const singletonEffectsService = new SingletonEffectsService(); - - const subscription = new EffectsSubscription(observer, singletonEffectsService, undefined, [ instance ]); - - expect(observer.next).toHaveBeenCalledTimes(3); - expect(observer.next).toHaveBeenCalledWith('a'); - expect(observer.next).toHaveBeenCalledWith('b'); - expect(observer.next).toHaveBeenCalledWith('c'); - }); - - it('should not merge duplicate effects instances when a SingletonEffectsService is provided', () => { - class Source { - @Effect() a$ = of('a'); - @Effect() b$ = of('b'); - @Effect() c$ = of('c'); - } - const instance = new Source(); - const observer: any = { next: jasmine.createSpy('next') }; - const singletonEffectsService = new SingletonEffectsService(); - singletonEffectsService.removeExistingAndRegisterNew([ instance ]); - - const subscription = new EffectsSubscription(observer, singletonEffectsService, undefined, [ instance ]); - - expect(observer.next).not.toHaveBeenCalled(); - expect(observer.next).not.toHaveBeenCalledWith('a'); - expect(observer.next).not.toHaveBeenCalledWith('b'); - expect(observer.next).not.toHaveBeenCalledWith('c'); - }); -}); diff --git a/modules/effects/spec/effects.spec.ts b/modules/effects/spec/effects.spec.ts deleted file mode 100644 index 848383e73f..0000000000 --- a/modules/effects/spec/effects.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import 'rxjs/add/observable/of'; -import { Observable } from 'rxjs/Observable'; - -import { Effect, mergeEffects } from '../src/effects'; - -describe('mergeEffects', function() { - it('should merge all observable sources decorated with @Effect()', function(done) { - class Fixture { - @Effect() a = Observable.of('a'); - @Effect() b = Observable.of('b'); - @Effect({ dispatch: false }) c = Observable.of('c'); - @Effect() d() { return Observable.of('d'); } - } - - const mock = new Fixture(); - const expected = ['a', 'b', 'd']; - - mergeEffects(mock).toArray().subscribe({ - next(actual) { - expect(actual).toEqual(expected); - }, - error: done, - complete: done - }); - }); -}); diff --git a/modules/effects/spec/effects_feature_module.ts b/modules/effects/spec/effects_feature_module.ts new file mode 100644 index 0000000000..31a4ec0ab4 --- /dev/null +++ b/modules/effects/spec/effects_feature_module.ts @@ -0,0 +1,40 @@ +import { TestBed } from '@angular/core/testing'; +import { EffectSources } from '../src/effect_sources'; +import { FEATURE_EFFECTS } from '../src/tokens'; +import { EffectsFeatureModule } from '../src/effects_feature_module'; + +describe('Effects Feature Module', () => { + const sourceA = 'sourceA'; + const sourceB = 'sourceB'; + const sourceC = 'sourceC'; + const effectSourceGroups = [[sourceA], [sourceB], [sourceC]]; + let mockEffectSources: { addEffects: jasmine.Spy }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: EffectSources, + useValue: { + addEffects: jasmine.createSpy('addEffects'), + }, + }, + { + provide: FEATURE_EFFECTS, + useValue: effectSourceGroups, + }, + EffectsFeatureModule, + ], + }); + + mockEffectSources = TestBed.get(mockEffectSources); + }); + + it('should add all effects when instantiated', () => { + TestBed.get(EffectsFeatureModule); + + expect(mockEffectSources.addEffects).toHaveBeenCalledWith(sourceA); + expect(mockEffectSources.addEffects).toHaveBeenCalledWith(sourceB); + expect(mockEffectSources.addEffects).toHaveBeenCalledWith(sourceC); + }); +}); diff --git a/modules/effects/spec/effects_metadata.spec.ts b/modules/effects/spec/effects_metadata.spec.ts new file mode 100644 index 0000000000..356288914b --- /dev/null +++ b/modules/effects/spec/effects_metadata.spec.ts @@ -0,0 +1,44 @@ +import { Effect, getSourceMetadata, getSourceForInstance } from '../src/effects_metadata'; + +describe('Effect Metadata', () => { + describe('getSourceMetadata', () => { + it('should get the effects metadata for a class instance', () => { + class Fixture { + @Effect() a: any; + @Effect() b: any; + @Effect({ dispatch: false }) c: any; + } + + const mock = new Fixture(); + + expect(getSourceMetadata(mock)).toEqual([ + { propertyName: 'a', dispatch: true }, + { propertyName: 'b', dispatch: true }, + { propertyName: 'c', dispatch: false } + ]); + }); + + it('should return an empty array if the class has not been decorated', () => { + class Fixture { + a: any; + b: any; + c: any; + } + + const mock = new Fixture(); + + expect(getSourceMetadata(mock)).toEqual([]); + }); + }); + + describe('getSourceProto', () => { + it('should get the prototype for an instance of a source', () => { + class Fixture { } + const instance = new Fixture(); + + const proto = getSourceForInstance(instance); + + expect(proto).toBe(Fixture.prototype); + }); + }); +}); diff --git a/modules/effects/spec/effects_resolver.spec.ts b/modules/effects/spec/effects_resolver.spec.ts new file mode 100644 index 0000000000..e69619fa54 --- /dev/null +++ b/modules/effects/spec/effects_resolver.spec.ts @@ -0,0 +1,8 @@ +import 'rxjs/add/observable/of'; +import { Observable } from 'rxjs/Observable'; +import { Effect, mergeEffects } from '../'; + + +describe('mergeEffects', () => { + +}); diff --git a/modules/effects/spec/index.spec.ts b/modules/effects/spec/index.spec.ts deleted file mode 100644 index 24801f95e7..0000000000 --- a/modules/effects/spec/index.spec.ts +++ /dev/null @@ -1,3 +0,0 @@ -describe('@ngrx/effects', function() { - -}); \ No newline at end of file diff --git a/modules/effects/spec/metadata.spec.ts b/modules/effects/spec/metadata.spec.ts deleted file mode 100644 index ed021dd984..0000000000 --- a/modules/effects/spec/metadata.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Effect, getEffectsMetadata } from '../src/effects'; - -describe('Effect Metadata', () => { - it('should get the effects metadata for a class instance', () => { - class Fixture { - @Effect() a: any; - @Effect() b: any; - @Effect({ dispatch: false }) c: any; - } - - const mock = new Fixture(); - - expect(getEffectsMetadata(mock)).toEqual([ - { propertyName: 'a', dispatch: true }, - { propertyName: 'b', dispatch: true }, - { propertyName: 'c', dispatch: false } - ]); - }); - - it('should return an empty array if the class has not been decorated', () => { - class Fixture { - a: any; - b: any; - c: any; - } - - const mock = new Fixture(); - - expect(getEffectsMetadata(mock)).toEqual([]); - }); -}); diff --git a/modules/effects/spec/ngc/ngc.spec.ts b/modules/effects/spec/ngc/ngc.spec.ts new file mode 100644 index 0000000000..23c5f40287 --- /dev/null +++ b/modules/effects/spec/ngc/ngc.spec.ts @@ -0,0 +1,60 @@ +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; +import { NgModule, Component, Injectable } from '@angular/core'; +import { platformDynamicServer } from '@angular/platform-server'; +import { BrowserModule } from '@angular/platform-browser'; +import { Store, StoreModule, combineReducers } from '../../../store'; +import { EffectsModule, Effect, Actions } from '../../'; + + +@Injectable() +export class NgcSpecFeatureEffects { + constructor(actions$: Actions) { } + + @Effect() run$ = of({ type: 'NgcSpecFeatureAction' }); +} + +@NgModule({ + imports: [ + EffectsModule.forFeature([ NgcSpecFeatureEffects ]), + ] +}) +export class NgcSpecFeatureModule { } + +@Injectable() +export class NgcSpecRootEffects { + constructor(actions$: Actions) { } + + @Effect() run$ = of({ type: 'NgcSpecRootAction' }); +} + + +export interface AppState { + count: number; +} + +@Component({ + selector: 'ngc-spec-component', + template: ` +

Hello Effects

+ ` +}) +export class NgcSpecComponent { + +} + +@NgModule({ + imports: [ + BrowserModule, + StoreModule.forRoot({ }), + EffectsModule.forRoot([ NgcSpecRootEffects ]), + NgcSpecFeatureModule, + ], + declarations: [ + NgcSpecComponent, + ], + bootstrap: [ + NgcSpecComponent, + ] +}) +export class NgcSpecModule {} diff --git a/modules/effects/spec/ngc/tsconfig.ngc.json b/modules/effects/spec/ngc/tsconfig.ngc.json new file mode 100644 index 0000000000..d38ff6972f --- /dev/null +++ b/modules/effects/spec/ngc/tsconfig.ngc.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES5", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "module": "commonjs", + "moduleResolution": "node", + "outDir": "./output", + "lib": ["es2015", "dom"], + "baseUrl": ".", + "paths": { + "@ngrx/store": ["../../../store"] + } + }, + "files": [ + "ngc.spec.ts" + ], + "angularCompilerOptions": { + "genDir": "ngfactory" + } +} diff --git a/modules/effects/spec/singleton-effects.service.spec.ts b/modules/effects/spec/singleton-effects.service.spec.ts deleted file mode 100644 index d0ff30b20f..0000000000 --- a/modules/effects/spec/singleton-effects.service.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { of } from 'rxjs/observable/of'; -import { Effect } from '../src/effects'; -import { SingletonEffectsService } from '../src/singleton-effects.service'; - -describe('SingletonEffectsService', () => { - it('should filter out duplicate effect instances and register new ones', () => { - class Source1 { - @Effect() a$ = of('a'); - @Effect() b$ = of('b'); - @Effect() c$ = of('c'); - } - class Source2 { - @Effect() d$ = of('d'); - @Effect() e$ = of('e'); - @Effect() f$ = of('f'); - } - const instance1 = new Source1(); - const instance2 = new Source2(); - let singletonEffectsService = new SingletonEffectsService(); - - let result = singletonEffectsService.removeExistingAndRegisterNew([ instance1 ]); - expect(result).toContain(instance1); - - result = singletonEffectsService.removeExistingAndRegisterNew([ instance1, instance2 ]); - expect(result).not.toContain(instance1); - expect(result).toContain(instance2); - - result = singletonEffectsService.removeExistingAndRegisterNew([ instance1, instance2 ]); - expect(result).not.toContain(instance1); - expect(result).not.toContain(instance2); - }); -}); diff --git a/modules/effects/src/actions.ts b/modules/effects/src/actions.ts index 3712deccd8..f8968997bb 100644 --- a/modules/effects/src/actions.ts +++ b/modules/effects/src/actions.ts @@ -4,33 +4,26 @@ import { Observable } from 'rxjs/Observable'; import { Operator } from 'rxjs/Operator'; import { filter } from 'rxjs/operator/filter'; - @Injectable() -export class Actions extends Observable { - constructor(@Inject(ScannedActionsSubject) actionsSubject: Observable) { +export class Actions extends Observable { + constructor(@Inject(ScannedActionsSubject) source?: Observable) { super(); - this.source = actionsSubject; + + if (source) { + this.source = source; + } } - lift(operator: Operator): Observable { - const observable = new Actions(this); + lift(operator: Operator): Observable { + const observable = new Actions(); + observable.source = this; observable.operator = operator; return observable; } - ofType(...keys: string[]): Actions { - return filter.call(this, ({ type }: {type: string}) => { - const len = keys.length; - if (len === 1) { - return type === keys[0]; - } else { - for (let i = 0; i < len; i++) { - if (keys[i] === type) { - return true; - } - } - } - return false; - }); + ofType(...allowedTypes: string[]): Actions { + return filter.call(this, (action: Action) => + allowedTypes.some(type => type === action.type), + ); } } diff --git a/modules/effects/src/bootstrap-listener.ts b/modules/effects/src/bootstrap-listener.ts deleted file mode 100644 index 1b51d6afa1..0000000000 --- a/modules/effects/src/bootstrap-listener.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injector, OpaqueToken } from '@angular/core'; -import { EffectsSubscription } from './effects-subscription'; - - -export const afterBootstrapEffects = new OpaqueToken('ngrx:effects: Bootstrap Effects'); - -export function runAfterBootstrapEffects(injector: Injector, subscription: EffectsSubscription) { - return () => { - const effectInstances = injector.get(afterBootstrapEffects, false); - - if (effectInstances) { - subscription.addEffects(effectInstances); - } - }; -} diff --git a/modules/effects/src/effect_sources.ts b/modules/effects/src/effect_sources.ts new file mode 100644 index 0000000000..766e7485b4 --- /dev/null +++ b/modules/effects/src/effect_sources.ts @@ -0,0 +1,74 @@ +import { groupBy, GroupedObservable } from 'rxjs/operator/groupBy'; +import { mergeMap } from 'rxjs/operator/mergeMap'; +import { exhaustMap } from 'rxjs/operator/exhaustMap'; +import { map } from 'rxjs/operator/map'; +import { dematerialize } from 'rxjs/operator/dematerialize'; +import { Observable } from 'rxjs/Observable'; +import { Subject } from 'rxjs/Subject'; +import { Injectable, isDevMode } from '@angular/core'; +import { Action } from '@ngrx/store'; +import { getSourceForInstance } from './effects_metadata'; +import { resolveEffectSource, EffectNotification } from './effects_resolver'; +import { ErrorReporter } from './error_reporter'; + +@Injectable() +export class EffectSources extends Subject { + constructor(private errorReporter: ErrorReporter) { + super(); + } + + addEffects(effectSourceInstance: any) { + this.next(effectSourceInstance); + } + + /** + * @private + */ + toActions(): Observable { + return mergeMap.call( + groupBy.call(this, getSourceForInstance), + (source$: GroupedObservable) => + dematerialize.call( + map.call( + exhaustMap.call(source$, resolveEffectSource), + (output: EffectNotification) => { + switch (output.notification.kind) { + case 'N': { + const action = output.notification.value; + const isInvalidAction = + !action || !action.type || typeof action.type !== 'string'; + + if (isInvalidAction) { + const errorReason = `Effect "${output.sourceName}.${output.propertyName}" dispatched an invalid action`; + + this.errorReporter.report(errorReason, { + Source: output.sourceInstance, + Effect: output.effect, + Dispatched: action, + Notification: output.notification, + }); + } + + break; + } + case 'E': { + const errorReason = `Effect "${output.sourceName}.${output.propertyName}" threw an error`; + + this.errorReporter.report(errorReason, { + Source: output.sourceInstance, + Effect: output.effect, + Error: output.notification.error, + Notification: output.notification, + }); + + break; + } + } + + return output.notification; + }, + ), + ), + ); + } +} diff --git a/modules/effects/src/effects-subscription.ts b/modules/effects/src/effects-subscription.ts deleted file mode 100644 index 75c5ca938b..0000000000 --- a/modules/effects/src/effects-subscription.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { OpaqueToken, Inject, SkipSelf, Optional, Injectable, OnDestroy } from '@angular/core'; -import { Action, Store } from '@ngrx/store'; -import { Observer } from 'rxjs/Observer'; -import { Subscription } from 'rxjs/Subscription'; -import { merge } from 'rxjs/observable/merge'; -import { mergeEffects } from './effects'; -import { SingletonEffectsService } from './singleton-effects.service'; - - -export const effects = new OpaqueToken('ngrx/effects: Effects'); - -@Injectable() -export class EffectsSubscription extends Subscription implements OnDestroy { - constructor( - @Inject(Store) private store: Observer, - @Inject(SingletonEffectsService) private singletonEffectsService: SingletonEffectsService, - @Optional() @SkipSelf() public parent?: EffectsSubscription, - @Optional() @Inject(effects) effectInstances?: any[] - ) { - super(); - - if (parent) { - parent.add(this); - } - - if (typeof effectInstances !== 'undefined' && effectInstances) { - this.addEffects(effectInstances); - } - } - - addEffects(effectInstances: any[]) { - effectInstances = this.singletonEffectsService.removeExistingAndRegisterNew(effectInstances); - - const sources = effectInstances.map(mergeEffects); - const merged = merge(...sources); - - this.add(merged.subscribe(this.store)); - } - - ngOnDestroy() { - if (!this.closed) { - this.unsubscribe(); - } - } -} diff --git a/modules/effects/src/effects.module.ts b/modules/effects/src/effects.module.ts deleted file mode 100644 index cfeb587096..0000000000 --- a/modules/effects/src/effects.module.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NgModule, Injector, Type, APP_BOOTSTRAP_LISTENER, OpaqueToken } from '@angular/core'; -import { Actions } from './actions'; -import { EffectsSubscription, effects } from './effects-subscription'; -import { runAfterBootstrapEffects, afterBootstrapEffects } from './bootstrap-listener'; -import { SingletonEffectsService } from './singleton-effects.service'; - - -@NgModule({ - providers: [ - Actions, - EffectsSubscription, - { - provide: APP_BOOTSTRAP_LISTENER, - multi: true, - deps: [ Injector, EffectsSubscription ], - useFactory: runAfterBootstrapEffects - } - ] -}) -export class EffectsModule { - static forRoot() { - return { - ngModule: EffectsModule, - providers: [ - SingletonEffectsService - ] - }; - } - - static run(type: Type) { - return { - ngModule: EffectsModule, - providers: [ - EffectsSubscription, - type, - { provide: effects, useExisting: type, multi: true } - ] - }; - } - - static runAfterBootstrap(type: Type) { - return { - ngModule: EffectsModule, - providers: [ - type, - { provide: afterBootstrapEffects, useExisting: type, multi: true } - ] - }; - } - - constructor(private effectsSubscription: EffectsSubscription) {} -} diff --git a/modules/effects/src/effects.ts b/modules/effects/src/effects.ts deleted file mode 100644 index fa1b780a39..0000000000 --- a/modules/effects/src/effects.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { merge } from 'rxjs/observable/merge'; -import { ignoreElements } from 'rxjs/operator/ignoreElements'; -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs/Observable'; - -const METADATA_KEY = '@ngrx/effects'; - -export interface EffectMetadata { - propertyName: string; - dispatch: boolean; -} - -export function Effect({ dispatch } = { dispatch: true }): PropertyDecorator { - return function(target: any, propertyName: string) { - if (!(Reflect as any).hasOwnMetadata(METADATA_KEY, target)) { - (Reflect as any).defineMetadata(METADATA_KEY, [], target); - } - - const effects: EffectMetadata[] = (Reflect as any).getOwnMetadata(METADATA_KEY, target); - const metadata: EffectMetadata = { propertyName, dispatch }; - - (Reflect as any).defineMetadata(METADATA_KEY, [ ...effects, metadata ], target); - }; -} - -export function getEffectsMetadata(instance: any): EffectMetadata[] { - const target = Object.getPrototypeOf(instance); - - if (!(Reflect as any).hasOwnMetadata(METADATA_KEY, target)) { - return []; - } - - return (Reflect as any).getOwnMetadata(METADATA_KEY, target); -} - -export function mergeEffects(instance: any): Observable { - const observables: Observable[] = getEffectsMetadata(instance).map( - ({ propertyName, dispatch }): Observable => { - const observable = typeof instance[propertyName] === 'function' ? - instance[propertyName]() : instance[propertyName]; - - if (dispatch === false) { - return ignoreElements.call(observable); - } - - return observable; - } - ); - - return merge(...observables); -} diff --git a/modules/effects/src/effects_feature_module.ts b/modules/effects/src/effects_feature_module.ts new file mode 100644 index 0000000000..3c881d098a --- /dev/null +++ b/modules/effects/src/effects_feature_module.ts @@ -0,0 +1,17 @@ +import { NgModule, Inject, Type } from '@angular/core'; +import { EffectSources } from './effect_sources'; +import { FEATURE_EFFECTS } from './tokens'; + +@NgModule({}) +export class EffectsFeatureModule { + constructor( + private effectSources: EffectSources, + @Inject(FEATURE_EFFECTS) effectSourceGroups: any[][], + ) { + effectSourceGroups.forEach(group => + group.forEach(effectSourceInstance => + effectSources.addEffects(effectSourceInstance), + ), + ); + } +} diff --git a/modules/effects/src/effects_metadata.ts b/modules/effects/src/effects_metadata.ts new file mode 100644 index 0000000000..9016cca4eb --- /dev/null +++ b/modules/effects/src/effects_metadata.ts @@ -0,0 +1,42 @@ +import { merge } from 'rxjs/observable/merge'; +import { ignoreElements } from 'rxjs/operator/ignoreElements'; +import { Observable } from 'rxjs/Observable'; +import { compose } from '@ngrx/store'; + +const METADATA_KEY = '@ngrx/effects'; +const r: any = Reflect; + +export interface EffectMetadata { + propertyName: string; + dispatch: boolean; +} + +function getEffectMetadataEntries(sourceProto: any): EffectMetadata[] { + if (r.hasOwnMetadata(METADATA_KEY, sourceProto)) { + return r.getOwnMetadata(METADATA_KEY, sourceProto); + } + + return []; +} + +function setEffectMetadataEntries(sourceProto: any, entries: EffectMetadata[]) { + r.defineMetadata(METADATA_KEY, entries, sourceProto); +} + +export function Effect({ dispatch } = { dispatch: true }): PropertyDecorator { + return function(target: any, propertyName: string) { + const effects: EffectMetadata[] = getEffectMetadataEntries(target); + const metadata: EffectMetadata = { propertyName, dispatch }; + + setEffectMetadataEntries(target, [...effects, metadata]); + }; +} + +export function getSourceForInstance(instance: Object): any { + return Object.getPrototypeOf(instance); +} + +export const getSourceMetadata = compose( + getEffectMetadataEntries, + getSourceForInstance, +); diff --git a/modules/effects/src/effects_module.ts b/modules/effects/src/effects_module.ts new file mode 100644 index 0000000000..fa8eb78aea --- /dev/null +++ b/modules/effects/src/effects_module.ts @@ -0,0 +1,53 @@ +import { NgModule, ModuleWithProviders, Type } from '@angular/core'; +import { EffectSources } from './effect_sources'; +import { Actions } from './actions'; +import { ROOT_EFFECTS, FEATURE_EFFECTS, CONSOLE } from './tokens'; +import { EffectsFeatureModule } from './effects_feature_module'; +import { EffectsRunner } from './effects_runner'; +import { ErrorReporter } from './error_reporter'; +import { RUN_EFFECTS } from './run_effects'; + +@NgModule({}) +export class EffectsModule { + static forFeature(featureEffects: Type[]): ModuleWithProviders { + return { + ngModule: EffectsFeatureModule, + providers: [ + featureEffects, + { + provide: FEATURE_EFFECTS, + multi: true, + deps: featureEffects, + useFactory: createSourceInstances, + }, + ], + }; + } + + static forRoot(rootEffects: Type[]): ModuleWithProviders { + return { + ngModule: EffectsModule, + providers: [ + EffectsRunner, + EffectSources, + ErrorReporter, + Actions, + RUN_EFFECTS, + rootEffects, + { + provide: ROOT_EFFECTS, + deps: rootEffects, + useFactory: createSourceInstances, + }, + { + provide: CONSOLE, + useValue: console, + }, + ], + }; + } +} + +export function createSourceInstances(...instances: any[]) { + return instances; +} diff --git a/modules/effects/src/effects_resolver.ts b/modules/effects/src/effects_resolver.ts new file mode 100644 index 0000000000..3cb63bbaa7 --- /dev/null +++ b/modules/effects/src/effects_resolver.ts @@ -0,0 +1,61 @@ +import { merge } from 'rxjs/observable/merge'; +import { ignoreElements } from 'rxjs/operator/ignoreElements'; +import { materialize } from 'rxjs/operator/materialize'; +import { map } from 'rxjs/operator/map'; +import { Observable } from 'rxjs/Observable'; +import { Notification } from 'rxjs/Notification'; +import { Action } from '@ngrx/store'; +import { getSourceMetadata, getSourceForInstance } from './effects_metadata'; +import { isOnRunEffects } from './on_run_effects'; + +export interface EffectNotification { + effect: Observable | (() => Observable); + propertyName: string; + sourceName: string; + sourceInstance: any; + notification: Notification; +} + +export function mergeEffects( + sourceInstance: any, +): Observable { + const sourceName = getSourceForInstance(sourceInstance).constructor.name; + + const observables: Observable[] = getSourceMetadata( + sourceInstance, + ).map(({ propertyName, dispatch }): Observable => { + const observable: Observable = typeof sourceInstance[propertyName] === + 'function' + ? sourceInstance[propertyName]() + : sourceInstance[propertyName]; + + if (dispatch === false) { + return ignoreElements.call(observable); + } + + const materialized$ = materialize.call(observable); + + return map.call( + materialized$, + (notification: Notification): EffectNotification => ({ + effect: sourceInstance[propertyName], + notification, + propertyName, + sourceName, + sourceInstance, + }), + ); + }); + + return merge(...observables); +} + +export function resolveEffectSource(sourceInstance: any) { + const mergedEffects$ = mergeEffects(sourceInstance); + + if (isOnRunEffects(sourceInstance)) { + return sourceInstance.ngrxOnRunEffects(mergedEffects$); + } + + return mergedEffects$; +} diff --git a/modules/effects/src/effects_runner.ts b/modules/effects/src/effects_runner.ts new file mode 100644 index 0000000000..13c94d28b1 --- /dev/null +++ b/modules/effects/src/effects_runner.ts @@ -0,0 +1,29 @@ +import { Subscription } from 'rxjs/Subscription'; +import { Injectable, OnDestroy } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { EffectSources } from './effect_sources'; + +@Injectable() +export class EffectsRunner implements OnDestroy { + private effectsSubscription: Subscription | null = null; + + constructor( + private effectSources: EffectSources, + private store: Store, + ) {} + + start() { + if (!this.effectsSubscription) { + this.effectsSubscription = this.effectSources + .toActions() + .subscribe(this.store); + } + } + + ngOnDestroy() { + if (this.effectsSubscription) { + this.effectsSubscription.unsubscribe(); + this.effectsSubscription = null; + } + } +} diff --git a/modules/effects/src/error_reporter.ts b/modules/effects/src/error_reporter.ts new file mode 100644 index 0000000000..0405ecce09 --- /dev/null +++ b/modules/effects/src/error_reporter.ts @@ -0,0 +1,17 @@ +import { Injectable, InjectionToken, Inject } from '@angular/core'; +import { CONSOLE } from './tokens'; + +@Injectable() +export class ErrorReporter { + constructor(@Inject(CONSOLE) private console: any) {} + + report(reason: string, details: any): void { + this.console.group(reason); + + for (let key in details) { + this.console.error(`${key}:`, details[key]); + } + + this.console.groupEnd(); + } +} diff --git a/modules/effects/src/index.ts b/modules/effects/src/index.ts index 61a251456e..c314508b9a 100644 --- a/modules/effects/src/index.ts +++ b/modules/effects/src/index.ts @@ -1,6 +1,7 @@ -export { Effect, mergeEffects } from './effects'; +export { Effect } from './effects_metadata'; +export { mergeEffects } from './effects_resolver'; export { Actions } from './actions'; -export { EffectsModule } from './effects.module'; -export { EffectsSubscription } from './effects-subscription'; +export { EffectsModule } from './effects_module'; +export { EffectSources } from './effect_sources'; +export { OnRunEffects } from './on_run_effects'; export { toPayload } from './util'; -export { runAfterBootstrapEffects } from './bootstrap-listener'; diff --git a/modules/effects/src/on_run_effects.ts b/modules/effects/src/on_run_effects.ts new file mode 100644 index 0000000000..9d0f3f4647 --- /dev/null +++ b/modules/effects/src/on_run_effects.ts @@ -0,0 +1,21 @@ +import { Observable } from 'rxjs/Observable'; +import { getSourceForInstance } from './effects_metadata'; +import { EffectNotification } from './effects_resolver'; + +export interface OnRunEffects { + ngrxOnRunEffects( + resolvedEffects$: Observable, + ): Observable; +} + +const onRunEffectsKey: keyof OnRunEffects = 'ngrxOnRunEffects'; + +export function isOnRunEffects( + sourceInstance: Object, +): sourceInstance is OnRunEffects { + const source = getSourceForInstance(sourceInstance); + + return ( + onRunEffectsKey in source && typeof source[onRunEffectsKey] === 'function' + ); +} diff --git a/modules/effects/src/run_effects.ts b/modules/effects/src/run_effects.ts new file mode 100644 index 0000000000..8d18c98f5a --- /dev/null +++ b/modules/effects/src/run_effects.ts @@ -0,0 +1,31 @@ +import { + APP_INITIALIZER, + Provider, + Optional, + Type, + Injector, +} from '@angular/core'; +import { EffectsRunner } from './effects_runner'; +import { EffectSources } from './effect_sources'; +import { BOOTSTRAP_EFFECTS, ROOT_EFFECTS } from './tokens'; + +export function createRunEffects( + effectSources: EffectSources, + runner: EffectsRunner, + rootEffects: any[], +) { + return function() { + runner.start(); + + rootEffects.forEach(effectSourceInstance => + effectSources.addEffects(effectSourceInstance), + ); + }; +} + +export const RUN_EFFECTS: Provider = { + provide: APP_INITIALIZER, + multi: true, + deps: [EffectSources, EffectsRunner, ROOT_EFFECTS], + useFactory: createRunEffects, +}; diff --git a/modules/effects/src/singleton-effects.service.ts b/modules/effects/src/singleton-effects.service.ts deleted file mode 100644 index 44eb744971..0000000000 --- a/modules/effects/src/singleton-effects.service.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable() -export class SingletonEffectsService { - private registeredEffects: string[] = []; - - removeExistingAndRegisterNew (effectInstances: any[]): any[] { - return effectInstances.filter(instance => { - const instanceAsString = instance.constructor.toString(); - if (this.registeredEffects.indexOf(instanceAsString) === -1) { - this.registeredEffects.push(instanceAsString); - return true; - } - return false; - }); - } -} diff --git a/modules/effects/src/tokens.ts b/modules/effects/src/tokens.ts new file mode 100644 index 0000000000..04f2abc664 --- /dev/null +++ b/modules/effects/src/tokens.ts @@ -0,0 +1,15 @@ +import { InjectionToken, Type } from '@angular/core'; + +export const IMMEDIATE_EFFECTS = new InjectionToken( + 'ngrx/effects: Immediate Effects', +); +export const BOOTSTRAP_EFFECTS = new InjectionToken( + 'ngrx/effects: Bootstrap Effects', +); +export const ROOT_EFFECTS = new InjectionToken[]>( + 'ngrx/effects: Root Effects', +); +export const FEATURE_EFFECTS = new InjectionToken( + 'ngrx/effects: Feature Effects', +); +export const CONSOLE = new InjectionToken('Browser Console'); diff --git a/modules/effects/src/util.ts b/modules/effects/src/util.ts index c487af9991..6c695ac396 100644 --- a/modules/effects/src/util.ts +++ b/modules/effects/src/util.ts @@ -1,6 +1,5 @@ import { Action } from '@ngrx/store'; - -export function toPayload(action: Action) { - return (action).payload; +export function toPayload(action: Action): any { + return (action as any).payload; } diff --git a/modules/effects/testing/index.ts b/modules/effects/testing/index.ts deleted file mode 100644 index 1437159b1d..0000000000 --- a/modules/effects/testing/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './runner'; -export * from './testing.module'; diff --git a/modules/effects/testing/runner.ts b/modules/effects/testing/runner.ts deleted file mode 100644 index e6446234d6..0000000000 --- a/modules/effects/testing/runner.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Injectable } from '@angular/core'; -import { ReplaySubject } from 'rxjs/ReplaySubject'; - - -@Injectable() -export class EffectsRunner extends ReplaySubject { - constructor() { - super(); - } - - queue(action: any) { - this.next(action); - } -} diff --git a/modules/effects/testing/testing.module.ts b/modules/effects/testing/testing.module.ts deleted file mode 100644 index 4671d53780..0000000000 --- a/modules/effects/testing/testing.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { Actions } from '@ngrx/effects'; -import { EffectsRunner } from './runner'; - - -export function _createActions(runner: EffectsRunner): Actions { - return new Actions(runner); -} - -@NgModule({ - providers: [ - EffectsRunner, - { provide: Actions, deps: [ EffectsRunner ], useFactory: _createActions } - ] -}) -export class EffectsTestingModule { } diff --git a/modules/store/spec/helpers/marble-testing.ts b/modules/store/spec/helpers/marble-testing.ts deleted file mode 100644 index 18c7d2bf06..0000000000 --- a/modules/store/spec/helpers/marble-testing.ts +++ /dev/null @@ -1,41 +0,0 @@ -declare var global; - -function hot(...args: any[]) { - if (!global.rxTestScheduler) { - throw 'tried to use hot() in async test'; - } - return global.rxTestScheduler.createHotObservable.apply(global.rxTestScheduler, arguments); -} - -function cold() { - if (!global.rxTestScheduler) { - throw 'tried to use cold() in async test'; - } - return global.rxTestScheduler.createColdObservable.apply(global.rxTestScheduler, arguments); -} - -function expectObservable(...args: any[]) { - if (!global.rxTestScheduler) { - throw 'tried to use expectObservable() in async test'; - } - return global.rxTestScheduler.expectObservable.apply(global.rxTestScheduler, arguments); -} - -function expectSubscriptions(...args: any[]) { - if (!global.rxTestScheduler) { - throw 'tried to use expectSubscriptions() in async test'; - } - return global.rxTestScheduler.expectSubscriptions.apply(global.rxTestScheduler, arguments); -} - -function assertDeepEqual(actual, expected) { - ( expect(actual)).toDeepEqual(expected); -} - -export { - hot, - cold, - expectObservable, - expectSubscriptions, - assertDeepEqual -}; \ No newline at end of file diff --git a/modules/store/spec/helpers/test-helper.ts b/modules/store/spec/helpers/test-helper.ts deleted file mode 100644 index d868eb9fab..0000000000 --- a/modules/store/spec/helpers/test-helper.ts +++ /dev/null @@ -1,130 +0,0 @@ -declare var global, require, Symbol; - -jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000; - -const _ = require('lodash'); -const root = require('rxjs/util/root').root; -import {TestScheduler} from 'rxjs/testing/TestScheduler'; - -import * as marbleHelpers from './marble-testing'; - -global.rxTestScheduler = null; -global.cold = marbleHelpers.cold; -global.hot = marbleHelpers.hot; -global.expectObservable = marbleHelpers.expectObservable; -global.expectSubscriptions = marbleHelpers.expectSubscriptions; - -const assertDeepEqual = marbleHelpers.assertDeepEqual; - -const glit = global.it; - -global.it = function(description, cb, timeout) { - if (cb.length === 0) { - glit(description, function() { - global.rxTestScheduler = new TestScheduler(assertDeepEqual); - cb(); - global.rxTestScheduler.flush(); - }); - } else { - glit.apply(this, arguments); - } -}; - -global.it.asDiagram = function() { - return global.it; -}; - -const glfit = global.fit; - -global.fit = function(description, cb, timeout) { - if (cb.length === 0) { - glfit(description, function() { - global.rxTestScheduler = new TestScheduler(assertDeepEqual); - cb(); - global.rxTestScheduler.flush(); - }); - } else { - glfit.apply(this, arguments); - } -}; - -function stringify(x) { - return JSON.stringify(x, function(key, value) { - if (Array.isArray(value)) { - return '[' + value - .map(function(i) { - return '\n\t' + stringify(i); - }) + '\n]'; - } - return value; - }) - .replace(/\\"/g, '"') - .replace(/\\t/g, '\t') - .replace(/\\n/g, '\n'); -} - -beforeEach(function() { - jasmine.addMatchers({ - toDeepEqual: function(util, customEqualityTesters) { - return { - compare: function(actual, expected) { - let result: any = { pass: _.isEqual(actual, expected) }; - - if (!result.pass && Array.isArray(actual) && Array.isArray(expected)) { - result.message = 'Expected \n'; - actual.forEach(function(x) { - result.message += stringify(x) + '\n'; - }); - result.message += '\nto deep equal \n'; - expected.forEach(function(x) { - result.message += stringify(x) + '\n'; - }); - } - - return result; - } - }; - } - }); -}); - -afterEach(function() { - global.rxTestScheduler = null; -}); - -(function() { - Object.defineProperty(Error.prototype, 'toJSON', { - value: function() { - let alt = {}; - - Object.getOwnPropertyNames(this).forEach(function(key) { - if (key !== 'stack') { - alt[key] = this[key]; - } - }, this); - return alt; - }, - configurable: true - }); - - global.__root__ = root; -})(); - -global.lowerCaseO = function lowerCaseO() { - const values = [].slice.apply(arguments); - - const o = { - subscribe: function(observer) { - values.forEach(function(v) { - observer.next(v); - }); - observer.complete(); - } - }; - - o[(Symbol).observable] = function() { - return this; - }; - - return o; -}; diff --git a/modules/store/spec/ngc/main.ts b/modules/store/spec/ngc/main.ts index 14739d9b8f..bd348af620 100644 --- a/modules/store/spec/ngc/main.ts +++ b/modules/store/spec/ngc/main.ts @@ -1,4 +1,4 @@ -import { NgModule, Component } from '@angular/core'; +import { NgModule, Component, InjectionToken } from '@angular/core'; import { platformDynamicServer } from '@angular/platform-server'; import { BrowserModule } from '@angular/platform-browser'; import { Store, StoreModule, combineReducers } from '../../'; @@ -31,6 +31,8 @@ export interface AppState { count: number; } +export const reducerToken = new InjectionToken('Reducers'); + @Component({ selector: 'ngc-spec-component', template: ` diff --git a/modules/store/spec/ngc/tsconfig.ngc.json b/modules/store/spec/ngc/tsconfig.ngc.json index 5b15db0b2a..601bfec18e 100644 --- a/modules/store/spec/ngc/tsconfig.ngc.json +++ b/modules/store/spec/ngc/tsconfig.ngc.json @@ -6,7 +6,11 @@ "module": "commonjs", "moduleResolution": "node", "outDir": "./output", - "lib": ["es2015", "dom"] + "lib": ["es2015", "dom"], + "baseUrl": ".", + "paths": { + "@ngrx/store": ["../../../store"] + } }, "files": [ "main.ts"