redux-logic
Version:
Redux middleware for organizing all your business logic. Intercept actions and perform async processing.
303 lines (279 loc) • 9.69 kB
JavaScript
import { Subject, BehaviorSubject } from 'rxjs';
import { map, scan, takeWhile } from 'rxjs/operators';
import wrapper from './logicWrapper';
import { identityFn, stringifyType } from './utils';
const debug = (/* ...args */) => {};
const OP_INIT = 'init'; // initial monitor op before anything else
/**
Builds a redux middleware for handling logic (created with
createLogic). It also provides a way to inject runtime dependencies
that will be provided to the logic for use during its execution hooks.
This middleware has two additional methods:
- `addLogic(arrLogic)` adds additional logic dynamically
- `replaceLogic(arrLogic)` replaces all logic, existing logic should still complete
@param {array} arrLogic array of logic items (each created with
createLogic) used in the middleware. The order in the array
indicates the order they will be called in the middleware.
@param {object} deps optional runtime dependencies that will be
injected into the logic hooks. Anything from config to instances
of objects or connections can be provided here. This can simply
testing. Reserved property names: getState, action, and ctx.
@returns {function} redux middleware with additional methods
addLogic and replaceLogic
*/
export default function createLogicMiddleware(arrLogic = [], deps = {}) {
if (!Array.isArray(arrLogic)) {
throw new Error('createLogicMiddleware needs to be called with an array of logic items');
}
const duplicateLogic = findDuplicates(arrLogic);
if (duplicateLogic.length) {
throw new Error(`duplicate logic, indexes: ${duplicateLogic}`);
}
const actionSrc$ = new Subject(); // mw action stream
const monitor$ = new Subject(); // monitor all activity
const lastPending$ = new BehaviorSubject({ op: OP_INIT });
monitor$
.pipe(
scan(
(acc, x) => {
// append a pending logic count
let pending = acc.pending || 0;
switch (
x.op // eslint-disable-line default-case
) {
case 'top': // action at top of logic stack
case 'begin': // starting into a logic
pending += 1;
break;
case 'end': // completed from a logic
case 'bottom': // action cleared bottom of logic stack
case 'nextDisp': // action changed type and dispatched
case 'filtered': // action filtered
case 'dispatchError': // error when dispatching
case 'cancelled': // action cancelled before intercept complete
// dispCancelled is not included here since
// already accounted for in the 'end' op
pending -= 1;
break;
default:
}
return {
...x,
pending
};
},
{ pending: 0 }
)
)
.subscribe(lastPending$); // pipe to lastPending
let savedStore;
let savedNext;
let actionEnd$;
let logicSub;
let logicCount = 0; // used for implicit naming
let savedLogicArr = arrLogic; // keep for uniqueness check
function mw(store) {
if (savedStore && savedStore !== store) {
throw new Error('cannot assign logicMiddleware instance to multiple stores, create separate instance for each');
}
savedStore = store;
return (next) => {
savedNext = next;
const { action$, sub, logicCount: cnt } = applyLogic(
arrLogic,
savedStore,
savedNext,
logicSub,
actionSrc$,
deps,
logicCount,
monitor$
);
actionEnd$ = action$;
logicSub = sub;
logicCount = cnt;
return (action) => {
debug('starting off', action);
monitor$.next({ action, op: 'top' });
actionSrc$.next(action);
return action;
};
};
}
/**
observable to monitor flow in logic
*/
mw.monitor$ = monitor$;
/**
Resolve promise when all in-flight actions are complete passing
through fn if provided
@param {function} fn optional fn() which is invoked on completion
@return {promise} promise resolves when all are complete
*/
mw.whenComplete = function whenComplete(fn = identityFn) {
return lastPending$
.pipe(
// tap(x => console.log('wc', x)), /* keep commented out */
takeWhile((x) => x.pending),
map((/* x */) => undefined) // not passing along anything
)
.toPromise()
.then(fn);
};
/**
add additional deps after createStore has been run. Useful for
dynamically injecting dependencies for the hooks. Throws an error
if it tries to override an existing dependency with a new
value or instance.
@param {object} additionalDeps object of dependencies to add
@return {undefined}
*/
mw.addDeps = function addDeps(additionalDeps) {
if (typeof additionalDeps !== 'object') {
throw new Error('addDeps should be called with an object');
}
Object.keys(additionalDeps).forEach((k) => {
const existing = deps[k];
const newValue = additionalDeps[k];
if (
typeof existing !== 'undefined' && // previously existing dep
existing !== newValue
) {
// no override
throw new Error(`addDeps cannot override an existing dep value: ${k}`);
}
// eslint-disable-next-line no-param-reassign
deps[k] = newValue;
});
};
/**
add logic after createStore has been run. Useful for dynamically
loading bundles at runtime. Existing state in logic is preserved.
@param {array} arrNewLogic array of logic items to add
@return {object} object with a property logicCount set to the count of logic items
*/
mw.addLogic = function addLogic(arrNewLogic) {
if (!arrNewLogic.length) {
return { logicCount };
}
const combinedLogic = savedLogicArr.concat(arrNewLogic);
const duplicateLogic = findDuplicates(combinedLogic);
if (duplicateLogic.length) {
throw new Error(`duplicate logic, indexes: ${duplicateLogic}`);
}
const { action$, sub, logicCount: cnt } = applyLogic(
arrNewLogic,
savedStore,
savedNext,
logicSub,
actionEnd$,
deps,
logicCount,
monitor$
);
actionEnd$ = action$;
logicSub = sub;
logicCount = cnt;
savedLogicArr = combinedLogic;
debug('added logic');
return { logicCount: cnt };
};
mw.mergeNewLogic = function mergeNewLogic(arrMergeLogic) {
// check for duplicates within the arrMergeLogic first
const duplicateLogic = findDuplicates(arrMergeLogic);
if (duplicateLogic.length) {
throw new Error(`duplicate logic, indexes: ${duplicateLogic}`);
}
// filter out any refs that match existing logic, then addLogic
const arrNewLogic = arrMergeLogic.filter((x) => savedLogicArr.indexOf(x) === -1);
return mw.addLogic(arrNewLogic);
};
/**
replace all existing logic with a new array of logic.
In-flight requests should complete. Logic state will be reset.
@param {array} arrRepLogic array of replacement logic items
@return {object} object with a property logicCount set to the count of logic items
*/
mw.replaceLogic = function replaceLogic(arrRepLogic) {
const duplicateLogic = findDuplicates(arrRepLogic);
if (duplicateLogic.length) {
throw new Error(`duplicate logic, indexes: ${duplicateLogic}`);
}
const { action$, sub, logicCount: cnt } = applyLogic(
arrRepLogic,
savedStore,
savedNext,
logicSub,
actionSrc$,
deps,
0,
monitor$
);
actionEnd$ = action$;
logicSub = sub;
logicCount = cnt;
savedLogicArr = arrRepLogic;
debug('replaced logic');
return { logicCount: cnt };
};
return mw;
}
function applyLogic(arrLogic, store, next, sub, actionIn$, deps, startLogicCount, monitor$) {
if (!store || !next) {
throw new Error('store is not defined');
}
if (sub) {
sub.unsubscribe();
}
const wrappedLogic = arrLogic.map((logic, idx) => {
const namedLogic = naming(logic, idx + startLogicCount);
return wrapper(namedLogic, store, deps, monitor$);
});
const actionOut$ = wrappedLogic.reduce((acc$, wep) => wep(acc$), actionIn$);
const newSub = actionOut$.subscribe((action) => {
debug('actionEnd$', action);
try {
const result = next(action);
debug('result', result);
} catch (err) {
// eslint-disable-next-line no-console
console.error('error in mw dispatch or next call, probably in middlware/reducer/render fn:', err);
monitor$.next({ action, err, op: 'nextError' });
}
// at this point, action is the transformed action, not original
monitor$.next({ nextAction: action, op: 'bottom' });
});
return {
action$: actionOut$,
sub: newSub,
logicCount: startLogicCount + arrLogic.length
};
}
/**
* Implement default names for logic using type and idx
* @param {object} logic named or unnamed logic object
* @param {number} idx index in the logic array
* @return {object} namedLogic named logic
*/
function naming(logic, idx) {
if (logic.name) {
return logic;
}
return {
...logic,
name: `L(${stringifyType(logic.type)})-${idx}`
};
}
/**
Find duplicates in arrLogic by checking if ref to same logic object
@param {array} arrLogic array of logic to check
@return {array} array of indexes to duplicates, empty array if none
*/
function findDuplicates(arrLogic) {
return arrLogic.reduce((acc, x1, idx1) => {
if (arrLogic.some((x2, idx2) => idx1 !== idx2 && x1 === x2)) {
acc.push(idx1);
}
return acc;
}, []);
}