Skip to content
Closed
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
8 changes: 4 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ <h1>Counter Demo</h1>
...and <a href="javascript:window.location.reload()">refresh</a> this page!
</div>
</div>
<script src="node_modules/react/dist/react.js"></script>
<script src="node_modules/react-dom/dist/react-dom.js"></script>
<script src="node_modules/react/dist/react.min.js"></script>
<script src="node_modules/react-dom/dist/react-dom.min.js"></script>
<script src="dist/bundle.js"></script>

<link rel="stylesheet" href="node_modules/zakalwe/zakalwe.css" />
<link rel="stylesheet" href="node_modules/zakalwe/zakalwe.min.css" />
<style>
.demo {
display: flex;
Expand Down Expand Up @@ -87,7 +87,7 @@ <h1>Counter Demo</h1>
<section class="container copyright">
Another unrepentant production from
<a href="https://rjzaworski.com">rj zaworski</a>
&middot; <a href="https://github.com/rjz/typescript-react-redux">source code</a>
&middot; <a href="https://github.com/rjz/typescript-react-redux">source</a>
<!-- :^) -->
</section>
</footer>
Expand Down
108 changes: 52 additions & 56 deletions src/actions/__tests__/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ import * as actions from '../index'

const api: jest.Mocked<apiExports.Api> = apiExports.api as any

const eventually = (assertFn) =>
new Promise((resolve, reject) => {
setTimeout(() => {
try {
assertFn()
} catch (e) {
return reject(e)
}
resolve()
}, 1)
})

const expectRequest = (type, request, apiAction) => {
expect(apiAction.type).toEqual(type)
expect(apiAction.request).toEqual(request)
expect(apiAction.error).toBeUndefined()
expect(apiAction.response).toBeUndefined()
}

const expectResponse = (type, response, apiAction) => {
expect(apiAction.type).toEqual(type)
expect(apiAction.response).toEqual(response)
expect(apiAction.response).not.toBeUndefined()
}

const expectError = (type, error, apiAction) => {
expect(apiAction.type).toEqual(type)
expect(apiAction.response).toBeUndefined()
expect(apiAction.error).toEqual(error)
}

describe('actions', () => {
const store = () => {
const reducer = jest.fn()
Expand All @@ -14,25 +45,9 @@ describe('actions', () => {
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))
api.save.mockReturnValue(Promise.resolve({}))
})

it('sends an API request', () => {
Expand All @@ -41,13 +56,14 @@ describe('actions', () => {
})

describe('when API request succeeds', () => {
it('dispatches SAVE_COUNT_SUCCESS', () => {
it('fills out SAVE_COUNT', () => {
const { dispatch, reducer } = store()
actions.saveCount({ value: 14 })(dispatch)
return eventually(expectTypes(reducer, [
'SAVE_COUNT_REQUEST',
'SAVE_COUNT_SUCCESS',
]))
return eventually(() => {
const actions = reducer.mock.calls.map(x => x[1])
expectRequest('SAVE_COUNT', { value: 14 }, actions[0])
expectResponse('SAVE_COUNT', {}, actions[1])
})
})
})

Expand All @@ -59,26 +75,11 @@ describe('actions', () => {
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 })
const actions = reducer.mock.calls.map(x => x[1])
expectRequest('SAVE_COUNT', { value: 14 }, actions[0])
expectError('SAVE_COUNT', 'Error: something terrible happened', actions[1])
})
})
})
Expand All @@ -95,13 +96,15 @@ describe('actions', () => {
})

