diff --git a/README.md b/README.md index f651233..e46d95a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Related articles: 2. [TypeScript and Redux: adding React][article-2] 3. [TypeScript and Redux: async actions][article-3] 4. [TypeScript and Redux: unit testing with Jest][article-4] + 5. [Integrating TypeScript and redux-thunk][article-5] ## Usage @@ -30,3 +31,4 @@ Install and build demo: [article-2]: https://rjzaworski.com/2016/08/typescript-redux-and-react [article-3]: https://rjzaworski.com/2016/09/typescript-redux-async-actions [article-4]: https://rjzaworski.com/2016/12/testing-typescript-with-jest +[article-5]: https://rjzaworski.com/2017/01/typescript-redux-thunk diff --git a/package.json b/package.json index f259f91..6892941 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "dependencies": { "react": "15.4.1", "react-redux": "4.4.6", - "redux": "3.6.0" + "redux": "3.6.0", + "redux-thunk": "^2.2.0" }, "devDependencies": { "@types/react": "0.14.55", diff --git a/src/__mocks__/api.ts b/src/__mocks__/api.ts new file mode 100644 index 0000000..a007a7d --- /dev/null +++ b/src/__mocks__/api.ts @@ -0,0 +1,4 @@ +export const api = { + save: jest.fn(), + load: jest.fn(), +} diff --git a/src/actions/__tests__/index_spec.ts b/src/actions/__tests__/index_spec.ts new file mode 100644 index 0000000..41f2953 --- /dev/null +++ b/src/actions/__tests__/index_spec.ts @@ -0,0 +1,138 @@ +jest.mock('../../api') + +import { createStore } from 'redux' +import { api } from '../../api' +import * as actions from '../index' + +describe('actions', () => { + const store = () => { + const reducer = jest.fn() + const { dispatch } = createStore(reducer) + reducer.mockReset() // ignore @@redux/INIT + return { dispatch, reducer } + } + + const eventually = (assertFn) => + new Promise((resolve, reject) => { + setTimeout(() => { + try { + assertFn() + } catch (e) { + return reject(e) + } + resolve() + }, 1) + }) + + const expectTypes = (reducer, types) => + () => + expect(reducer.mock.calls.map(x => x[1].type)).toEqual(types) + + describe('.saveCount', () => { + beforeEach(() => { + api.save.mockReturnValue(Promise.resolve(null)) + }) + + it('sends an API request', () => { + actions.saveCount({ value: '14' })(jest.fn()) + expect(api.save.mock.calls.length).toEqual(1) + }) + + describe('when API request succeeds', () => { + it('dispatches SAVE_COUNT_SUCCESS', () => { + const { dispatch, reducer } = store() + actions.saveCount({ value: 14 })(dispatch) + return eventually(expectTypes(reducer, [ + 'SAVE_COUNT_REQUEST', + 'SAVE_COUNT_SUCCESS', + ])) + }) + }) + + describe('when API request fails', () => { + beforeEach(() => { + api.save.mockReturnValue(Promise.reject(new Error('something terrible happened'))) + }) + + it('dispatches SAVE_COUNT_ERROR', () => { + const { dispatch, reducer } = store() + actions.saveCount({ value: 14 })(dispatch) + return eventually(expectTypes(reducer, [ + 'SAVE_COUNT_REQUEST', + 'SAVE_COUNT_ERROR', + ])) + }) + + it('includes error message with SAVE_COUNT_ERROR', () => { + const { dispatch, reducer } = store() + actions.saveCount({ value: 14 })(dispatch) + return eventually(() => { + expect(reducer.mock.calls[1][1].error.message) + .toEqual('something terrible happened') + }) + }) + + it('includes request with SAVE_COUNT_ERROR for convenience', () => { + const { dispatch, reducer } = store() + actions.saveCount({ value: 14 })(dispatch) + return eventually(() => { + expect(reducer.mock.calls[1][1].request).toEqual({ value: 14 }) + }) + }) + }) + }) + + describe('.loadCount', () => { + beforeEach(() => { + api.load.mockReturnValue(Promise.resolve({ value: 14 })) + }) + + it('sends an API request', () => { + actions.loadCount()(jest.fn()) + expect(api.load.mock.calls.length).toEqual(1) + }) + + describe('when API request succeeds', () => { + it('dispatches LOAD_COUNT_SUCCESS', () => { + const { dispatch, reducer } = store() + actions.loadCount()(dispatch) + return eventually(expectTypes(reducer, [ + 'LOAD_COUNT_REQUEST', + 'LOAD_COUNT_SUCCESS', + ])) + }) + + it('includes new value with LOAD_COUNT_SUCCESS', () => { + const { dispatch, reducer } = store() + actions.loadCount()(dispatch) + return eventually(() => { + expect(reducer.mock.calls[1][1].response).toEqual({ value: 14 }) + }) + }) + }) + + describe('when API request fails', () => { + beforeEach(() => { + api.load.mockReturnValue(Promise.reject(new Error('something terrible happened'))) + }) + + it('dispatches LOAD_COUNT_ERROR', () => { + const { dispatch, reducer } = store() + actions.loadCount()(dispatch) + return eventually(expectTypes(reducer, [ + 'LOAD_COUNT_REQUEST', + 'LOAD_COUNT_ERROR', + ])) + }) + + it('includes error message with LOAD_COUNT_ERROR', () => { + const { dispatch, reducer } = store() + actions.loadCount()(dispatch) + return eventually(() => { + expect(reducer.mock.calls[1][1].error.message) + .toEqual('something terrible happened') + }) + }) + }) + }) +}) diff --git a/src/actions/index.ts b/src/actions/index.ts index 1aa17c6..96c3913 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,3 +1,8 @@ +import * as redux from 'redux' + +import { api } from '../api' +import { Store } from '../reducers/index' + type Q = { request: T } type S = { response: T } type E = { error: Error } @@ -34,7 +39,7 @@ export type ApiActionGroup<_Q, _S> = { error: (e: Error, q?: _Q) => Action & Q<_Q> & E } -export const saveCount: ApiActionGroup<{ value: number }, {}> = { +const _saveCount: ApiActionGroup<{ value: number }, {}> = { request: (request) => ({ type: 'SAVE_COUNT_REQUEST', request }), success: (response, request) => @@ -43,7 +48,7 @@ export const saveCount: ApiActionGroup<{ value: number }, {}> = { ({ type: 'SAVE_COUNT_ERROR', request, error }), } -export const loadCount: ApiActionGroup = { +const _loadCount: ApiActionGroup = { request: (request) => ({ type: 'LOAD_COUNT_REQUEST', request: null }), success: (response, request) => @@ -51,3 +56,17 @@ export const loadCount: ApiActionGroup = { error: (error, request) => ({ type: 'LOAD_COUNT_ERROR', request: null, error }), } + +type apiFunc = (q: Q) => Promise + +function apiActionGroupFactory(x: ApiActionGroup, go: apiFunc) { + return (request: Q) => (dispatch: redux.Dispatch) => { + dispatch(x.request(request)) + go(request) + .then((response) => dispatch(x.success(response, request))) + .catch((e: Error) => dispatch(x.error(e, request))) + } +} + +export const saveCount = apiActionGroupFactory(_saveCount, api.save) +export const loadCount = apiActionGroupFactory(_loadCount, api.load) diff --git a/src/components/counter.tsx b/src/components/counter.tsx index 84676e1..6145f11 100644 --- a/src/components/counter.tsx +++ b/src/components/counter.tsx @@ -4,8 +4,8 @@ import { connect } from 'react-redux' import { incrementCounter, - saveCount, loadCount, + saveCount, } from '../actions' import { Store } from '../reducers' @@ -38,9 +38,9 @@ const mapDispatchToProps = (dispatch: redux.Dispatch): ConnectedDispa increment: (n: number) => dispatch(incrementCounter(n)), load: () => - dispatch(loadCount.request()), + dispatch(loadCount(null)), save: (value: number) => - dispatch(saveCount.request({ value })), + dispatch(saveCount({ value })), }) class CounterComponent extends React.Component { diff --git a/src/index.tsx b/src/index.tsx index 0d5f84d..a274acd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import * as React from 'react' // tslint:disable-line import * as ReactDOM from 'react-dom' import * as redux from 'redux' import { Provider } from 'react-redux' +import thunk from 'redux-thunk' import { reducers, @@ -10,14 +11,12 @@ import { import { Counter } from './components/counter' -import { apiMiddleware } from './middleware' - -const middleware = redux.applyMiddleware( - apiMiddleware +let store: redux.Store = redux.createStore( + reducers, + {} as Store.All, + redux.applyMiddleware(thunk), ) -let store: redux.Store = redux.createStore(reducers, {} as Store.All, middleware) - // Commented out ("let HTML app be HTML app!") window.addEventListener('DOMContentLoaded', () => { const rootEl = document.getElementById('redux-app-root') diff --git a/src/middleware/__tests__/index_spec.ts b/src/middleware/__tests__/index_spec.ts deleted file mode 100644 index ae554fa..0000000 --- a/src/middleware/__tests__/index_spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as redux from 'redux' - -import { apiMiddleware } from '../' - -import { api } from '../../api' - -import * as sinon from 'sinon' - -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({ value: 13 })) - }) - - }) - - 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 deleted file mode 100644 index 5b72a02..0000000 --- a/src/middleware/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -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': - const { value } = action.request - api.save({ value }) - .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 f73b348..eefbf3c 100644 --- a/src/reducers/__tests__/index_spec.ts +++ b/src/reducers/__tests__/index_spec.ts @@ -3,7 +3,6 @@ import { createStore } from 'redux' import { reducers } from '../index' import { incrementCounter, - loadCount, } from '../../actions' describe('reducers/counter', () => { @@ -30,7 +29,9 @@ describe('reducers/counter', () => { expect(counter.value).toEqual(14) done() }) - store.dispatch(loadCount.success({ value: 14 })) + store.dispatch({ + type: 'LOAD_COUNT_SUCCESS', + request: {}, + response: { value: 14 } }) }) - })