diff --git a/examples/counter/actions/search-actions.ts b/examples/counter/actions/search-actions.ts new file mode 100644 index 0000000..467bb92 --- /dev/null +++ b/examples/counter/actions/search-actions.ts @@ -0,0 +1,39 @@ +import { NgRedux } from 'ng2-redux'; + +export const SEARCH_ACTIONS = { + SEARCH: 'SEARCH', + SEARCH_RESULT: 'SEARCH_RESULT', + TERMINATE: 'TERMINATE', + SEARCH_NEXT: 'SEARCH_NEXT', + SEARCH_PREVIOUS: 'SEARCH_PREVIOUS' +}; + +import { Injectable } from '@angular/core'; +@Injectable() +export class SearchActions { + constructor(private ngRedux: NgRedux) {} + + searchDispatch(keyword: string) { + this.ngRedux.dispatch(this.search(keyword)); + } + + fetchResultDispatch(total: number) { + this.ngRedux.dispatch(this.fetchResult(total)); + } + + private search(keyword: string) { + return { + type: SEARCH_ACTIONS.SEARCH, + payload: keyword + }; + } + + private fetchResult(total: number) { + return { + type: SEARCH_ACTIONS.SEARCH_RESULT, + payload: { + total: total + } + }; + } +} diff --git a/examples/counter/components/search-info.component.ts b/examples/counter/components/search-info.component.ts new file mode 100644 index 0000000..fb544db --- /dev/null +++ b/examples/counter/components/search-info.component.ts @@ -0,0 +1,35 @@ +import { Component, Input } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { NgRedux } from 'ng2-redux'; +import 'rxjs/add/operator/combineLatest'; +import { SearchActions } from '../actions/search-actions'; + +@Component({ + selector: 'search-info', + providers: [SearchActions], + template: ` + + ` +}) +export class SearchInfo { + + private search$: Observable; + private test; + + constructor(private ngRedux: NgRedux, private actions: SearchActions) { } + + ngOnInit() { + this.search$ = this.ngRedux.select(state => state.searchReducer.keyword); + this.search$.subscribe((keyword) => { + if (keyword != '') { + this.actions.fetchResultDispatch(keyword.length) + } + }); + } + ngAfterViewInit() { + + + } +} diff --git a/examples/counter/components/search.component.ts b/examples/counter/components/search.component.ts new file mode 100644 index 0000000..045ab7e --- /dev/null +++ b/examples/counter/components/search.component.ts @@ -0,0 +1,52 @@ +import { Component } from '@angular/core'; +import { NgRedux, select } from 'ng2-redux'; +import { SearchActions } from '../actions/search-actions'; +import { Observable } from 'rxjs/Rx'; + +@Component({ + selector: 'search', + providers: [SearchActions], + template: ` +

+ Counter: {{ counter }}
+ Counter$ Async: {{ counter$ | async }}
+ +

+ ` +}) +export class Search { + counter$: Observable; + search$: Observable; + counter; + keyword: string; + + constructor(private actions: SearchActions, private ngRedux: NgRedux) { } + + ngOnInit() { + this.counter$ = this.ngRedux.select(state => state.searchReducer.total); + this.search$ = this.ngRedux.select(state => state.searchReducer.keyword); + + + +this.search$.subscribe((keyword) => { + if (keyword != '') { + this.actions.fetchResultDispatch(keyword.length) + } + }); + + this.counter$.subscribe(state => { + + this.counter = state; + }); + + + + + + } + + private searchKeyword() { + this.actions.searchDispatch(this.keyword); + } +} diff --git a/examples/counter/containers/App.ts b/examples/counter/containers/App.ts index f475af2..b79f9b0 100644 --- a/examples/counter/containers/App.ts +++ b/examples/counter/containers/App.ts @@ -4,6 +4,8 @@ import { NgRedux, DevToolsExtension } from 'ng2-redux'; import { Counter } from '../components/Counter'; import { CounterInfo } from '../components/CounterInfo'; +import { Search } from '../components/search.component'; +import { SearchInfo } from '../components/search-info.component'; import { RootState, enhancers } from '../store'; import reducer from '../reducers/index'; @@ -11,13 +13,17 @@ const createLogger = require('redux-logger'); @Component({ selector: 'root', - directives: [Counter, CounterInfo], + directives: [Counter, CounterInfo, Search, SearchInfo], pipes: [AsyncPipe], - template: ` - - - `, - providers: [ DevToolsExtension ] + providers: [ DevToolsExtension ], + template: ` +
+ + + + + ` + }) export class App { constructor( diff --git a/examples/counter/index.ts b/examples/counter/index.ts index 9a5316a..bd70afc 100644 --- a/examples/counter/index.ts +++ b/examples/counter/index.ts @@ -1,5 +1,6 @@ import { bootstrap } from '@angular/platform-browser-dynamic'; import { App } from './containers/App'; import { NgRedux } from 'ng2-redux'; +import { SearchActions } from './actions/search-actions'; -bootstrap(App, [ NgRedux ]); +bootstrap(App, [ NgRedux, SearchActions]); diff --git a/examples/counter/package.json b/examples/counter/package.json index d0a557f..6836a87 100644 --- a/examples/counter/package.json +++ b/examples/counter/package.json @@ -6,7 +6,7 @@ "scripts": { "postinstall": "typings install", "start": "webpack-dev-server -d --inline --progress --no-info --config webpack.config.js", - "dev": "webpack-dev-server -d --inline --progress --no-info --config webpack.dev.config.js" + "dev": "webpack-dev-server -d --inline --progress --no-info --config webpack.dev.config.js --host 127.0.0.1" }, "repository": { "type": "git", diff --git a/examples/counter/reducers/index.ts b/examples/counter/reducers/index.ts index 3151ec8..f72eb9d 100644 --- a/examples/counter/reducers/index.ts +++ b/examples/counter/reducers/index.ts @@ -3,10 +3,11 @@ const { combineReducers } = Redux; import { RootState } from '../store'; import counter from './counter'; import pathDemo from './path-demo'; - +import searchReducer from './search'; const rootReducer = combineReducers({ counter, - pathDemo + pathDemo, + searchReducer }); export default rootReducer; diff --git a/examples/counter/reducers/search.ts b/examples/counter/reducers/search.ts new file mode 100644 index 0000000..02471ac --- /dev/null +++ b/examples/counter/reducers/search.ts @@ -0,0 +1,34 @@ +import { SEARCH_ACTIONS } from '../actions/search-actions'; + +export interface SearchState { + onSearch: boolean; + keyword: string; + total: number; +} + +const searchInitState: SearchState = { + onSearch: false, + keyword: '', + total: -1 +}; + +export default function searchReducer(state = searchInitState, action): + SearchState { + switch (action.type) { + case SEARCH_ACTIONS.SEARCH: + return Object.assign({}, state, { + onSearch: true, + keyword: action.payload, + total: state.total + }); + case SEARCH_ACTIONS.SEARCH_RESULT: + let total = action.payload.total; + return Object.assign({}, state, { + onSearch: state.onSearch, + keyword: state.keyword, + total + }); + default: + return state; + } +} diff --git a/examples/counter/store/index.ts b/examples/counter/store/index.ts index 003d222..bdff239 100644 --- a/examples/counter/store/index.ts +++ b/examples/counter/store/index.ts @@ -7,4 +7,4 @@ export const enhancers = [ export interface RootState { counter: number; pathDemo: Object; -} +}; diff --git a/package.json b/package.json index ad25a03..a852779 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "ng2-redux", - "version": "3.2.0", + "version": "3.2.1-beta.1", "description": "Angular 2 bindings for Redux", "main": "./lib/index.js", "scripts": { "build": "npm run typings && rimraf ./lib; tsc; rimraf ./lib/___tests___", - "test": "npm run typings && npm run lint && npm run mocha", + "test": "npm run typings && npm run lint && npm run mocha", "typings": "rimraf ./typings && typings install", "mocha": "ts-node ./node_modules/mocha/bin/_mocha --opts ./src/___tests___/mocha.opts", "lint": "tslint 'src/**/*.ts' 'examples/counter/**.ts --exclude 'examples/counter/node_modules" @@ -58,6 +58,7 @@ "rxjs": "5.0.0-beta.6", "sinon": "^1.16.1", "sinon-chai": "^2.8.0", + "symbol-observable": "^1.0.1", "ts-loader": "^0.8.1", "ts-node": "^0.5.5", "tslint": "^3.11.0", diff --git a/src/___tests___/components/ng-redux.spec.ts b/src/___tests___/components/ng-redux.spec.ts index 6d061a5..20daa5c 100644 --- a/src/___tests___/components/ng-redux.spec.ts +++ b/src/___tests___/components/ng-redux.spec.ts @@ -6,7 +6,8 @@ import { NgRedux } from '../../components/ng-redux'; import { select } from '../../decorators/select'; import * as sinon from 'sinon'; import * as sinonChai from 'sinon-chai'; - +import 'rxjs/add/operator/filter'; +import 'rxjs/add/operator/combineLatest'; use(sinonChai); function returnPojo() { @@ -302,5 +303,230 @@ describe('NgRedux Observable Store', () => { 'getState', 'replaceReducer' ); + }); + + it('should wait until store is configured before emitting values', + () => { + class SomeService { + foo: string; + bar: string; + baz: number; + + constructor(private _ngRedux: NgRedux) { + _ngRedux.select(n => n.foo).subscribe(foo => this.foo = foo); + _ngRedux.select(n => n.bar).subscribe(bar => this.bar = bar); + _ngRedux.select(n => n.baz).subscribe(baz => this.baz = baz); + } + } + ngRedux = new NgRedux(mockAppRef); + + let someService = new SomeService(ngRedux); + ngRedux.configureStore(rootReducer, defaultState); + expect(someService.foo).to.be.equal('bar'); + expect(someService.bar).to.be.equal('foo'); + expect(someService.baz).to.be.equal(-1); + + }); + + it('should have select decorators work before store is configured', + (done) => { + class SomeService { + @select() foo$: any; + @select() bar$: any; + @select() baz$: any; + + } + ngRedux = new NgRedux(mockAppRef); + + let someService = new SomeService(); + someService + .foo$ + .combineLatest(someService.bar$, someService.baz$) + .subscribe(([foo, bar, baz]) => { + expect(foo).to.be.equal('bar'); + expect(bar).to.be.equal('foo'); + expect(baz).to.be.equal(-1); + done(); + }); + + ngRedux.configureStore(rootReducer, defaultState); + + }); +}); + +describe('Chained actions in subscriptions', () => { + interface IAppState { + keyword: string; + keywordLength: number; + }; + + let defaultState: IAppState; + let rootReducer; + let ngRedux; + let doSearch = (word) => { + ngRedux.dispatch({ type: 'SEARCH', payload: word }); + }; + let doFetch = (word) => { + ngRedux.dispatch({ type: 'SEARCH_RESULT', payload: word.length }); + }; + + beforeEach(() => { + defaultState = { + keyword: '', + keywordLength: -1 + + }; + + rootReducer = (state = defaultState, action) => { + switch (action.type) { + case 'SEARCH': + return Object.assign({}, state, { keyword: action.payload }); + case 'SEARCH_RESULT': + return Object.assign({}, state, { keywordLength: action.payload }); + default: + return state; + } + }; + + ngRedux = new NgRedux(); + ngRedux.configureStore(rootReducer, defaultState); + }); + + + describe('dispatching an action in a keyword$ before length$ happens', () => { + it(`length sub should be called twice`, () => { + + let keyword$ = ngRedux.select(n => n.keyword); + let keyword = ''; + let length; + let length$ = ngRedux.select(n => n.keywordLength); + let lengthSpy = sinon.spy((n) => length = n); + let lenSub; + let keywordSub; + keywordSub = keyword$. + filter(n => n !== '') + .subscribe(n => { + keyword = n; + doFetch(n); + }); + + lenSub = length$.subscribe(lengthSpy); + + expect(keyword).to.equal(''); + expect(length).to.equal(-1); + + expect(lengthSpy.calledOnce).to.be.equal(true); + + doSearch('test'); + + expect(lengthSpy.calledTwice).to.be.equal(true); + + expect(keyword).to.equal('test'); + expect(length).to.equal(4); + keywordSub.unsubscribe(); + lenSub.unsubscribe(); + }); + + it(`second sub should get most current state value`, () => { + + let keyword$ = ngRedux.select(n => n.keyword); + let keyword = ''; + let length; + let length$ = ngRedux.select(n => n.keywordLength); + let lengthSpy = sinon.spy((n) => length = n); + let lenSub; + let keywordSub; + keywordSub = keyword$. + filter(n => n !== '') + .subscribe(n => { + keyword = n; + doFetch(n); + }); + + lenSub = length$.subscribe(lengthSpy); + + expect(keyword).to.equal(''); + expect(length).to.equal(-1); + + expect(lengthSpy.calledOnce).to.be.equal(true); + + doSearch('test'); + + expect(keyword).to.equal('test'); + expect(length).to.equal(4); + keywordSub.unsubscribe(); + lenSub.unsubscribe(); + }); }); + + describe('dispatching an action in a keyword$ after length$ happens', () => { + it(`length sub should be called twice`, () => { + + let keyword$ = ngRedux.select(n => n.keyword); + let keyword = ''; + let length; + let length$ = ngRedux.select(n => n.keywordLength); + let lengthSpy = sinon.spy((n) => length = n); + let lenSub; + let keywordSub; + + lenSub = length$.subscribe(lengthSpy); + keywordSub = keyword$. + filter(n => n !== '') + .subscribe(n => { + keyword = n; + doFetch(n); + }); + + + + expect(keyword).to.equal(''); + expect(length).to.equal(-1); + + expect(lengthSpy.calledOnce).to.be.equal(true); + + doSearch('test'); + + expect(lengthSpy.calledTwice).to.be.equal(true); + + expect(keyword).to.equal('test'); + expect(length).to.equal(4); + keywordSub.unsubscribe(); + lenSub.unsubscribe(); + }); + + it(`first sub should get most current state value`, () => { + + let keyword$ = ngRedux.select(n => n.keyword); + let keyword = ''; + let length; + let length$ = ngRedux.select(n => n.keywordLength); + let lengthSpy = sinon.spy((n) => length = n); + let lenSub; + let keywordSub; + lenSub = length$.subscribe(lengthSpy); + keywordSub = keyword$. + filter(n => n !== '') + .subscribe(n => { + keyword = n; + doFetch(n); + }); + + + + expect(keyword).to.equal(''); + expect(length).to.equal(-1); + + expect(lengthSpy.calledOnce).to.be.equal(true); + + doSearch('test'); + + expect(keyword).to.equal('test'); + expect(length).to.equal(4); + keywordSub.unsubscribe(); + lenSub.unsubscribe(); + }); + }); + + }); diff --git a/src/components/ng-redux.ts b/src/components/ng-redux.ts index e71e278..580300b 100644 --- a/src/components/ng-redux.ts +++ b/src/components/ng-redux.ts @@ -12,7 +12,10 @@ import { import { Observable } from 'rxjs/Observable'; import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/filter'; +import 'rxjs/add/observable/from'; import 'rxjs/add/operator/distinctUntilChanged'; +import 'rxjs/add/operator/switchMap'; import { Injectable, Optional, ApplicationRef } from '@angular/core'; import shallowEqual from '../utils/shallow-equal'; @@ -30,8 +33,8 @@ const checkSelector = (s) => VALID_SELECTORS.indexOf(typeof s, 0) >= 0 || @Injectable() export class NgRedux { - private _store: Store; - private _store$: BehaviorSubject; + private _store: Store = null; + private _store$: BehaviorSubject = null; private _defaultMapStateToTarget: Function; private _defaultMapDispatchToTarget: Function; @@ -43,8 +46,13 @@ export class NgRedux { * The parameter is deprecated and left for backwards compatibility. * It doesn't do anything. It will be removed in a future major version. */ - constructor(@Optional() deprecated?: ApplicationRef) { - NgRedux.instance = this; + constructor( @Optional() deprecated?: ApplicationRef) { + NgRedux.instance = this; + this._store$ = new BehaviorSubject(null) + .filter(n => n !== null) + .switchMap(n => { + return Observable.from(n as any); + }) as BehaviorSubject; } /** @@ -71,7 +79,7 @@ export class NgRedux { = >compose( applyMiddleware(...middleware), ...enhancers - )(createStore); + )(createStore); const store = finalCreateStore(reducer, initState); this.setStore(store); @@ -93,15 +101,6 @@ export class NgRedux { this.setStore(store); }; - /** - * Get an observable from the attached Redux store. - * - * @returns {BehaviorSubject} - */ - observableFromStore(): BehaviorSubject { - return this._store$; - }; - /** * Select a slice of state to expose as an observable. * @@ -125,6 +124,7 @@ export class NgRedux { invariant(checkSelector(selector), ERROR_MESSAGE, selector); + if ( typeof selector === 'string' || typeof selector === 'number' || @@ -298,9 +298,7 @@ export class NgRedux { */ private setStore(store: Store) { this._store = store; - this._store$ = new BehaviorSubject(store.getState()); - this._store.subscribe(() => this._store$.next(this._store.getState())); - + this._store$.next(store as any); this._defaultMapStateToTarget = () => ({}); this._defaultMapDispatchToTarget = dispatch => ({ dispatch }); const cleanedStore = omit(store, [ @@ -310,4 +308,5 @@ export class NgRedux { 'replaceReducer']); Object.assign(this, cleanedStore); } -} +}; +