diff --git a/.eslintrc b/.eslintrc index bb7b57c..12ebe06 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,7 +1,7 @@ { - "extends": "airbnb/base", + "extends": "airbnb-base", "env": { - "mocha": true + "jest": true }, "rules": { "indent": [2, 4], diff --git a/Makefile b/Makefile index dc89ffb..4673ab3 100755 --- a/Makefile +++ b/Makefile @@ -1,8 +1,5 @@ BIN=node_modules/.bin -MOCHA_ARGS= --compilers js:babel/register -MOCHA_TARGET=src/**/test*.js - clean: rm -rf lib rm -rf docs @@ -11,10 +8,10 @@ build: clean $(BIN)/babel src --out-dir lib test: lint - NODE_ENV=test $(BIN)/mocha $(MOCHA_ARGS) $(MOCHA_TARGET) + NODE_ENV=test $(BIN)/jest test-watch: lint - NODE_ENV=test $(BIN)/mocha $(MOCHA_ARGS) -w $(MOCHA_TARGET) + NODE_ENV=test $(BIN)/jest --watch lint: $(BIN)/eslint src diff --git a/README.md b/README.md index 7be7810..5069e51 100755 --- a/README.md +++ b/README.md @@ -25,8 +25,6 @@ Use the mixin function which returns a class with PropTypes and defaultProps log const ValidatingModel = propTypesMixin(Model); ``` -If `process.env.NODE_ENV === 'production'`, PropTypes checking will be disabled. - Define your concrete model, and add `propTypes` and `defaultProps` static class attributes. ```javascript @@ -44,7 +42,7 @@ Person.defaultProps = { Person.modelName = 'Person'; ``` -The mixin adds a layer of logic on top of the Model static method `create` and the instance method `update`. When calling `create`, if you have defined `defaultProps`, it'll merge the defaults with the props you passed in. Then, if you've defined `Model.propTypes`, it'll validate the props. An error will be thrown if a prop is found to be invalid. The final props (that may have been merged with defaults) will be passed to the `create` method on the superclass you passed the mixin function. +The mixin adds a layer of logic on top of the Model static method `create` and the instance method `update`. When calling `create`, if you have defined `defaultProps`, it'll merge the defaults with the props you passed in. Then, if you've defined `Model.propTypes`, it'll validate the props. The final props (that may have been merged with defaults) will be passed to the `create` method on the superclass you passed the mixin function. When you call the `modelInstance.update(attrObj)` instance method, the keys in `attrObj` will be checked against the corresponding `propTypes`, if they exist. diff --git a/package.json b/package.json index 43028c6..719c1b1 100644 --- a/package.json +++ b/package.json @@ -20,18 +20,19 @@ }, "license": "MIT", "devDependencies": { - "babel": "^5.8.24", - "babel-core": "^5.8.24", - "babel-eslint": "^4.1.5", - "chai": "^3.0.0", - "eslint": "^1.10.1", - "eslint-config-airbnb": "1.0.0", - "mocha": "^2.2.5", - "react": "^0.14.7", - "sinon": "^1.17.2", - "sinon-chai": "^2.8.0" + "babel-cli": "^6.8.0", + "babel-core": "^6.7.7", + "babel-eslint": "^6.0.4", + "babel-plugin-transform-es2015-classes": "6.18.0", + "babel-plugin-transform-runtime": "^6.8.0", + "babel-preset-es2015": "^6.6.0", + "babel-preset-stage-2": "^6.5.0", + "eslint": "^3.19.0", + "eslint-config-airbnb-base": "^11.2.0", + "eslint-plugin-import": "^2.2.0", + "jest": "^21.2.1" }, "dependencies": { - "lodash": "^4.1.0" + "prop-types": "^15.6.0" } } diff --git a/src/index.js b/src/index.js index b6a5a98..8786244 100755 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,4 @@ -import forOwn from 'lodash/forOwn'; - -function validateProp(validator, props, key, modelName) { - const result = validator(props, key, modelName, 'prop'); - if (result instanceof Error) { - throw result; - } -} +import { PropTypes } from 'prop-types'; function hasPropTypes(obj) { return typeof obj.propTypes === 'object'; @@ -15,18 +8,12 @@ function hasDefaultProps(obj) { return typeof obj.defaultProps === 'object'; } -function validateProps(props, propTypes, modelName) { - forOwn(propTypes, (validator, key) => { - validateProp(validator, props, key, modelName); - }); -} - export function getPropTypesMixin(userOpts) { const opts = userOpts || {}; let useValidation; - if (opts.hasOwnProperty('validate')) { + if (Object.prototype.hasOwnProperty.call(opts, 'validate')) { useValidation = opts.validate; } else if (process) { useValidation = process.env.NODE_ENV !== 'production'; @@ -34,7 +21,7 @@ export function getPropTypesMixin(userOpts) { useValidation = true; } - const useDefaults = opts.hasOwnProperty('useDefaults') + const useDefaults = Object.prototype.hasOwnProperty.call(opts, 'useDefaults') ? opts.useDefaults : true; @@ -50,7 +37,8 @@ export function getPropTypesMixin(userOpts) { const propsWithDefaults = Object.assign({}, defaults, props); if (useValidation && hasPropTypes(this)) { - validateProps(propsWithDefaults, this.propTypes, this.modelName + '.create'); + PropTypes.checkPropTypes(this.propTypes, propsWithDefaults, 'prop', + `${this.modelName}.create`); } return super.create(propsWithDefaults, ...rest); @@ -68,12 +56,14 @@ export function getPropTypesMixin(userOpts) { // Run validators for only the props passed in, not // all declared PropTypes. - forOwn(props, (val, key) => { - if (propTypes.hasOwnProperty(key)) { - const validator = propTypes[key]; - validateProp(validator, props, key, modelName + '.update'); + const propTypesToValidate = Object.keys(props).reduce((result, key) => { + if (Object.prototype.hasOwnProperty.call(propTypes, key)) { + return { ...result, [key]: propTypes[key] }; } - }); + return result; + }, {}); + PropTypes.checkPropTypes(propTypesToValidate, props, 'prop', + `${modelName}.update`); } return super.update(...args); diff --git a/src/index.test.js b/src/index.test.js new file mode 100644 index 0000000..9849aab --- /dev/null +++ b/src/index.test.js @@ -0,0 +1,108 @@ +import { PropTypes } from 'prop-types'; + +import propTypesMixin, { getPropTypesMixin } from './index'; + +describe('PropTypesMixin', () => { + let Model; + let ModelWithMixin; + let modelInstance; + let createSpy; + let updateSpy; + let consoleErrorSpy; + + beforeEach(() => { + Model = class { + static create() {} + update() {} // eslint-disable-line class-methods-use-this + getClass() { + return this.constructor; + } + }; + + + createSpy = jest.spyOn(Model, 'create'); + + ModelWithMixin = propTypesMixin(Model); + ModelWithMixin.modelName = 'ModelWithMixin'; + + modelInstance = new ModelWithMixin(); + updateSpy = jest.spyOn(modelInstance, 'update'); + consoleErrorSpy = jest.spyOn(global.console, 'error'); + consoleErrorSpy.mockReset(); + }); + + it('getPropTypesMixin works correctly', () => { + const mixin = getPropTypesMixin(); + expect(mixin).toBeInstanceOf(Function); + + const result = mixin(Model); + expect(result).toBeInstanceOf(Function); + expect(Object.getPrototypeOf(result)).toEqual(Model); + }); + + it('mixin correctly returns a class', () => { + expect(ModelWithMixin).toBeInstanceOf(Function); + expect(Object.getPrototypeOf(ModelWithMixin)).toEqual(Model); + }); + + it('correctly delegates to superclass create', () => { + const arg = {}; + ModelWithMixin.create(arg); + + expect(createSpy.mock.calls.length).toBe(1); + expect(createSpy).toBeCalledWith(arg); + }); + + it('correctly delegates to superclass update', () => { + const arg = {}; + modelInstance.update(arg); + + expect(updateSpy.mock.calls.length).toBe(1); + expect(updateSpy).toBeCalledWith(arg); + }); + + it('raises validation error on create correctly', () => { + ModelWithMixin.propTypes = { + name: PropTypes.string.isRequired, + }; + + ModelWithMixin.create({ name: 'shouldnt raise error' }); + expect(consoleErrorSpy.mock.calls.length).toBe(0); + + ModelWithMixin.create({ notName: 'asd' }); + expect(consoleErrorSpy.mock.calls.length).toBe(1); + expect(consoleErrorSpy).toBeCalledWith('Warning: Failed prop type: The prop `name` is marked as required in `ModelWithMixin.create`, but its value is `undefined`.'); + }); + + it('raises validation error on update correctly', () => { + ModelWithMixin.propTypes = { + name: PropTypes.string.isRequired, + age: PropTypes.number.isRequired, + }; + expect(consoleErrorSpy.mock.calls.length).toBe(0); + const instance = new ModelWithMixin({ name: 'asd', age: 123 }); + expect(consoleErrorSpy.mock.calls.length).toBe(0); + + instance.update({ name: 123 }); + + expect(consoleErrorSpy.mock.calls.length).toBe(1); + expect(consoleErrorSpy).toBeCalledWith('Warning: Failed prop type: Invalid prop `name` of type `number` supplied to `ModelWithMixin.update`, expected `string`.'); + }); + + it('correctly uses defaultProps', () => { + ModelWithMixin.propTypes = { + name: PropTypes.string.isRequired, + age: PropTypes.number.isRequired, + isFetching: PropTypes.bool.isRequired, + }; + ModelWithMixin.defaultProps = { + isFetching: false, + }; + + const createArg = { name: 'Tommi', age: 25 }; + + ModelWithMixin.create(createArg); + expect(createSpy.mock.calls.length).toBe(1); + expect(createSpy).toBeCalledWith(expect.objectContaining({ name: 'Tommi', isFetching: false })); + }); +}); diff --git a/src/test/testMixin.js b/src/test/testMixin.js deleted file mode 100644 index d28674b..0000000 --- a/src/test/testMixin.js +++ /dev/null @@ -1,108 +0,0 @@ -import { PropTypes } from 'react'; -import chai from 'chai'; -import sinonChai from 'sinon-chai'; -import sinon from 'sinon'; - -import propTypesMixin, { getPropTypesMixin } from '../index'; - -chai.use(sinonChai); -const { expect } = chai; - -describe('PropTypesMixin', () => { - let Model; - let ModelWithMixin; - let modelInstance; - let createSpy; - let updateSpy; - - beforeEach(() => { - Model = class { - static create() {} - update() {} - getClass() { - return this.constructor; - } - }; - - - createSpy = sinon.spy(Model, 'create'); - - ModelWithMixin = propTypesMixin(Model); - ModelWithMixin.modelName = 'ModelWithMixin'; - - modelInstance = new ModelWithMixin(); - updateSpy = sinon.spy(modelInstance, 'update'); - }); - - it('getPropTypesMixin works correctly', () => { - const mixin = getPropTypesMixin(); - expect(mixin).to.be.a('function'); - - const result = mixin(Model); - expect(result).to.be.a('function'); - expect(Object.getPrototypeOf(result)).to.equal(Model); - }); - - it('mixin correctly returns a class', () => { - expect(ModelWithMixin).to.be.a('function'); - expect(Object.getPrototypeOf(ModelWithMixin)).to.equal(Model); - }); - - it('correctly delegates to superclass create', () => { - const arg = {}; - ModelWithMixin.create(arg); - - expect(createSpy).to.be.calledOnce; - expect(createSpy).to.be.calledWithExactly(arg); - }); - - it('correctly delegates to superclass update', () => { - const arg = {}; - modelInstance.update(arg); - - expect(updateSpy).to.be.calledOnce; - expect(updateSpy).to.be.calledWithExactly(arg); - }); - - it('raises validation error on create correctly', () => { - ModelWithMixin.propTypes = { - name: PropTypes.string.isRequired, - }; - - ModelWithMixin.create({ name: 'shouldnt raise error' }); - - const funcShouldThrow = () => ModelWithMixin.create({ notName: 'asd' }); - - expect(funcShouldThrow).to.throw('ModelWithMixin', 'name'); - }); - - it('raises validation error on update correctly', () => { - ModelWithMixin.propTypes = { - name: PropTypes.string.isRequired, - age: PropTypes.number.isRequired, - }; - - const instance = new ModelWithMixin(); - - const funcShouldThrow = () => instance.update({ name: 123 }); - - expect(funcShouldThrow).to.throw('ModelWithMixin', 'name'); - }); - - it('correctly uses defaultProps', () => { - ModelWithMixin.propTypes = { - name: PropTypes.string.isRequired, - age: PropTypes.number.isRequired, - isFetching: PropTypes.bool.isRequired, - }; - ModelWithMixin.defaultProps = { - isFetching: false, - }; - - const createArg = { name: 'Tommi', age: 25 }; - - ModelWithMixin.create(createArg); - expect(createSpy).to.have.been.calledOnce; - expect(createSpy).to.have.been.calledWithMatch({ name: 'Tommi', isFetching: false }); - }); -});