describe('when API request succeeds', () => {
it('dispatches LOAD_COUNT_SUCCESS', () => {
it('fills out LOAD_COUNT .response', () => {
const { dispatch, reducer } = store()
actions.loadCount()(dispatch)
return eventually(expectTypes(reducer, [
'LOAD_COUNT_REQUEST',
'LOAD_COUNT_SUCCESS',
]))

return eventually(() => {
const actions = reducer.mock.calls.map(x => x[1])
expectRequest('LOAD_COUNT', undefined, actions[0])
expectResponse('LOAD_COUNT', { value: 14 }, actions[1])
})
})

it('includes new value with LOAD_COUNT_SUCCESS', () => {
Expand All @@ -118,21 +121,14 @@ describe('actions', () => {
api.load.mockReturnValue(Promise.reject(new Error('something terrible happened')))
})

it('dispatches LOAD_COUNT_ERROR', () => {
it('fills out 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')
const actions = reducer.mock.calls.map(x => x[1])
expectRequest('LOAD_COUNT', undefined, actions[0])
expectError('LOAD_COUNT', 'Error: something terrible happened', actions[1])
})
})
})
Expand Down
63 changes: 18 additions & 45 deletions src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,23 @@ import * as redux from 'redux'
import { api } from '../api'
import * as state from '../reducers/index'

type Q<T> = { request: T }
type S<T> = { response: T }
type E = { error: Error }

type QEmpty = Q<null>
type QValue = Q<{ value: number }>
// API actions contain details on the initial request and an eventual error or
// response
type APIAction<Q, S> = {
request?: Q
response?: S
error?: string
}

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)
// API Requests implemented as partial actions
// See: https://goo.gl/FYWGpr
| ({ type: 'SAVE_COUNT' } & APIAction<{ value: number }, {}>)
| ({ type: 'LOAD_COUNT' } & APIAction<undefined, { value: number }>)

export const incrementCounter = (delta: number): Action => ({
type: 'INCREMENT_COUNTER',
Expand All @@ -33,40 +30,16 @@ 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
}

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 }),
}

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<state.All>) => {
dispatch(x.request(request))
function apiActionCreator<Q, S>(a: Action & APIAction<Q, S>, go: apiFunc<Q, S>) {
return (request?: Q) => (dispatch: redux.Dispatch<state.All>) => {
dispatch({ ...a, request })
go(request)
.then((response) => dispatch(x.success(response, request)))
.catch((e: Error) => dispatch(x.error(e, request)))
.then((response) => dispatch({ ...a, request, response }))
.catch((e: Error) => dispatch({ ...a, request, error: e.toString() }))
}
}

export const saveCount = apiActionGroupFactory(_saveCount, api.save)
export const loadCount = () => apiActionGroupFactory(_loadCount, api.load)(null)
export const saveCount = apiActionCreator({ type: 'SAVE_COUNT' }, api.save)
export const loadCount = apiActionCreator({ type: 'LOAD_COUNT' }, api.load)
8 changes: 4 additions & 4 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ const flakify = <T>(f: () => T): Promise<T> =>
)

export type Api = {
save(x: { value: number }): Promise<null>,
save(x: { value: number }): Promise<{}>,
load(): (Promise<{ value: number }>),
}

export const api: Api = {
save: (counter: { value: number }): Promise<null> => flakify(() => {
save: counter => flakify(() => {
localStorage.setItem('__counterValue', counter.value.toString())
return null
return {}
}),
load: (): Promise<{ value: number }> => flakify(() => {
load: () => flakify(() => {
const storedValue = parseInt(localStorage.getItem('__counterValue'), 10)
return {
value: storedValue || 0,
Expand Down
2 changes: 1 addition & 1 deletion src/reducers/__tests__/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('reducers/counter', () => {
done()
})
store.dispatch({
type: 'LOAD_COUNT_SUCCESS',
type: 'LOAD_COUNT',
request: {},
response: { value: 14 } })
})
Expand Down
44 changes: 20 additions & 24 deletions src/reducers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,37 +12,27 @@ export type All = {
}

function isSaving (state: boolean = false, action: Action): boolean {
switch (action.type) {
case 'SAVE_COUNT_REQUEST':
return true
case 'SAVE_COUNT_SUCCESS':
case 'SAVE_COUNT_ERROR':
return false
default:
return state
if (action.type === 'SAVE_COUNT') {
// `SAVE_COUNT` is a partial action. We'll check its payload to determine
// whether this instance describes its resolution.
// See: https://goo.gl/FYWGpr
return !action.response && !action.error
}
return state
}

function isLoading (state: boolean = false, action: Action): boolean {
switch (action.type) {
case 'LOAD_COUNT_REQUEST':
return true
case 'LOAD_COUNT_SUCCESS':
case 'LOAD_COUNT_ERROR':
return false
default:
return state
if (action.type === 'LOAD_COUNT') {
return !action.response && !action.error
}
return state
}

function error (state: string = '', action: Action): string {
switch (action.type) {
case 'LOAD_COUNT_REQUEST':
case 'SAVE_COUNT_REQUEST':
return ''
case 'LOAD_COUNT_ERROR':
case 'SAVE_COUNT_ERROR':
return action.error.toString()
case 'LOAD_COUNT':
case 'SAVE_COUNT':
return action.error || ''
default:
return state
}
Expand All @@ -61,8 +51,14 @@ function counter (state: Counter = initialState, action: Action): Counter {
case 'RESET_COUNTER':
return { value: 0 }

case 'LOAD_COUNT_SUCCESS':
return { value: action.response.value }
case 'LOAD_COUNT': {
const { response } = action
if (response) {
// If `response` is set, `LOAD_COUNT` is "resolved"
// See: https://goo.gl/FYWGpr
return { value: response.value }
}
}

default:
return state
Expand Down