lucid-ui
Version:
A UI component library from AppNexus.
751 lines (730 loc) • 26.8 kB
JavaScript
import _toUpper from "lodash/toUpper";
import _merge from "lodash/merge";
import _isEqualWith from "lodash/isEqualWith";
import _get from "lodash/get";
import _isFunction from "lodash/isFunction";
import _assign from "lodash/assign";
import _noop from "lodash/noop";
import _xorWith from "lodash/xorWith";
import _isEqual from "lodash/isEqual";
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; }
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
import assert from 'assert';
import sinon from 'sinon';
import React from 'react';
import PropTypes from 'prop-types';
import { mount } from 'enzyme';
import { getDeepPaths, omitFunctionPropsDeep, bindReducerToState, bindReducersToState, getStatefulPropsContext, reduceSelectors, safeMerge, buildHybridComponent } from './state-management';
import { createClass } from './component-types';
describe('#getDeepPaths', function () {
it('should return an empty array when arg is empty object, null, or undefined', function () {
assert(_isEqual([], getDeepPaths({})));
assert(_isEqual([], getDeepPaths()));
assert(_isEqual([], getDeepPaths(null)));
});
it('should return an array of paths for each node with non-plain object value if arg is object', function () {
var pagedTableObj = {
rows: ['data0', 'data1'],
paginator: {
selectedPageIndex: 0,
selectedPageSize: 10,
dropselector: {
selectedIndex: 1,
options: [5, 10, 20]
}
}
};
var deepPaths = getDeepPaths(pagedTableObj);
var xorPaths = _xorWith(deepPaths, [['rows'], ['paginator', 'selectedPageIndex'], ['paginator', 'selectedPageSize'], ['paginator', 'dropselector', 'selectedIndex'], ['paginator', 'dropselector', 'options']], _isEqual);
assert(_isEqual([], xorPaths));
});
it('should return an array of paths for each node with non-plain object value if arg is array', function () {
var deepPaths = getDeepPaths(['zero', {
one: 1
}, 2]);
var xorPaths = _xorWith(deepPaths, [[0], [1, 'one'], [2]], _isEqual);
assert(_isEqual([], xorPaths));
});
});
describe('#omitFunctionPropsDeep', function () {
it('should return an empty object when arg is empty object, null, or undefined', function () {
assert(_isEqual({}, omitFunctionPropsDeep({})));
assert(_isEqual({}, omitFunctionPropsDeep(null)));
assert(_isEqual({}, omitFunctionPropsDeep()));
});
it('should transform to object without function properties', function () {
var pagedTableObj = {
rows: ['data0', 'data1'],
onRowSelect: _noop,
paginator: {
selectedPageIndex: 0,
selectedPageSize: 10,
onPageSizeSelect: _noop,
onPageSelect: _noop,
dropselector: {
selectedIndex: 1,
options: [5, 10, 20],
onSelect: _noop
}
}
};
var result = omitFunctionPropsDeep(pagedTableObj);
assert(_isEqual(result, {
rows: ['data0', 'data1'],
paginator: {
selectedPageIndex: 0,
selectedPageSize: 10,
dropselector: {
selectedIndex: 1,
options: [5, 10, 20]
}
}
}));
});
});
describe('#bindReducerToState', function () {
it('should bind a single reducer function to a state management interface', function () {
var state = {
value: null
};
var stateManager = {
getState: function getState() {
return state;
},
setState: function setState(nextState) {
state = nextState;
}
};
function setValue(state, value) {
return _assign({}, state, {
value: value
});
}
var boundSetValue = bindReducerToState(setValue, stateManager);
assert.equal(state.value, null);
boundSetValue('foo');
assert.equal(state.value, 'foo');
});
it('should bind a single, nested reducer function to a state management interface', function () {
var state = {
sub: {
value: null
}
};
var stateManager = {
getState: function getState() {
return state;
},
setState: function setState(nextState) {
state = nextState;
}
};
function setValue(state, value) {
return _assign({}, state, {
value: value
});
}
var boundSetValue = bindReducerToState(setValue, stateManager, ['sub', 'setValue']);
assert.equal(state.sub.value, null);
boundSetValue('foo');
assert.equal(state.sub.value, 'foo');
});
});
describe('#bindReducersToState', function () {
it('should bind an object of reducers functions to a state management interface', function () {
var state = {
counter: 0
};
var stateManager = {
getState: function getState() {
return state;
},
setState: function setState(nextState) {
state = nextState;
}
};
var reducers = {
increaseCounter: function increaseCounter(state) {
return _assign({}, state, {
counter: state.counter + 1
});
},
decreaseCounter: function decreaseCounter(state) {
return _assign({}, state, {
counter: state.counter - 1
});
},
setCounter: function setCounter(state, x) {
return _assign({}, state, {
counter: x
});
}
};
var boundReducers = bindReducersToState(reducers, stateManager);
assert.equal(state.counter, 0);
boundReducers.increaseCounter();
assert.equal(state.counter, 1);
boundReducers.setCounter(32);
assert.equal(state.counter, 32);
boundReducers.decreaseCounter();
assert.equal(state.counter, 31);
});
it('should bind an object of nested reducers functions to a state management interface', function () {
var state = {
name: '',
count: {
counter: 0
}
};
var stateManager = {
getState: function getState() {
return state;
},
setState: function setState(nextState) {
state = nextState;
}
};
var reducers = {
setName: function setName(state, newName) {
return _assign({}, state, {
name: newName
});
},
count: {
increaseCounter: function increaseCounter(state) {
return _assign({}, state, {
counter: state.counter + 1
});
},
decreaseCounter: function decreaseCounter(state) {
return _assign({}, state, {
counter: state.counter - 1
});
},
setCounter: function setCounter(state, x) {
return _assign({}, state, {
counter: x
});
}
}
};
var boundReducers = bindReducersToState(reducers, stateManager);
assert.equal(state.name, '');
assert.equal(state.count.counter, 0);
boundReducers.setName('Neumann');
assert.equal(state.name, 'Neumann');
boundReducers.count.increaseCounter();
assert.equal(state.count.counter, 1);
boundReducers.count.setCounter(32);
assert.equal(state.count.counter, 32);
boundReducers.count.decreaseCounter();
assert.equal(state.count.counter, 31);
assert(_isEqual(state, {
name: 'Neumann',
count: {
counter: 31
}
}));
});
});
describe('#getStatefulPropsContext', function () {
function isFunctions(objValue, othValue) {
if (_isFunction(objValue) && _isFunction(othValue)) {
return true;
}
}
it('should return an object with two functions on it', function () {
var statefulPropsContext = getStatefulPropsContext({}, {});
var getPropReplaceReducers = _get(statefulPropsContext, 'getPropReplaceReducers');
var getProps = _get(statefulPropsContext, 'getProps');
assert(_isFunction(getPropReplaceReducers));
assert(_isFunction(getProps));
});
describe('statefulPropsContext', function () {
var state;
var stateManager;
var reducers;
var statefulPropsContext;
beforeEach(function () {
state = {
name: '',
count: {
counter: 0
}
};
stateManager = {
getState: function getState() {
return state;
},
setState: function setState(nextState) {
state = nextState;
}
};
reducers = {
setName: function setName(state, newName) {
return _assign({}, state, {
name: newName
});
},
count: {
increaseCounter: function increaseCounter(state) {
return _assign({}, state, {
counter: state.counter + 1
});
},
decreaseCounter: function decreaseCounter(state) {
return _assign({}, state, {
counter: state.counter - 1
});
},
setCounter: function setCounter(state, x) {
return _assign({}, state, {
counter: x
});
}
}
};
sinon.spy(reducers, 'setName');
sinon.spy(reducers.count, 'increaseCounter');
sinon.spy(reducers.count, 'decreaseCounter');
sinon.spy(reducers.count, 'setCounter');
statefulPropsContext = getStatefulPropsContext(reducers, stateManager);
});
describe('.getProps', function () {
it('should return an object with reducers and current state merged', function () {
var props = statefulPropsContext.getProps();
assert(_isEqualWith(props, _merge({}, state, reducers), isFunctions));
});
it('should return an object with reducers and current state merged with prop arg overrides', function () {
var overrides = {
name: 'Neumann',
dead: 0xbeef
};
var props = statefulPropsContext.getProps(overrides);
assert(_isEqualWith(props, _merge({}, state, reducers, overrides), isFunctions));
});
it('should return an object with current state applied after function call modifies state', function () {
var overrides = {
name: 'Neumann'
};
var props;
props = statefulPropsContext.getProps(overrides);
assert.equal(props.count.counter, 0);
props.count.increaseCounter();
props = statefulPropsContext.getProps(overrides);
assert.equal(props.count.counter, 1);
props.count.setCounter(16);
props = statefulPropsContext.getProps(overrides);
assert.equal(props.count.counter, 16);
props.count.decreaseCounter();
props = statefulPropsContext.getProps(overrides);
assert.equal(props.count.counter, 15);
});
it('should call override function after the same reducer function', function () {
var overrides = {
setName: sinon.spy()
};
var props;
props = statefulPropsContext.getProps(overrides);
assert.equal(props.name, '');
props.setName('Neumann');
props = statefulPropsContext.getProps(overrides);
assert.equal(props.name, 'Neumann');
assert(reducers.setName.calledOnce);
assert(overrides.setName.calledOnce);
assert(reducers.setName.calledBefore(overrides.setName));
}); // Test written because of a perf issue related to cloning we ran into
// with lodash@4.7.0 -- https://github.com/appnexus/lucid/issues/181
it('should not clone arrays when the source object is undefined', function () {
var overrides = {
fresh: [{
a: 1
}]
};
var props = statefulPropsContext.getProps(overrides);
assert(overrides.fresh[0] === props.fresh[0]);
});
});
describe('.getPropReplaceReducers', function () {
it('should return an object with reducers and current state merged', function () {
var props = statefulPropsContext.getPropReplaceReducers();
assert(_isEqualWith(props, _merge({}, state, reducers), isFunctions));
});
it('should return an object with reducers and current state merged with prop arg overrides', function () {
var overrides = {
name: 'Neumann',
dead: 0xbeef
};
var props = statefulPropsContext.getPropReplaceReducers(overrides);
assert(_isEqualWith(props, _merge({}, state, reducers, overrides), isFunctions));
});
it('should return an object with current state applied after function call modifies state', function () {
var overrides = {
name: 'Neumann'
};
var props;
props = statefulPropsContext.getPropReplaceReducers(overrides);
assert.equal(props.count.counter, 0);
props.count.increaseCounter();
props = statefulPropsContext.getPropReplaceReducers(overrides);
assert.equal(props.count.counter, 1);
props.count.setCounter(16);
props = statefulPropsContext.getPropReplaceReducers(overrides);
assert.equal(props.count.counter, 16);
props.count.decreaseCounter();
props = statefulPropsContext.getPropReplaceReducers(overrides);
assert.equal(props.count.counter, 15);
});
it('should call override function instead of the reducer function', function () {
var overrides = {
setName: sinon.spy(function (state, name) {
return _assign({}, state, {
name: _toUpper(name)
});
})
};
var props;
props = statefulPropsContext.getPropReplaceReducers(overrides);
assert.equal(props.name, '');
props.setName('Neumann');
props = statefulPropsContext.getPropReplaceReducers(overrides);
assert.equal(props.name, 'NEUMANN');
assert(!reducers.setName.called);
assert(overrides.setName.calledOnce);
});
});
});
});
describe('#reduceSelectors', function () {
var selectors = {
fooAndBar: function fooAndBar(_ref) {
var foo = _ref.foo,
bar = _ref.bar;
return "".concat(foo, " and ").concat(bar);
},
incrementedBaz: function incrementedBaz(_ref2) {
var baz = _ref2.baz;
return baz + 1;
},
nested: {
nestedFooAndBar: function nestedFooAndBar(_ref3) {
var foo = _ref3.foo,
bar = _ref3.bar;
return "".concat(foo, " & ").concat(bar);
},
nestedIncrementedBaz: function nestedIncrementedBaz(_ref4) {
var baz = _ref4.baz;
return baz + 1;
},
moreNested: {
moreNestedFooAndBar: function moreNestedFooAndBar(_ref5) {
var foo = _ref5.foo,
bar = _ref5.bar;
return "".concat(foo, " & ").concat(bar);
}
}
}
};
var state = {
foo: 'foo',
bar: 'bar',
baz: 0,
nested: {
foo: 'nestedFoo',
bar: 'nestedBar',
baz: 10,
moreNested: {
foo: 'foo',
bar: 'bar'
}
}
};
var selector = reduceSelectors(selectors);
it('should create a single selector function from selector tree', function () {
var expected = {
foo: 'foo',
bar: 'bar',
baz: 0,
fooAndBar: 'foo and bar',
incrementedBaz: 1,
nested: {
foo: 'nestedFoo',
bar: 'nestedBar',
baz: 10,
nestedFooAndBar: 'nestedFoo & nestedBar',
nestedIncrementedBaz: 11,
moreNested: {
foo: 'foo',
bar: 'bar',
moreNestedFooAndBar: 'foo & bar'
}
}
};
assert.deepEqual(selector(state), expected, 'must be deeply equal');
});
it('should maintain referential equality if source does', function () {
assert.equal(selector(state), selector(state));
});
it('should maintain referential equality of branches if source does', function () {
assert.equal(selector(state).nested, selector(_objectSpread(_objectSpread({}, state), {}, {
foo: 'bar',
bar: 'foo'
})).nested);
assert.equal(selector(state).nested.moreNested, selector(_objectSpread(_objectSpread({}, state), {}, {
nested: _objectSpread(_objectSpread({}, state.nested), {}, {
foo: 'bar',
bar: 'foo'
})
})).nested.moreNested);
});
it('should throw if the selector is not an object', function () {
expect(function () {
reduceSelectors(['foo']);
}).toThrow();
});
it('should not throw if the selector is a babel esModule', function () {
/*
babel no longer creates plain javascript objects when transpiling imports
like `import * as foo from 'someSelectorFile';`. What babel imports has a
prototype and a defined `__esModule` property.
A common pattern used by consumers is to create a module of selector pure functions,
import them all, and directly pass those selectors to the stateful component.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
function mockBabelModule() {}
mockBabelModule.prototype.foo = 'bar'; // @ts-ignore
var someModule = new mockBabelModule();
someModule.someSelector = function () {};
Object.defineProperty(someModule, '__esModule', {
value: true,
enumerable: false,
writable: false
});
expect(function () {
reduceSelectors(someModule);
}).not.toThrow();
});
});
describe('#safeMerge', function () {
it('should not merge arrays', function () {
var objValue = ['foo'];
var srcValue = ['bar'];
var value = safeMerge(objValue, srcValue);
assert.deepEqual(value, srcValue, 'must be ["bar"]');
});
it('should return valid react elements', function () {
var srcValue = /*#__PURE__*/React.createElement("div", null, "foo");
var value = safeMerge({}, srcValue);
assert.equal(value, srcValue, 'must be srcValue');
});
it('should return arrays that contain react elements', function () {
var srcValue = [/*#__PURE__*/React.createElement("div", {
key: "1"
}, "foo")];
var value = safeMerge({}, srcValue);
assert.equal(value, srcValue, 'must be srcValue');
});
it('should return srcValue array if objValue is undefined', function () {
var srcValue = [];
var value = safeMerge(undefined, srcValue);
assert.equal(value, srcValue, 'must be srcValue');
});
});
describe('#buildHybridComponent', function () {
var CounterDumb = createClass({
displayName: 'Counter',
propTypes: {
count: PropTypes.number,
onIncrement: PropTypes.func,
onDecrement: PropTypes.func,
countDisplay: PropTypes.string,
countModThree: PropTypes.number
},
getDefaultProps: function getDefaultProps() {
return {
count: 0
};
},
reducers: {
onIncrement: function onIncrement(state) {
return _assign({}, state, {
count: state.count + 1
});
},
onDecrement: function onDecrement(state) {
return _assign({}, state, {
count: state.count - 1
});
}
},
selectors: {
countDisplay: function countDisplay(state) {
return "count: ".concat(state.count);
},
countModThree: function countModThree(state) {
return state.count % 3;
}
},
render: function render() {
var _ref6 = this.props,
count = _ref6.count,
countDisplay = _ref6.countDisplay,
countModThree = _ref6.countModThree,
onIncrement = _ref6.onIncrement,
onDecrement = _ref6.onDecrement;
return /*#__PURE__*/React.createElement("section", null, /*#__PURE__*/React.createElement("button", {
className: "minus",
onClick: onDecrement
}, "-"), /*#__PURE__*/React.createElement("span", {
className: "count"
}, count), /*#__PURE__*/React.createElement("span", {
className: "count-display"
}, countDisplay), /*#__PURE__*/React.createElement("span", {
className: "count-mod-three"
}, countModThree), /*#__PURE__*/React.createElement("button", {
className: "plus",
onClick: onIncrement
}, "+"));
}
});
it('should generate a stateful component from stateless component + reducers', function () {
var StatefulCounter = buildHybridComponent(CounterDumb);
var wrapper = mount( /*#__PURE__*/React.createElement(StatefulCounter, null));
var minusButton = wrapper.find('button.minus');
var countSpan = wrapper.find('.count');
var countDisplaySpan = wrapper.find('.count-display');
var countModThreeSpan = wrapper.find('.count-mod-three');
var plusButton = wrapper.find('button.plus');
assert.equal(countSpan.text(), '0');
assert.equal(countDisplaySpan.text(), 'count: 0');
assert.equal(countModThreeSpan.text(), '0');
plusButton.simulate('click');
assert.equal(countSpan.text(), '1');
assert.equal(countDisplaySpan.text(), 'count: 1');
assert.equal(countModThreeSpan.text(), '1');
plusButton.simulate('click');
assert.equal(countSpan.text(), '2');
assert.equal(countDisplaySpan.text(), 'count: 2');
assert.equal(countModThreeSpan.text(), '2');
plusButton.simulate('click');
assert.equal(countSpan.text(), '3');
assert.equal(countDisplaySpan.text(), 'count: 3');
assert.equal(countModThreeSpan.text(), '0');
minusButton.simulate('click');
assert.equal(countSpan.text(), '2');
assert.equal(countDisplaySpan.text(), 'count: 2');
assert.equal(countModThreeSpan.text(), '2');
minusButton.simulate('click');
assert.equal(countSpan.text(), '1');
assert.equal(countDisplaySpan.text(), 'count: 1');
assert.equal(countModThreeSpan.text(), '1');
});
describe('wrapped component', function () {
/* eslint-disable no-console */
var warn;
beforeEach(function () {
warn = console.warn;
console.warn = jest.fn();
});
it('should not wrap a wrapped component', function () {
var StatefulCounter = buildHybridComponent(CounterDumb);
assert.equal(StatefulCounter, buildHybridComponent(StatefulCounter));
expect(console.warn).toHaveBeenCalledWith('Lucid: you are trying to apply buildHybridComponent to Counter, which is already a hybrid component. Lucid exports hybrid components by default. To access the dumb components, use the -Dumb suffix, e.g. "ComponentDumb"');
});
afterEach(function () {
console.warn = warn;
});
/* eslint-enable no-console */
});
it('should prioritize passed-in prop values over internal state', function () {
var StatefulCounter = buildHybridComponent(CounterDumb);
var wrapper = mount( /*#__PURE__*/React.createElement(StatefulCounter, {
count: 36
}));
var minusButton = wrapper.find('button.minus');
var countSpan = wrapper.find('.count');
var plusButton = wrapper.find('button.plus');
assert.equal(countSpan.text(), '36');
plusButton.simulate('click');
assert.equal(countSpan.text(), '36');
plusButton.simulate('click');
assert.equal(countSpan.text(), '36');
plusButton.simulate('click');
assert.equal(countSpan.text(), '36');
minusButton.simulate('click');
assert.equal(countSpan.text(), '36');
minusButton.simulate('click');
assert.equal(countSpan.text(), '36');
});
it('should override initial default state with data from the `initialState` prop', function () {
var StatefulCounter = buildHybridComponent(CounterDumb);
var wrapper = mount( /*#__PURE__*/React.createElement(StatefulCounter, {
initialState: {
count: 36
}
}));
var minusButton = wrapper.find('button.minus');
var countSpan = wrapper.find('.count');
var plusButton = wrapper.find('button.plus');
assert.equal(countSpan.text(), '36');
plusButton.simulate('click');
assert.equal(countSpan.text(), '37');
plusButton.simulate('click');
assert.equal(countSpan.text(), '38');
plusButton.simulate('click');
assert.equal(countSpan.text(), '39');
minusButton.simulate('click');
assert.equal(countSpan.text(), '38');
minusButton.simulate('click');
assert.equal(countSpan.text(), '37');
});
it('should call functions passed in thru props with same name as invoked reducers', function () {
var onIncrement = sinon.spy();
var onDecrement = sinon.spy();
var StatefulCounter = buildHybridComponent(CounterDumb);
var wrapper = mount( /*#__PURE__*/React.createElement(StatefulCounter, {
onIncrement: onIncrement,
onDecrement: onDecrement
}));
var minusButton = wrapper.find('button.minus');
var countSpan = wrapper.find('.count');
var plusButton = wrapper.find('button.plus');
assert(!onIncrement.called);
assert(!onDecrement.called);
assert.equal(countSpan.text(), '0');
plusButton.simulate('click');
assert(onIncrement.calledOnce);
assert(!onDecrement.called);
assert.equal(countSpan.text(), '1');
minusButton.simulate('click');
assert(onIncrement.calledOnce);
assert(onDecrement.calledOnce);
assert.equal(countSpan.text(), '0');
plusButton.simulate('click');
assert(onIncrement.calledTwice);
assert(onDecrement.calledOnce);
assert.equal(countSpan.text(), '1');
});
it('should allow the consumer to override reducers', function () {
var onIncrement = sinon.spy();
var onDecrement = sinon.spy();
var StatefulCounter = buildHybridComponent(CounterDumb, {
reducers: {
onIncrement: onIncrement,
onDecrement: onDecrement
}
});
var wrapper = mount( /*#__PURE__*/React.createElement(StatefulCounter, null));
var minusButton = wrapper.find('button.minus');
var plusButton = wrapper.find('button.plus');
assert(!onIncrement.called);
assert(!onDecrement.called);
plusButton.simulate('click');
assert(onIncrement.calledOnce);
assert(!onDecrement.called);
minusButton.simulate('click');
assert(onIncrement.calledOnce);
assert(onDecrement.calledOnce);
});
});