UNPKG

lucid-ui

Version:

A UI component library from AppNexus.

751 lines (730 loc) 26.8 kB
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); }); });