Add async middleware

This commit is contained in:
Jerko Steiner 2019-11-13 16:23:35 -03:00
parent e9926e3484
commit 69122466b1
4 changed files with 241 additions and 0 deletions

View File

@ -0,0 +1,59 @@
import { Action } from 'redux'
export type PendingAction<T extends string, P> = Action<T> & Promise<P> & {
status: 'pending'
}
export type ResolvedAction<T extends string, P> = Action<T> & {
payload: P
status: 'resolved'
}
export type RejectedAction<T extends string> = Action<T> & {
payload: Error
status: 'rejected'
}
export type AsyncAction<T extends string, P> =
PendingAction<T, P> |
ResolvedAction<T, P> |
RejectedAction<T>
export type GetAsyncAction<A> =
A extends PendingAction<infer T, infer P>
? AsyncAction<T, P>
: A extends ResolvedAction<infer T, infer P>
? AsyncAction<T, P>
: never
export type GetAllActions<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => infer R
? R
: never
}[keyof T]
export type GetAllAsyncActions<T> = GetAsyncAction<GetAllActions<T>>
function isPromise(value: unknown): value is Promise<unknown> {
return value && typeof value === 'object' &&
typeof (value as Promise<unknown>).then === 'function'
}
export function isPendingAction(
value: unknown,
): value is PendingAction<string, unknown> {
return isPromise(value) &&
typeof (value as unknown as { type: 'string' }).type === 'string'
}
export function makeAction<A extends unknown[], T extends string, P>(
type: T,
impl: (...args: A) => Promise<P>,
): (...args: A) => PendingAction<T, P>{
return (...args: A) => {
const pendingAction= impl(...args) as PendingAction<T, P>
pendingAction.type = type
pendingAction.status = 'pending'
return pendingAction
}
}

View File

@ -0,0 +1,128 @@
import { GetAllAsyncActions, makeAction } from './action'
import { middleware } from './middleware'
import { reduce } from './reducer'
import { createStore, applyMiddleware, combineReducers } from 'redux'
describe('middleware', () => {
interface State {
sum: number
status: 'pending' | 'resolved' | 'rejected'
}
const defaultState: State = {
status: 'resolved',
sum: 0,
}
const actions = {
add: makeAction('add', async (a: number, b: number) => {
return {a, b}
}),
subtract: makeAction('subtract', async (a: number, b: number) => {
return {a, b}
}),
reject: makeAction('reject', async (a: number, b: number) => {
throw new Error('Test reject')
}),
}
type Action = GetAllAsyncActions<typeof actions>
function result(state = defaultState, action: Action): State {
switch (action.type) {
case 'add':
return reduce(
state,
action,
(state, pending) => ({
status: pending.status,
sum: state.sum,
}),
(state, resolved) => ({
status: resolved.status,
sum: resolved.payload.a + resolved.payload.b,
}),
(state, rejected) => ({status: rejected.status, sum: 0}),
)
case 'subtract':
return reduce(
state,
action,
(state, pending) => ({
status: pending.status,
sum: state.sum,
}),
(state, resolved) => ({
status: resolved.status,
sum: resolved.payload.a - resolved.payload.b,
}),
(state, rejected) => ({status: rejected.status, sum: 0}),
)
case 'reject':
return reduce(
state,
action,
(state, pending) => ({
status: pending.status,
sum: state.sum,
}),
(state, resolved) => ({
status: resolved.status,
sum: 0,
}),
(state, rejected) => ({status: rejected.status, sum: 0}),
)
default:
return state
}
}
function getStore() {
return createStore(
combineReducers({ result }),
applyMiddleware(middleware),
)
}
describe('pending and resolved', () => {
it('makes it easy to dispatch async actions for redux', async () => {
const store = getStore()
await store.dispatch(actions.add(1, 2))
expect(store.getState()).toEqual({
result: {
status: 'resolved',
sum: 3,
},
})
await store.dispatch(actions.subtract(1, 2))
expect(store.getState()).toEqual({
result: {
status: 'resolved',
sum: -1,
},
})
})
})
describe('rejected', () => {
it('handles rejected actions', async () => {
const store = getStore()
let error!: Error
try {
await store.dispatch(actions.reject(1, 2))
} catch (err) {
error = err
}
expect(error).toBeTruthy()
expect(error.message).toBe('Test reject')
expect(store.getState()).toEqual({
result: {
status: 'rejected',
sum: 0,
},
})
})
})
})

View File

@ -0,0 +1,36 @@
import { AnyAction, Middleware } from 'redux'
import { isPendingAction, ResolvedAction, PendingAction, RejectedAction } from './action'
export const middleware: Middleware = store => next => (action: AnyAction) => {
if (!isPendingAction(action)) {
return next(action)
}
const promise = action
.then(payload => {
const resolvedAction: ResolvedAction<string, unknown> = {
payload,
type: action.type,
status: 'resolved',
}
store.dispatch(resolvedAction)
})
// Propagate this action. Only attach listeners to the promise.
next({
type: action.type,
status: 'pending',
})
const promise2 = promise
.catch((err: Error) => {
const rejectedAction: RejectedAction<string> = {
payload: err,
type: action.type,
status: 'rejected',
}
store.dispatch(rejectedAction)
})
return promise2.then(() => action)
}

View File

@ -0,0 +1,18 @@
import { AsyncAction, PendingAction, ResolvedAction, RejectedAction } from './action'
export function reduce<State, T extends string, P>(
state: State,
action: AsyncAction<T, P>,
handlePending: (state: State, action: PendingAction<T, P>) => State,
handleResolved: (state: State, action: ResolvedAction<T, P>) => State,
handleRejected: (state: State, action: RejectedAction<T>) => State,
): State {
switch (action.status) {
case 'pending':
return handlePending(state, action)
case 'resolved':
return handleResolved(state, action)
case 'rejected':
return handleRejected(state, action)
}
}