lucid-ui
Version:
A UI component library from AppNexus.
341 lines (318 loc) • 9.96 kB
text/typescript
import _ from 'lodash';
import { createSelector } from 'reselect';
import { reduceSelectors, safeMerge } from './state-management';
import { logger, isDevMode } from './logger';
type Funk = (...args: any) => any;
interface IThunk {
isThunk?: boolean;
}
type FunkThunk = Funk & IThunk;
/**
* Marks a function on the reducer tree as a thunk action creator so it doesn't
* get incorporated into the redux reducer
*
* @return {function} with `isThunk` set to `true`
*/
export function thunk(fn: FunkThunk) {
fn.isThunk = true;
return fn;
}
/**
* Creates a redux reducer and connectors (inputs to redux-react's `connect`)
*
* @param {Object} param
* @param {Object} param.initialState - the initial state object that the reducer will return
* @param {Object} param.reducers - a tree of lucid reducers
* @param {string[]} param.rootPath - array of strings representing the path to local state in global state
* @param {function} param.rootSelector - a top-level selector which takes as input state that has run through every selector in param.selectors
* @param {Object} param.selectors - a tree of lucid selectors
* @return {Object} redux reducer and connectors
*/
interface IGetReduxPrimitives {
initialState: object;
reducers: object;
rootPath?: string[];
rootSelector?: (arg0: any) => any;
selectors?: object;
}
export function getReduxPrimitives({
initialState,
reducers,
rootPath = [],
rootSelector = _.identity,
selectors,
}: IGetReduxPrimitives) {
/* istanbul ignore if */
if (isDevMode && _.isEmpty(rootPath)) {
logger.warn(
`\`getReduxPrimitives\` warning:
\`rootPath\` is empty`
);
}
/* istanbul ignore if */
if (isDevMode && !initialState) {
logger.warn(
`\`getReduxPrimitives\` warning:
Missing \`initialState\` for component at \`rootPath\` ${
_.isArray(rootPath) ? rootPath.join(',') : rootPath
}
Components should have an \`initialState\` property or a \`getDefaultProps\` defined.
`
);
}
// we need this in scope so actionCreators can refer to it
let dispatchTree: object;
const reducer = createReduxReducer(reducers, initialState, rootPath);
const selector = selectors ? reduceSelectors(selectors) : _.identity;
const rootPathSelector = (state: object) =>
_.isEmpty(rootPath) ? state : _.get(state, rootPath);
const mapStateToProps = createSelector([rootPathSelector], (rootState) =>
rootSelector(selector(rootState))
);
// dispatch could be store.dispatch's return value or an async lib's return value?
const mapDispatchToProps = (dispatch: Funk) =>
getDispatchTree(reducers, rootPath, dispatch);
const devModeMapStateToProps = (rootState: object) => {
/* istanbul ignore if */
if (isDevMode && !_.has(rootState, rootPath)) {
logger.warn(
`\`getReduxPrimitives\` warning:
\`rootPath\` ${rootPath} does not exist in the redux store.
Make sure your \`rootPath\` is correct.
`
);
}
return mapStateToProps(rootState);
};
return {
reducer,
connectors: [
isDevMode ? devModeMapStateToProps : mapStateToProps,
mapDispatchToProps,
mergeProps,
],
};
/**
* @param {function} node - a node in the the reducer tree, either a reducer or a thunk
* @param {string[]} path - the path to the reducer in the reducer tree
* @param {string[]} rootPath - array of strings representing the path to local state in global state
* @return {function} action creator that returns either an action or a thunk
*/
function createActionCreator(
node: Funk | FunkThunk,
rootPath: string[],
path: string[]
) {
if ((node as FunkThunk).isThunk) {
return function thunk(...args: any[]) {
return function thunkInner(
dispatch: Funk,
getState: Funk,
...rest: any[]
) {
const pathToLocalDispatchTree = _.slice(path, rootPath.length, -1);
const pathToLocalState = _.dropRight(path);
const localDispatchTree = _.isEmpty(pathToLocalDispatchTree)
? dispatchTree
: _.get(dispatchTree, pathToLocalDispatchTree);
const getLocalState = _.isEmpty(pathToLocalState)
? getState
: () => _.get(getState(), pathToLocalState);
return node(...args)(
localDispatchTree,
getLocalState,
dispatch,
getState,
...rest
);
};
};
}
return function actionCreator(...args: any[]) {
const [payload, ...meta] = isDevMode ? cleanArgs(args) : args;
return {
type: path.join('.'),
payload,
meta,
};
};
}
/**
* Walks the reducer tree and generates a tree of action creators that correspond to each reducer
* @param {Object} reducers - a tree of lucid reducers
* @param {string[]} rootPath - array of strings representing the path to local state in global state
* @returns {Object} action creator tree
*/
function createActionCreatorTree(
reducers: object,
rootPath: string[],
path: string[] = rootPath
): object {
return _.reduce(
reducers,
(memo, node, key) => {
const currentPath = path.concat(key);
return {
...memo,
[key]: _.isFunction(node)
? createActionCreator(node, rootPath, currentPath)
: createActionCreatorTree(node, rootPath, currentPath),
};
},
{}
);
}
/**
* Walks the reducer tree and generates an action creator tree, then binds dispatch to each node
* @param {Object} reducers - a tree of lucid reducers
* @param {string[]} rootPath - array of strings representing the path to local state in global state
* @param {function} dispatch - the redux store's `dispatch` function
*/
function getDispatchTree(
reducers: object,
rootPath: string[],
dispatch: Funk
) {
const actionCreatorTree = createActionCreatorTree(reducers, rootPath);
dispatchTree = bindActionCreatorTree(actionCreatorTree, dispatch);
/* istanbul ignore if */
if (isDevMode) {
//@ts-ignore
window.lucidReduxUtil = window.lucidReduxUtil || {};
//@ts-ignore
window.lucidReduxUtil[rootPath] = {
actionCreatorTree,
dispatchTree,
};
}
return dispatchTree;
}
}
/**
* Walks the reducer tree and generates a tree of redux reducers, converting the
* signature from `(state, payload) => state` to `(state, action) => state`
* @param {Object} reducers - a tree of lucid reducers
* @param {string[]} path - array of strings representing the path to the reducer
* @return {Object} redux reducer tree
*/
type PayloadReducer = (state: object, payload: any, ...args: any) => any;
type ActionReducer = (state: object, action: object, ...args: any) => any;
function createReduxReducerTree(reducers: object, path: string[] = []): object {
return _.reduce(
reducers,
(memo, node, key) => {
// filter out thunks from the reducer tree
if ((node as FunkThunk).isThunk) {
return memo;
}
const currentPath = path.concat(key);
return {
...memo,
[key]: _.isFunction(node)
? function reduxReducer(
state: object,
action: {
type: any;
payload: any;
meta: [];
}
) {
const { type, payload, meta = [] } = action;
if (_.isUndefined(state) || type !== currentPath.join('.')) {
return state;
}
return (node as PayloadReducer)(state, payload, ...meta);
}
: createReduxReducerTree(node, currentPath),
};
},
{}
);
}
/**
* Returns a function that calls every reducer in the reducer tree with the reducer's local state and action
* @param {Object} reduxReducerTree - tree of redux reducers with signature `(state, action) => state`
* @param {Object} initialState - the initial state object that the reducer will return
* @return {function} the redux reducer
*/
function createReducerFromReducerTree(
reduxReducerTree: object,
initialState: object
) {
return function reduxReducer(state: any, action: object): object | Funk {
if (_.isUndefined(state)) {
return initialState;
}
return _.reduce(
reduxReducerTree,
(state, node, key) => {
return {
...state,
...(_.isFunction(node)
? (node as ActionReducer)(state, action)
: {
[key]: createReducerFromReducerTree(node, {})(
state[key],
action
),
}),
};
},
state
);
};
}
/**
* Generates a redux reducer from a tree of lucid reducers
* @param {Object} reducers - a tree of lucid reducers
* @param {Object} initialState - the initial state object that the reducer will return
* @param {string[]} rootPath - array of strings representing the path to part of global state this reducer applies to
* @return {function} the redux reducer
*/
function createReduxReducer(
reducers: object,
initialState: object,
rootPath: string[]
) {
const reducerTree = createReduxReducerTree(reducers, rootPath);
return createReducerFromReducerTree(reducerTree, initialState);
}
/**
* Binds redux store.dispatch to actionCreators in a tree
* @param {Object} actionCreatorTree - a tree of redux action creator functions
* @param {function} dispatch - the redux store's `dispatch` function
* @param {string[]} path - array of strings representing the path to the action creator
*/
function bindActionCreatorTree(
actionCreatorTree: any,
dispatch: Funk,
path: string[] = []
): object {
return _.reduce(
actionCreatorTree,
(memo, node, key: string) => ({
...memo,
[key]: _.isFunction(node)
? function boundActionCreator(...args: any) {
const action = actionCreatorTree[key](...args);
return dispatch(action);
}
: bindActionCreatorTree(node, dispatch, path.concat(key)),
}),
// @ts-ignore
{}
);
}
/**
* Merges state, dispatchTree, and ownProps into a single props object
* @param {Object} state
* @param {Object} dispatchTree
* @param {Object} ownProps
* @return {Object}
*/
const mergeProps = _.memoize((state, dispatchTree, ownProps) => {
return _.mergeWith({}, state, dispatchTree, ownProps, safeMerge);
});
export function cleanArgs(args: any[]) {
return _.has(_.last(args), 'event') ? _.dropRight(args) : args;
}