Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/__mocks__/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const api = {
save: jest.fn(),
load: jest.fn(),
}
138 changes: 138 additions & 0 deletions src/actions/__tests__/index_spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
})
})
23 changes: 21 additions & 2 deletions src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import * as redux from 'redux'

import { api } from '../api'
import { Store } from '../reducers/index'

type Q<T> = { request: T }
type S<T> = { response: T }
type E = { error: Error }
Expand Down Expand Up @@ -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) =>
Expand All @@ -43,11 +48,25 @@ export const saveCount: ApiActionGroup<{ value: number }, {}> = {
({ type: 'SAVE_COUNT_ERROR', request, error }),
}

export const loadCount: ApiActionGroup<null, { value: number }> = {
const _loadCount: ApiActionGroup<null, { value: number }> = {
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 }),
}

type apiFunc<Q, S> = (q: Q) => Promise<S>

function apiActionGroupFactory<Q, S>(x: ApiActionGroup<Q, S>, go: apiFunc<Q, S>) {
return (request: Q) => (dispatch: redux.Dispatch<Store.All>) => {
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)
6 changes: 3 additions & 3 deletions src/components/counter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { connect } from 'react-redux'

import {
incrementCounter,
saveCount,
loadCount,
saveCount,
} from '../actions'

import { Store } from '../reducers'
Expand Down Expand Up @@ -38,9 +38,9 @@ const mapDispatchToProps = (dispatch: redux.Dispatch<Store.All>): 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<ConnectedState & ConnectedDispatch & OwnProps, {}> {
Expand Down
11 changes: 5 additions & 6 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -10,14 +11,12 @@ import {

import { Counter } from './components/counter'

import { apiMiddleware } from './middleware'

const middleware = redux.applyMiddleware(
apiMiddleware
let store: redux.Store<Store.All> = redux.createStore(
reducers,
{} as Store.All,
redux.applyMiddleware(thunk),
)

let store: redux.Store<Store.All> = 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')
Expand Down
67 changes: 0 additions & 67 deletions src/middleware/__tests__/index_spec.ts

This file was deleted.

31 changes: 0 additions & 31 deletions src/middleware/index.ts

This file was deleted.

7 changes: 4 additions & 3 deletions src/reducers/__tests__/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { createStore } from 'redux'
import { reducers } from '../index'
import {
incrementCounter,
loadCount,
} from '../../actions'

describe('reducers/counter', () => {
Expand All @@ -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 } })
})

})