lucid-ui
Version:
A UI component library from AppNexus.
431 lines (383 loc) • 11.9 kB
text/typescript
import React, { isValidElement } from 'react';
import _ from 'lodash';
import { logger } from './logger';
import { createSelector } from 'reselect';
import createClass from 'create-react-class';
import hoistNonReactStatics from 'hoist-non-react-statics';
// TODO: could we somehow type the `...args` with a generic?
export type Reducer<S extends object> = (arg0: S, ...args: any[]) => S;
export type Reducers<P, S extends object> = {
//TODO: used any here to cover cases where a component's reducers file also
//exports child component reducers, e.g. SingleSelect/DropMenu
[K in keyof P]?: Reducer<S> | Reducers<P[K], S> | Reducers<P[K], any>;
};
export type Selector<S> = (arg0: S) => any;
export type Selectors<P, S extends object> = {
[K in keyof P]?: (arg0: S) => any;
};
interface IStateOptions<S extends object> {
getState: () => S;
setState: (arg0: S) => void;
}
interface IBoundContext<P, S extends object> {
getPropReplaceReducers(props: P): {} & S & P;
getProps(props: P): {} & S & P;
}
interface IBuildHybridComponentOptions<P = {}, S extends object = {}> {
replaceEvents?: boolean; // TODO: pretty sure this isn't used in anx-react or lucid, I looked through the git history and even when Joe wrote it in 2016 he didn't seem to need it for any concrete use case
reducers?: Reducers<P, S>;
selectors?: Selectors<P, S>;
}
interface IBaseComponentType<P> {
displayName: string;
}
/*
Returns an array of paths for each reducer function
*/
export function getDeepPaths(
obj: { [k: string]: any } | null = null,
path: string[] = []
): string[][] {
return _.reduce(
obj,
(terminalKeys: string[][], value, key) =>
isPlainObjectOrEsModule(value)
? //getDeepPaths if value is a module or object (another Reducers)
terminalKeys.concat(getDeepPaths(value, path.concat(key)))
: //add key to terminalKeys (probably a Reducer (function))
terminalKeys.concat([path.concat(key)]),
[]
);
}
export function isPlainObjectOrEsModule(obj: any): boolean {
return _.isPlainObject(obj) || _.get(obj, '__esModule', false);
}
/**
Recursively removes function type properties from obj
*/
export function omitFunctionPropsDeep<P>(obj: object | P | null = null) {
return _.reduce<{ [k: string]: any }, { [k: string]: any }>(
obj,
(memo, value, key) => {
if (isPlainObjectOrEsModule(value)) {
memo[key] = omitFunctionPropsDeep(value);
} else if (!_.isFunction(value)) {
memo[key] = value;
}
return memo;
},
{}
);
}
export function bindReducerToState<P, S extends object>(
reducerFunction: Reducer<S>,
{ getState, setState }: IStateOptions<S>,
path: string[] = []
) {
const localPath = _.take(path, _.size(path) - 1);
return _.assign(
function (...args: any[]) {
if (_.isEmpty(localPath)) {
// Source of bug, `reducerFunction` returns undefined
setState(reducerFunction(getState(), ...args));
} else {
const localNextState = reducerFunction(
_.get(getState(), localPath),
...args
);
setState(_.set<S>(_.clone(getState()), localPath, localNextState));
}
},
{ path }
);
}
export function bindReducersToState<P, S extends object>(
reducers: Reducers<P, S>,
{ getState, setState }: IStateOptions<S>
) {
return _.reduce(
getDeepPaths(reducers),
(memo, path) => {
return _.set(
memo,
path,
bindReducerToState(_.get(reducers, path), { getState, setState }, path)
);
},
{}
);
}
/*
*/
export function getStatefulPropsContext<P, S extends object>(
reducers: Reducers<P, S>,
{ getState, setState }: IStateOptions<S>
): IBoundContext<P, S> {
const boundReducers = bindReducersToState(reducers, { getState, setState });
const combineFunctionsCustomizer = (objValue: any, srcValue: any) => {
if (_.isFunction(srcValue) && _.isFunction(objValue)) {
return function (...args: any[]) {
objValue(...args);
return srcValue(...args);
};
}
return safeMerge(objValue, srcValue);
};
const bindFunctionOverwritesCustomizer = (
objValue: { (...args: any[]): any; path: string[] },
srcValue: any
) => {
if (_.isFunction(srcValue) && _.isFunction(objValue)) {
return bindReducerToState(
srcValue,
{ getState, setState },
objValue.path
);
}
return safeMerge(objValue, srcValue);
};
return {
getPropReplaceReducers(props: P) {
return _.mergeWith(
{},
boundReducers,
getState(),
props,
bindFunctionOverwritesCustomizer
);
},
getProps(props: P) {
return _.mergeWith(
{},
boundReducers,
getState(),
props,
combineFunctionsCustomizer
);
},
};
}
/**
* reduceSelectors
*
* Generates a root selector from a tree of selectors
* @param {Object} selectors - a tree of selectors
* @returns {function} root selector that when called with state, calls each of
* the selectors in the tree with the state local to that selector.
*
* This function is memoized because it's recursive, and we want it to reuse
* the functions created in the recursive reduce because those functions are
* also memoized (reselect selectors are memoized with a cache of 1) and we want
* to maintain their caches.
*
* TODO: the types suck on this function but we spent a couple hours trying to
* get them to work and we couldn't figure out how to get generics to pass
* through _.memoize correctly. ¯\_(ツ)_/¯
*/
export const reduceSelectors: any = _.memoize((selectors: object) => {
if (!isPlainObjectOrEsModule(selectors)) {
throw new Error(
'Selectors must be a plain object with function or plain object values'
);
}
/**
* For each iteration of `reduceSelectors`, we return a memoized selector so
* that individual branches maintain reference equality if they haven't been
* modified, even if a sibling (and therefore the parent) has been modified.
*/
return createSelector(_.identity, (state: { [k: string]: any }) =>
_.reduce(
selectors,
(acc: object, selector: any, key: string) => ({
...acc,
[key]: _.isFunction(selector)
? selector(state)
: reduceSelectors(selector)(state[key]),
}),
state
)
);
});
export function safeMerge(objValue: any, srcValue: any) {
// don't merge arrays
if (_.isArray(srcValue) && _.isArray(objValue)) {
return srcValue;
}
// guards against traversing react elements which can cause cyclical recursion
// If we don't have this clause, lodash (as of 4.7.0) will attempt to
// deeply clone the react children, which is really freaking slow.
if (
isValidElement(srcValue) ||
(_.isArray(srcValue) && _.some(srcValue, isValidElement)) ||
(_.isArray(srcValue) && _.isUndefined(objValue))
) {
return srcValue;
}
}
export function buildHybridComponent(
baseComponent: any,
{
replaceEvents = false, // if true, function props replace the existing reducers, else they are invoked *after* state reducer returns
reducers = _.get(baseComponent, 'definition.statics.reducers', {}),
selectors = _.get(baseComponent, 'definition.statics.selectors', {}),
} = {}
) {
const {
_isLucidHybridComponent,
displayName,
propTypes,
definition: { statics = {} } = {},
defaultProps,
} = baseComponent;
if (_isLucidHybridComponent) {
logger.warnOnce(
displayName,
`Lucid: you are trying to apply buildHybridComponent to ${displayName}, which is already a hybrid component. Lucid exports hybrid components by default. To access the dumb components, use the -Dumb suffix, e.g. "ComponentDumb"`
);
return baseComponent;
}
const selector = reduceSelectors(selectors);
return createClass({
propTypes,
statics: {
_isLucidHybridComponent: true,
peekDefaultProps: defaultProps,
...statics,
},
displayName,
getInitialState() {
const { initialState } = this.props; //initial state overrides
return _.mergeWith(
{},
omitFunctionPropsDeep(baseComponent.defaultProps),
initialState,
safeMerge
);
},
UNSAFE_componentWillMount() {
let synchronousState = this.state; //store reference to state, use in place of `this.state` in `getState`
this.boundContext = getStatefulPropsContext(reducers, {
getState: () =>
_.mergeWith(
{},
omitFunctionPropsDeep(synchronousState),
omitFunctionPropsDeep(this.props),
safeMerge
),
setState: (state) => {
synchronousState = state; //synchronously update the state reference
this.setState(state);
},
});
},
render() {
if (replaceEvents) {
return React.createElement(
baseComponent,
selector(this.boundContext.getPropReplaceReducers(this.props)),
this.props.children
);
}
return React.createElement(
baseComponent,
selector(this.boundContext.getProps(this.props)),
this.props.children
);
},
});
}
export interface IHybridComponent<P, S extends object> {
reducers: Reducers<P, S>;
selectors: Selectors<P, S>;
peekDefaultProps: { [key: string]: any }; // not sure how to give this a better type
}
export function buildModernHybridComponent<
P extends object = {},
S extends object = {},
BaseType extends object = {}
>(
BaseComponent: React.ComponentType<P>,
{
replaceEvents = false,
reducers = {},
selectors = {},
}: IBuildHybridComponentOptions<P, S>
) {
// TODO: make sure hybrid components don't get double wrapped. Maybe use a type guard?
type AugmentedProps = P & { initialState?: P & S };
const selector = reduceSelectors(selectors);
class HybridComponent extends React.Component<AugmentedProps, S> {
private boundContext?: IBoundContext<P, S>;
// It would be nice to prepend "Hybrid" to this but some of our component
// sadly rely on the displayName remaining unchanged. E.g. `VerticalListMenu`.
static displayName = BaseComponent.displayName;
static propTypes = BaseComponent.propTypes;
static reducers = reducers;
static selectors = selectors;
static peekDefaultProps = BaseComponent.defaultProps;
// Note: we purposefully *do not* set defaultProps here as that would
// effectively eliminate our ability to distinguish what props the user
// explicity included.
constructor(props: AugmentedProps) {
super(props);
const { initialState } = props; // initial state overrides
this.state = _.mergeWith(
{},
omitFunctionPropsDeep(BaseComponent.defaultProps),
initialState,
safeMerge
);
}
UNSAFE_componentWillMount() {
// store reference to state, use in place of `this.state` in `getState`
let synchronousState: S = this.state;
this.boundContext = getStatefulPropsContext<P, S>(reducers, {
getState: () =>
_.mergeWith(
{},
omitFunctionPropsDeep(synchronousState),
omitFunctionPropsDeep(this.props),
safeMerge
) as S,
setState: (state) => {
synchronousState = state; //synchronously update the state reference
this.setState(state);
},
});
}
render() {
if (this.boundContext === undefined) {
return null;
}
if (replaceEvents) {
return React.createElement(
BaseComponent,
selector(this.boundContext.getPropReplaceReducers(this.props)),
this.props.children
);
}
return React.createElement(
BaseComponent,
selector(this.boundContext.getProps(this.props)),
this.props.children
);
}
}
// I used a type cast and intersection with `BaseType` here because I
// couldn't figure out any other way to generate a valid type signuture to
// reflected all the statics on the unerlying base component. @jondlm 2019-11-27
// @ts-ignore
return hoistNonReactStatics(HybridComponent, BaseComponent) as BaseType &
IHybridComponent<P, S>;
}
/*
export function buildStatefulComponent(...args: any[]) {
logger.warnOnce(
'buildHybridComponent-once',
'Lucid: buildStatefulComponent has been renamed to buildHybridComponent.'
);
// We don't really care about type checking our legacy buildHybridComponent
// @ts-ignore
return buildHybridComponent(...args);
}
*/