Add async middleware
This commit is contained in:
parent
e9926e3484
commit
69122466b1
59
src/client/async/action.ts
Normal file
59
src/client/async/action.ts
Normal 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
|
||||
}
|
||||
}
|
||||
128
src/client/async/middleware.test.ts
Normal file
128
src/client/async/middleware.test.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
36
src/client/async/middleware.ts
Normal file
36
src/client/async/middleware.ts
Normal 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)
|
||||
}
|
||||
18
src/client/async/reducer.ts
Normal file
18
src/client/async/reducer.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user