reprehenderitiure
Version:
Create actions that return promises, which are resolved or rejected by a redux saga
253 lines (214 loc) • 10.3 kB
text/typescript
import { Dispatch, Middleware, MiddlewareAPI } from "redux";
import {
ActionCreatorWithPayload,
ActionCreatorWithPreparedPayload, createAction, PayloadAction, PayloadActionCreator, PrepareAction,
} from "@reduxjs/toolkit";
import { isFunction, merge } from "lodash";
import { call, CallEffect, SagaReturnType } from "redux-saga/effects";
import { ArgumentError } from "./ArgumentError";
import { ConfigurationError } from "./ConfigurationError";
type PromiseFromMeta<V> = {
promise?: Promise<V>;
};
type PromiseActionsFromMeta<V, T extends string> = {
promiseActions: {
resolved: ActionCreatorWithPayload<V, T>;
rejected: ActionCreatorWithPayload<any, `${T}/rejected`>;
},
} & PromiseFromMeta<V>;
type PromiseResolutionFromMeta<V> = {
promiseResolution: {
resolve: (value: V) => void;
reject: (error: any) => void;
}
};
type SagaPromiseMeta<V, T extends string> = PromiseActionsFromMeta<V, T>;
type SagaPromiseMetaMutated<V, T extends string> = SagaPromiseMeta<V, T> & PromiseResolutionFromMeta<V>;
type SagaPromiseActionBase<V, P, T extends string, M extends PromiseActionsFromMeta<V, T>> = PayloadAction<P, T, M, never>;
type SagaPromiseAction<V, P, T extends string> = SagaPromiseActionBase<V, P, T, SagaPromiseMeta<V, T>>;
type SagaPromiseActionMutated<V, P, T extends string> = SagaPromiseActionBase<V, P, T, SagaPromiseMetaMutated<V, T>>;
type ActionCreatorWithPreparedPayloadAndMeta<V, P, T extends string, M extends PromiseActionsFromMeta<V, T>, PA extends PrepareAction<any>> =
ActionCreatorWithPreparedPayload<Parameters<PA>, P, T, never, ReturnType<PA> extends {
meta: infer InferM & M;
} ? InferM : M>;
type Sagas<V, P, T extends string> = {
implement: (action: SagaPromiseAction<V, P, T>, executor: TriggerExecutor<V>) => Generator<CallEffect<SagaReturnType<TriggerExecutor<V>>>, void, any>,
resolve: (action: SagaPromiseAction<V, P, T>, value: V) => ReturnType<typeof resolvePromiseAction>,
reject: (action: SagaPromiseAction<V, P, T>, error: any) => ReturnType<typeof rejectPromiseAction>
};
type SagasFromAction<V, P, T extends string> = {
sagas: Sagas<V, P, T>
};
type TypesFromAction<V, P, T extends string, M extends PromiseActionsFromMeta<V, T>> = {
types: {
triggerAction: SagaPromiseActionBase<V, P, T, M>,
resolvedAction: PayloadAction<V, T>,
rejectedAction: PayloadAction<any, `${T}/rejected`>
promise: Promise<V>,
resolveValue: V
}
};
type TriggerActionCreator<V, P, T extends string, M extends PromiseActionsFromMeta<V, T>, TPAC extends PayloadActionCreator<any, any>> = ActionCreatorWithPreparedPayloadAndMeta<V, P, T, M, TPAC>;
type SagaPromiseActionCreatorBase<V, P, T extends string, M extends PromiseActionsFromMeta<V, T>, TPAC extends PayloadActionCreator<any, any>> = TriggerActionCreator<V, P, T, M, TPAC> & {
trigger: SagaPromiseActionCreatorBase<V, P, T, M, TPAC>
resolved: ActionCreatorWithPayload<V, T>;
rejected: ActionCreatorWithPayload<any, `${T}/rejected`>;
} & SagasFromAction<V, P, T> & TypesFromAction<V, P, T, M>;
export type SagaPromiseActionCreator<V, P, T extends string, TPAC extends PayloadActionCreator<any, any>> = SagaPromiseActionCreatorBase<V, P, T, SagaPromiseMeta<V, T>, TPAC>;
function isTriggerAction(action: SagaPromiseAction<any, any, any>) {
return action?.meta?.promiseActions.resolved != null;
}
function isActionSagaPromise(action: SagaPromiseAction<any, any, any>, method): action is SagaPromiseActionMutated<any, any, any> {
if (!isTriggerAction(action)) throw new ArgumentError(`redux-saga-promise: ${method}: first argument must be promise trigger action, got ${action}`);
if (!isFunction((action as SagaPromiseActionMutated<any, any, any>)?.meta?.promiseResolution?.resolve)) throw new ConfigurationError(`redux-saga-promise: ${method}: Unable to execute--it seems that promiseMiddleware has not been not included before SagaMiddleware`);
return true;
}
type ResolveValueFromTriggerAction<TAction> = TAction extends {
meta: PromiseFromMeta<infer V>;
} ? V : never;
function resolvePromise(action: SagaPromiseActionMutated<any, any, any>, value: any) {
return action.meta.promiseResolution.resolve(value);
}
function rejectPromise(action: SagaPromiseActionMutated<any, any, any>, error: any) {
return action.meta.promiseResolution.reject(error);
}
/**
* Saga to resolve or reject promise depending on the executor function returns or throws.
*
* @param executor A function that returns a value or throws a error that get applied to promise.
*/
export function* implementPromiseAction<TAction extends SagaPromiseAction<any, any, any>>(action: TAction, executor: TriggerExecutor<ResolveValueFromTriggerAction<TAction>>) {
if (!isActionSagaPromise(action, "implementPromiseAction")) {
return; // Never hit, exception is thrown before
}
try {
resolvePromise(action, yield call(executor));
} catch (error) {
rejectPromise(action, error);
}
}
/**
* Saga to resolve a promise.
*/
export function* resolvePromiseAction<TAction extends SagaPromiseAction<any, any, any>>(action: TAction, value: ResolveValueFromTriggerAction<TAction>) {
if (!isActionSagaPromise(action, "resolvePromiseAction")) {
return; // Never hit, exception is thrown before
}
yield call(resolvePromise, action, value);
}
/**
* Saga to reject a promise.
*/
export function* rejectPromiseAction<TAction extends SagaPromiseAction<any, any, any>>(action: TAction, error: any) {
if (!isActionSagaPromise(action, "rejectPromiseAction")) {
return; // Never hit, exception is thrown before
}
yield call(rejectPromise, action, error);
}
function createPromiseActions<V, T extends string>(type: T) {
return {
resolvedAction: createAction<V>(`${type}/resolved`),
rejectedAction: createAction<any>(`${type}/rejected`),
};
}
type TriggerExecutor<RT> = (() => PromiseLike<RT> | RT | Iterator<any, RT, any>);
function wrapTriggerAction<V, P, T extends string, TPAC extends PayloadActionCreator<any, any>>(
type: T,
triggerAction: TPAC,
): SagaPromiseActionCreator<V, P, T, TPAC> {
const { resolvedAction, rejectedAction } = createPromiseActions<V, T>(type);
const updatedTrigger = <TriggerActionCreator<V, P, T, SagaPromiseMeta<V, T>, TPAC>>createAction(type, (...args: any[]) => merge(triggerAction.apply(null, args), {
meta: <SagaPromiseMeta<V, T>>{
promiseActions: {
resolved: resolvedAction,
rejected: rejectedAction,
},
},
}));
const sagas = <Sagas<V, P, T>>{
implement: implementPromiseAction,
resolve: resolvePromiseAction,
reject: rejectPromiseAction,
};
return <any>Object.assign(updatedTrigger, {
trigger: updatedTrigger,
resolved: resolvedAction,
rejected: rejectedAction,
sagas,
});
}
function createPromiseAction<V = any, P = void, T extends string = string>(type: T) {
const triggerAction = createAction<P, T>(type);
return wrapTriggerAction<V, P, T, typeof triggerAction>(
type,
triggerAction,
);
}
function createPreparedPromiseAction<V, PA extends PrepareAction<any> = PrepareAction<any>, T extends string = string>(type: T, prepareAction: PA) {
const triggerAction = createAction<PA, T>(type, prepareAction);
return wrapTriggerAction<V, ReturnType<PA>["payload"], T, typeof triggerAction>(
type,
triggerAction,
);
}
/**
* @template V Resolve type contraint for promise.
*/
export function promiseActionFactory<V = any>() {
return {
/**
* A utility function to create an action creator for the given action type
* string. The action creator accepts a single argument, which will be included
* in the action object as a field called payload. The action creator function
* will also have its toString() overriden so that it returns the action type,
* allowing it to be used in reducer logic that is looking for that action type.
* The created action contains promise actions to make redux-saga-promise work.
*
* @param type The action type to use for created actions.
*/
simple: <P = void, T extends string = string>(type: T) => createPromiseAction<V, P>(type),
/**
* A utility function to create an action creator for the given action type
* string. The action creator accepts a single argument, which will be included
* in the action object as a field called payload. The action creator function
* will also have its toString() overriden so that it returns the action type,
* allowing it to be used in reducer logic that is looking for that action type.
* The created action contains promise actions to make redux-saga-promise work.
*
* @param type The action type to use for created actions.
* @param prepare (optional) a method that takes any number of arguments and returns { payload } or { payload, meta }.
* If this is given, the resulting action creator will pass its arguments to this method to calculate payload & meta.
*/
advanced: <PA extends PrepareAction<any> = PrepareAction<any>, T extends string = string>(type: T, prepareAction: PA) => createPreparedPromiseAction<V, PA>(type, prepareAction),
};
}
/**
* For a trigger action a promise is created and returned, and the action's
* meta.promise is augmented with resolve and reject functions for use
* by the sagas. (This middleware must come before sagaMiddleware so that
* the sagas will have those functions available.)
*
* Non-actionPromiseFactory actions won't get processed in any kind.
*/
export const promiseMiddleware: Middleware = (store: MiddlewareAPI) => (next: Dispatch) => (action) => {
if (isTriggerAction(action)) {
const promise = new Promise((resolve, reject) => next(merge(action, {
meta: {
promiseResolution: {
resolve: (value) => {
resolve(value);
store.dispatch(action.meta.promiseActions.resolved(value));
},
reject: (error) => {
reject(error);
store.dispatch(action.meta.promiseActions.rejected(error));
},
},
} as PromiseResolutionFromMeta<any>,
})));
return merge(promise, { meta: { promise } as PromiseFromMeta<any> });
}
return next(action);
};
export * from "./ArgumentError";
export * from "./ConfigurationError";