diff --git a/karma.conf.js b/karma.conf.js index b6a0892..c6948bf 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -12,6 +12,10 @@ module.exports = function(config) { basePath: '', frameworks: ['jasmine', 'sinon'], files: [ + // polyfill features for phantom + 'node_modules/es6-promise/dist/es6-promise.js', + + // source files 'src/**/__tests__/*spec.ts', 'src/**/__tests__/*spec.tsx' ], diff --git a/package.json b/package.json index a1b5e31..b9a7b56 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "redux": "^3.5.2" }, "devDependencies": { + "es6-promise": "^3.2.1", "jasmine-core": "2.4.1", "karma": "^1.1.2", "karma-jasmine": "1.0.2", diff --git a/src/actions/index.ts b/src/actions/index.ts index c377783..1aa17c6 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,9 +1,23 @@ -export type Action = { - type: 'INCREMENT_COUNTER', - delta: number, -} | { - type: 'RESET_COUNTER', -} +type Q = { request: T } +type S = { response: T } +type E = { error: Error } + +type QEmpty = Q +type QValue = Q<{ value: number }> + +export type Action = +// UI actions + { type: 'INCREMENT_COUNTER', delta: number } +| { type: 'RESET_COUNTER' } + +// API Requests +| ({ type: 'SAVE_COUNT_REQUEST' } & QValue) +| ({ type: 'SAVE_COUNT_SUCCESS' } & QValue & S<{}>) +| ({ type: 'SAVE_COUNT_ERROR' } & QValue & E) + +| ({ type: 'LOAD_COUNT_REQUEST' } & QEmpty) +| ({ type: 'LOAD_COUNT_SUCCESS' } & QEmpty & S<{ value: number }>) +| ({ type: 'LOAD_COUNT_ERROR' } & QEmpty & E) export const incrementCounter = (delta: number): Action => ({ type: 'INCREMENT_COUNTER', @@ -13,3 +27,27 @@ export const incrementCounter = (delta: number): Action => ({ export const resetCounter = (): Action => ({ type: 'RESET_COUNTER', }) + +export type ApiActionGroup<_Q, _S> = { + request: (q?: _Q) => Action & Q<_Q> + success: (s: _S, q?: _Q) => Action & Q<_Q> & S<_S> + error: (e: Error, q?: _Q) => Action & Q<_Q> & E +} + +export const saveCount: ApiActionGroup<{ value: number }, {}> = { + request: (request) => + ({ type: 'SAVE_COUNT_REQUEST', request }), + success: (response, request) => + ({ type: 'SAVE_COUNT_SUCCESS', request, response }), + error: (error, request) => + ({ type: 'SAVE_COUNT_ERROR', request, error }), +} + +export const loadCount: ApiActionGroup = { + request: (request) => + ({ type: 'LOAD_COUNT_REQUEST', request: null }), + success: (response, request) => + ({ type: 'LOAD_COUNT_SUCCESS', request: null, response }), + error: (error, request) => + ({ type: 'LOAD_COUNT_ERROR', request: null, error }), +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..934ff66 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,20 @@ +export const api = { + save: (counter: { value: number }): Promise => { + try { + localStorage.setItem('__counterValue', counter.value.toString()) + return Promise.resolve(null) + } + catch (e) { + return Promise.reject(e) + } + }, + load: (): Promise<{ value: number }> => { + try { + const value = parseInt(localStorage.getItem('__counterValue'), 10) + return Promise.resolve({ value }) + } + catch (e) { + return Promise.reject(e) + } + }, +} diff --git a/src/components/__tests__/counter_spec.tsx b/src/components/__tests__/counter_spec.tsx index b5b5acf..4f95536 100644 --- a/src/components/__tests__/counter_spec.tsx +++ b/src/components/__tests__/counter_spec.tsx @@ -45,10 +45,10 @@ describe('components/Counter', () => { beforeEach(() => { counter = setup() - const buttonEl = TestUtils.findRenderedDOMComponentWithTag(counter, 'button') - TestUtils.Simulate.click(buttonEl) - TestUtils.Simulate.click(buttonEl) - TestUtils.Simulate.click(buttonEl) + const [ increment ] = TestUtils.scryRenderedDOMComponentsWithTag(counter, 'button') + TestUtils.Simulate.click(increment) + TestUtils.Simulate.click(increment) + TestUtils.Simulate.click(increment) }) it('increments counter', () => { diff --git a/src/components/counter.tsx b/src/components/counter.tsx index 94c81f8..c072b2a 100644 --- a/src/components/counter.tsx +++ b/src/components/counter.tsx @@ -2,7 +2,12 @@ import * as React from 'react' import * as redux from 'redux' import { connect } from 'react-redux' -import { incrementCounter } from '../actions' +import { + incrementCounter, + saveCount, + loadCount, +} from '../actions' + import { Store } from '../reducers' type OwnProps = { @@ -15,6 +20,8 @@ type ConnectedState = { type ConnectedDispatch = { increment: (n: number) => void + save: (n: number) => void + load: () => void } const mapStateToProps = (state: Store.All, ownProps: OwnProps): ConnectedState => ({ @@ -22,9 +29,12 @@ const mapStateToProps = (state: Store.All, ownProps: OwnProps): ConnectedState = }) const mapDispatchToProps = (dispatch: redux.Dispatch): ConnectedDispatch => ({ - increment: (n: number): void => { - dispatch(incrementCounter(n)) - }, + increment: (n: number) => + dispatch(incrementCounter(n)), + load: () => + dispatch(loadCount.request()), + save: (value: number) => + dispatch(saveCount.request({ value })), }) class CounterComponent extends React.Component { @@ -34,12 +44,24 @@ class CounterComponent extends React.Component { + e.preventDefault() + this.props.save(this.props.counter.value) + } + + _onClickLoad = (e: React.SyntheticEvent) => { + e.preventDefault() + this.props.load() + } + render () { const { counter, label } = this.props return
counter = {counter.value}
+ +
} } diff --git a/src/index.tsx b/src/index.tsx index 201b8fc..25c8179 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,6 @@ import * as React from 'react' // tslint:disable-line import * as ReactDOM from 'react-dom' -import * as Redux from 'redux' +import * as redux from 'redux' import { Provider } from 'react-redux' import { @@ -10,7 +10,13 @@ import { import { Counter } from './components/counter' -let store: Redux.Store = Redux.createStore(reducers) +import { apiMiddleware } from './middleware' + +const middleware = redux.applyMiddleware( + apiMiddleware +) + +let store: redux.Store = redux.createStore(reducers, {} as Store.All, middleware) // Commented out ("let HTML app be HTML app!") window.addEventListener('DOMContentLoaded', () => { diff --git a/src/middleware/__tests__/index_spec.ts b/src/middleware/__tests__/index_spec.ts new file mode 100644 index 0000000..6affef1 --- /dev/null +++ b/src/middleware/__tests__/index_spec.ts @@ -0,0 +1,67 @@ +import * as redux from 'redux' + +import { apiMiddleware } from '../' + +import { api } from '../../api' + +import { + Action, + loadCount, + saveCount, +} from '../../actions' + +const empty = () => {} + +const mockDispatch = (dispatch: (a: Action) => void): redux.MiddlewareAPI => + ({ dispatch, getState: empty }) + +describe('apiMiddleware', () => { + + describe('when SAVE_COUNT_REQUEST succeeds', () => { + + it('includes request { value }', (done) => { + const saveStub = sinon.stub(api, 'save') + .returns(Promise.resolve({})) + + apiMiddleware(mockDispatch((actual: Action) => { + expect(saveStub.firstCall.args[0].value).toEqual(13) + saveStub.restore() + done() + }))(empty)(saveCount.request({ value: 13 })) + }) + + it('fires SAVE_COUNT_SUCCESS', (done) => { + const saveStub = sinon.stub(api, 'save') + .returns(Promise.resolve({})) + + apiMiddleware(mockDispatch((actual: Action) => { + saveStub.restore() + expect(actual.type).toEqual('SAVE_COUNT_SUCCESS') + done() + }))(empty)(saveCount.request()) + }) + + }) + + describe('when LOAD_COUNT_REQUEST succeeds', () => { + + it('fires LOAD_COUNT_SUCCESS', (done) => { + const loadStub = sinon.stub(api, 'load') + .returns(Promise.resolve({ value: 42 })) + + apiMiddleware(mockDispatch((actual: Action) => { + loadStub.restore() + + if (actual.type === 'LOAD_COUNT_SUCCESS') { + expect(42).toEqual(actual.response.value) + done() + } + else { + done.fail('types don\'t match') + } + }))(empty)(loadCount.request()) + }) + }) + + +}) diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..09fdf27 --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,30 @@ +import * as redux from 'redux' + +import { api } from '../api' + +import { + Action, + saveCount, + loadCount, +} from '../actions' + +export const apiMiddleware = ({ dispatch }: redux.MiddlewareAPI) => + (next: redux.Dispatch) => + (action: Action) => { + switch (action.type) { + + case 'SAVE_COUNT_REQUEST': + api.save(action.request) + .then(() => dispatch(saveCount.success({}, action.request))) + .catch((e) => dispatch(saveCount.error(e, action.request))) + break + + case 'LOAD_COUNT_REQUEST': + api.load() + .then(({ value }) => dispatch(loadCount.success({ value }, action.request))) + .catch((e) => dispatch(loadCount.error(e, action.request))) + break + } + + return next(action) + } diff --git a/src/reducers/__tests__/index_spec.ts b/src/reducers/__tests__/index_spec.ts index 5f5f94a..f73b348 100644 --- a/src/reducers/__tests__/index_spec.ts +++ b/src/reducers/__tests__/index_spec.ts @@ -1,7 +1,10 @@ import { createStore } from 'redux' import { reducers } from '../index' -import { incrementCounter } from '../../actions' +import { + incrementCounter, + loadCount, +} from '../../actions' describe('reducers/counter', () => { it('starts at 0', () => { @@ -19,4 +22,15 @@ describe('reducers/counter', () => { }) store.dispatch(incrementCounter(3)) }) + + it('restores state', (done) => { + const store = createStore(reducers) + store.subscribe(() => { + const { counter } = store.getState() + expect(counter.value).toEqual(14) + done() + }) + store.dispatch(loadCount.success({ value: 14 })) + }) + }) diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 8ca6fed..c279737 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -16,17 +16,20 @@ const initialState: Store.Counter = { } function counter (state: Store.Counter = initialState, action: Action): Store.Counter { - const { value } = state switch (action.type) { case 'INCREMENT_COUNTER': const { delta } = action - return { value: value + delta } + return { value: state.value + delta } case 'RESET_COUNTER': return { value: 0 } - } - return state + case 'LOAD_COUNT_SUCCESS': + return { value: action.response.value } + + default: + return state + } } export const reducers = combineReducers({ diff --git a/typings.json b/typings.json index 27a34c3..b68f482 100644 --- a/typings.json +++ b/typings.json @@ -1,10 +1,12 @@ { "name": "typescript-react-redux", "globalDependencies": { + "es6-promise": "registry:dt/es6-promise#0.0.0+20160614011821", "jasmine": "registry:dt/jasmine#2.2.0+20160621224255", "react": "registry:dt/react#0.14.0+20160805125551", "react-addons-test-utils": "registry:dt/react-addons-test-utils#0.14.0+20160427035638", - "react-dom": "registry:dt/react-dom#0.14.0+20160412154040" + "react-dom": "registry:dt/react-dom#0.14.0+20160412154040", + "sinon": "registry:dt/sinon#1.16.0+20160924120326" }, "dependencies": { "react-redux": "registry:npm/react-redux#4.4.0+20160614222153"