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