diff --git a/.eslintrc.json b/.eslintrc.json index 3a05f3f..8a2cd07 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,6 +2,7 @@ "env": { "jest": true }, + "parser": "babel-eslint", "extends": ["airbnb"], "rules": { "indent": [2, 4], diff --git a/package.json b/package.json index f784ffc..816c341 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-components-form", - "version": "3.5.2", + "version": "3.6.0-rc-7", "description": "React form components", "main": "main.js", "jsnext:main": "src/index.js", diff --git a/src/components/AutocompleteField.jsx b/src/components/AutocompleteField.jsx index aec0a82..63ee2d1 100644 --- a/src/components/AutocompleteField.jsx +++ b/src/components/AutocompleteField.jsx @@ -2,9 +2,10 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import Autocomplete from 'react-autosuggest'; -import FieldConnect from './FieldConnect'; +import cloneDeep from 'lodash/cloneDeep'; +import get from 'lodash/get'; +import { FieldConnect } from './FieldConnect'; import ErrorField from './ErrorField'; -import { get, cloneArray } from '../helpers'; import { fieldDefaultPropTypes } from '../constants/propTypes'; import { fieldDefaultProps } from '../constants/defaultProps'; @@ -93,7 +94,7 @@ export class AutocompleteField extends Component { applySectionFilter(sections, escapedValue, searchKey) { const { sectionSuggestionsIndex } = this.props; - const copiedSections = cloneArray(sections); + const copiedSections = cloneDeep(sections); const newSections = []; copiedSections.forEach((section) => { const clonedSection = Object.assign({}, section); diff --git a/src/components/CheckboxField.jsx b/src/components/CheckboxField.jsx index 71c2b2a..f638d1a 100644 --- a/src/components/CheckboxField.jsx +++ b/src/components/CheckboxField.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import FieldConnect from './FieldConnect'; +import { FieldConnect } from './FieldConnect'; import ErrorField from './ErrorField'; import { fieldDefaultPropTypes } from '../constants/propTypes'; import { fieldDefaultProps } from '../constants/defaultProps'; diff --git a/src/components/DateField.jsx b/src/components/DateField.jsx index f5d9316..fbefe58 100644 --- a/src/components/DateField.jsx +++ b/src/components/DateField.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import FieldConnect from './FieldConnect'; +import { FieldConnect } from './FieldConnect'; import ErrorField from './ErrorField'; import { fieldDefaultPropTypes } from '../constants/propTypes'; import { fieldDefaultProps } from '../constants/defaultProps'; diff --git a/src/components/FieldConnect.jsx b/src/components/FieldConnect.jsx index aa3ca53..d71e380 100644 --- a/src/components/FieldConnect.jsx +++ b/src/components/FieldConnect.jsx @@ -1,8 +1,7 @@ import React from 'react'; import isEqual from 'lodash/isEqual'; import PropTypes from 'prop-types'; - -import { cloneArray, cloneObject } from '../helpers'; +import cloneDeep from 'lodash/cloneDeep'; export const FieldConnect = (Component) => { class FieldConnector extends React.Component { @@ -11,26 +10,22 @@ export const FieldConnect = (Component) => { this.listeners = []; this.fieldValue = null; this.fieldValidationErrors = null; - this.onChangeData = this.onChangeData.bind(this); - this.submit = this.submit.bind(this); - this.getValidationErrors = this.getValidationErrors.bind(this); - this.getPath = this.getPath.bind(this); - this.hasValidationError = this.hasValidationError.bind(this); - this.getPropsFromSchema = this.getPropsFromSchema.bind(this); - this.getEventsEmitter = this.getEventsEmitter.bind(this); - this.getFieldAttributes = this.getFieldAttributes.bind(this); + this.fieldValidators = []; } getChildContext() { return { getSchema: this.context.getSchema, getPath: this.getPath, + setFieldValidator: this.setFieldValidator, + removeFieldValidator: this.removeFieldValidator, }; } componentWillMount() { this.updateModelWithValueOrOptions(); this.registerListeners(); + this.registerFieldValidators(); } shouldComponentUpdate(nextProps) { @@ -47,9 +42,10 @@ export const FieldConnect = (Component) => { componentWillUnmount() { this.unregisterListeners(); + this.unregisterFieldValidators(); } - onChangeData(value) { + onChangeData = (value) => { const { name, callbacks: { onChange } } = this.props; const { setModel, @@ -73,16 +69,27 @@ export const FieldConnect = (Component) => { } } + getModelPath() { + const seperatedPath = this.getPath().split('.'); + return seperatedPath.splice(1, seperatedPath.length) + .map((path) => { + const pathAttributes = path.split('-'); + if (pathAttributes.length > 1) { + return pathAttributes[1]; + } + return path; + }) + .join('.'); + } + + setFieldValidator = (validator) => { + const modelPath = this.getModelPath(); + this.context.setValidator(modelPath, validator); + this.fieldValidators.push(validator); + }; + setCurrentFieldValue(value) { - if (Array.isArray(value)) { - this.fieldValue = cloneArray(value); - return; - } - if (typeof value === 'object') { - this.fieldValue = cloneObject(value); - return; - } - this.fieldValue = value; + this.fieldValue = cloneDeep(value); } getValue() { @@ -99,18 +106,16 @@ export const FieldConnect = (Component) => { return fieldValue; } - getPropsFromSchema() { + getPropsFromSchema = () => { const { name } = this.props; const { getSchema } = this.context; if (typeof getSchema !== 'function') return undefined; return getSchema(name); } - getEventsEmitter() { - return this.context.eventsEmitter; - } + getEventsEmitter = () => this.context.eventsEmitter; - getValidationErrors() { + getValidationErrors = () => { const { name, callbacks: { onError } } = this.props; const { getValidationErrors } = this.context; let results = []; @@ -121,14 +126,14 @@ export const FieldConnect = (Component) => { return results; } - getPath() { + getPath = () => { const { name } = this.props; const { getPath } = this.context; if (typeof getPath !== 'function') return name; return `${getPath()}.${name}`; - } + }; - getFieldAttributes() { + getFieldAttributes = () => { const { validateOnChange, isFormSubmitted } = this.context; const { fieldAttributes, callbacks: { onFocus, onBlur } } = this.props; return Object.assign( @@ -159,6 +164,31 @@ export const FieldConnect = (Component) => { } } + removeFieldValidator = (validator) => { + const index = this.fieldValidators.indexOf(validator); + if (index > -1) { + this.context.removeValidator(validator); + this.fieldValidators.splice(index, 1); + } + }; + + registerFieldValidators = () => { + if (this.props.validator) { + const { getModel } = this.context; + const fieldValidator = (...attr) => { + const value = typeof getModel === 'function' ? getModel(this.props.name) : ''; + return this.props.validator(value, ...attr); + }; + this.setFieldValidator(fieldValidator); + } + }; + + unregisterFieldValidators = () => { + this.fieldValidators.forEach((validator) => { + this.context.removeValidator(validator); + }); + }; + shouldShowErrors() { const { hasBeenTouched, validateOnChange, isFormSubmitted } = this.context; if (!validateOnChange || isFormSubmitted) { @@ -167,7 +197,7 @@ export const FieldConnect = (Component) => { return hasBeenTouched(this.getPath()); } - submit(event) { + submit = (event) => { const { submitForm } = this.context; if (typeof submitForm !== 'function') return; submitForm(event); @@ -234,9 +264,7 @@ export const FieldConnect = (Component) => { } } - hasValidationError() { - return this.getValidationErrors().length > 0; - } + hasValidationError = () => this.getValidationErrors().length > 0; render() { return ( { hasBeenTouched: PropTypes.func, validateOnChange: PropTypes.bool, isFormSubmitted: PropTypes.bool, + setValidator: PropTypes.func, + removeValidator: PropTypes.func, }; FieldConnector.childContextTypes = { getSchema: PropTypes.func, getPath: PropTypes.func, + setFieldValidator: PropTypes.func, + removeFieldValidator: PropTypes.func, }; FieldConnector.propTypes = { @@ -315,11 +347,13 @@ export const FieldConnect = (Component) => { onFocus: PropTypes.func, onBlur: PropTypes.func, }), + validator: PropTypes.func, }; FieldConnector.defaultProps = { fieldAttributes: {}, callbacks: {}, + validator: undefined, }; return FieldConnector; diff --git a/src/components/FieldsRestyle.jsx b/src/components/FieldsRestyle.jsx index 90c6a4c..fd95a88 100644 --- a/src/components/FieldsRestyle.jsx +++ b/src/components/FieldsRestyle.jsx @@ -1,6 +1,6 @@ import React from 'react'; import classnames from 'classnames'; -import { get } from '../helpers'; +import get from 'lodash/get'; const extendStyles = ( styles, diff --git a/src/components/Form.jsx b/src/components/Form.jsx index 601343a..393ae9b 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; +import cloneDeep from 'lodash/cloneDeep'; import Storage from './Storage'; -import { cloneObject } from '../helpers'; class Form extends React.Component { constructor(props) { @@ -17,6 +17,7 @@ class Form extends React.Component { if (props.controller) { props.controller.setForm(this); } + this.fieldsValidators = []; this.storage = new Storage(this.state.model); this.eventsEmitter = props.eventsEmitter; this.setModel = this.setModel.bind(this); @@ -53,6 +54,8 @@ class Form extends React.Component { hasBeenTouched: this.hasBeenTouched, validateOnChange: this.state.validateOnChange, isFormSubmitted: this.state.isFormSubmitted, + setValidator: this.setValidator, + removeValidator: this.removeValidator, }; } @@ -100,6 +103,23 @@ class Form extends React.Component { return this.state.schema.getField(name); } + setValidator = (path, validator) => { + const index = this.findValidatorIndex(validator); + if (index < 0) { + const schemaValidator = (model, schema) => { + const validationResults = validator(model); + if (validationResults && typeof validationResults === 'boolean') { + return; + } + schema.setModelError(path, validationResults); + }; + this.fieldsValidators.push({ path, validator, schemaValidator }); + if (typeof this.state.schema.validate === 'function') { + this.state.schema.addValidator(schemaValidator); + } + } + }; + getValidationErrors(name) { return this.state.validationErrors[name] || {}; } @@ -112,6 +132,20 @@ class Form extends React.Component { return this.props.id; } + removeValidator = (validator) => { + const index = this.findValidatorIndex(validator); + if (index > -1) { + const fieldValidator = this.fieldsValidators[index]; + if (typeof this.state.schema.validate === 'function') { + this.state.schema.removeValidator(fieldValidator.schemaValidator); + } + this.fieldsValidators.splice(index, 1); + } + }; + + findValidatorIndex = validator => + this.fieldsValidators.findIndex(fieldValidator => fieldValidator.validator === validator); + submitListener() { return this.submitForm(); } @@ -178,7 +212,7 @@ class Form extends React.Component { } runSubmit(validationErrors, modelData) { - const model = cloneObject(modelData); + const model = cloneDeep(modelData); if (Object.keys(validationErrors).length > 0) { if (this.props.onError) this.props.onError(validationErrors, model); return undefined; @@ -245,6 +279,8 @@ Form.childContextTypes = { hasBeenTouched: PropTypes.func, validateOnChange: PropTypes.bool, isFormSubmitted: PropTypes.bool, + setValidator: PropTypes.func, + removeValidator: PropTypes.func, }; Form.propTypes = { diff --git a/src/components/ListField.jsx b/src/components/ListField.jsx index 82e55e5..f18dcae 100644 --- a/src/components/ListField.jsx +++ b/src/components/ListField.jsx @@ -1,5 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; +import isEqual from 'lodash/isEqual'; +import cloneDeep from 'lodash/cloneDeep'; import Storage from './Storage'; import FieldConnect from './FieldConnect'; @@ -43,9 +45,11 @@ export class ListField extends React.Component { componentWillReceiveProps({ value }) { let shouldSetState = false; value.forEach((item, key) => { - if (item !== this.state.model[key].value) shouldSetState = true; + if (!isEqual(item, this.state.model[key].value)) shouldSetState = true; }); - if (shouldSetState) this.storage.setModel(ListField.getModelFromProps({ value })); + if (shouldSetState || value.length !== this.state.model.length) { + this.storage.setModel(ListField.getModelFromProps({ value })); + } } componentWillUnmount() { @@ -168,9 +172,8 @@ export class ListField extends React.Component { } removeListElement(key) { - const model = Array.from(this.state.model); + const model = cloneDeep(this.state.model); model.splice(key, 1); - this.setState({ model }); this.props.onChange(model.map(item => item.value)); } diff --git a/src/helpers.js b/src/helpers.js deleted file mode 100644 index ff5b2f2..0000000 --- a/src/helpers.js +++ /dev/null @@ -1,43 +0,0 @@ -export const get = (object, path, defaultValue) => { - const paths = path.split('.'); - let getter = object; - if(typeof object === 'object'){ - paths.forEach(key => { - if (getter !== defaultValue && getter[key]) { - getter = getter[key]; - return; - } - getter = defaultValue; - }); - return getter; - } - return defaultValue; -}; - -export const cloneArray = (array) => { - if (Array.isArray(array)) { - return array.map(item => { - if (Array.isArray(item)) return cloneArray(item); - if (typeof item === 'object' && !(item instanceof Date)) return cloneObject(item); - return item; - }); - } - return array; -}; - -export const cloneObject = (object) => { - const results = {}; - Object.keys(object).forEach((key) => { - const data = cloneArray(object[key]); - if ( - typeof object[key] === 'object' && - !Array.isArray(object[key]) && - !(object[key] instanceof Date) - ) { - results[key] = cloneObject(data); - return; - } - results[key] = data; - }); - return results; -}; diff --git a/tests/data/schemas.js b/tests/data/schemas.js index 0e4dfe4..506bf0a 100644 --- a/tests/data/schemas.js +++ b/tests/data/schemas.js @@ -66,3 +66,12 @@ export const bookSchema = new Schema({ type: titleSchema, } }); + +export const fooBarSchema = new Schema({ + foo: { + type: String, + }, + bar: { + type: String, + } +}); diff --git a/tests/integration/FieldConnect.test.jsx b/tests/integration/FieldConnect.test.jsx index b4edb2e..5fe05dc 100644 --- a/tests/integration/FieldConnect.test.jsx +++ b/tests/integration/FieldConnect.test.jsx @@ -172,4 +172,93 @@ describe('FieldConnect', () => { wrapper.find('input').first().simulate('blur'); expect(props.callbacks.onBlur).toHaveBeenCalled(); }); + it('should set field validator', () => { + const context = { + setValidator: jest.fn(), + removeValidator: jest.fn(), + getPath: () => 'form.user.user-1', + }; + const props = { + name: 'name', + }; + const validator = jest.fn(); + const wrapper = mount(, { context }); + wrapper.instance().setFieldValidator(validator); + expect(context.setValidator).toHaveBeenCalledWith('user.1.name', validator); + }); + it('should remove field validator when validator exist', () => { + const context = { + setValidator: jest.fn(), + removeValidator: jest.fn(), + getPath: () => 'form.user.user-1', + }; + const props = { + name: 'name', + }; + const validator = jest.fn(); + const wrapper = mount(, { context }); + wrapper.instance().setFieldValidator(validator); + wrapper.instance().removeFieldValidator(validator); + expect(context.removeValidator).toHaveBeenCalledWith(validator); + }); + it('should not remove field validator when validator not exist', () => { + const context = { + setValidator: jest.fn(), + removeValidator: jest.fn(), + getPath: () => 'form.user.user-1', + }; + const props = { + name: 'name', + }; + const validator = jest.fn(); + const validator2 = jest.fn(); + const wrapper = mount(, { context }); + wrapper.instance().setFieldValidator(validator); + wrapper.instance().removeFieldValidator(validator2); + expect(context.removeValidator).not.toHaveBeenCalled(); + }); + it('should register field validator when validator passed by props', () => { + const context = { + setValidator: jest.fn(), + removeValidator: jest.fn(), + getPath: () => 'form.user.user-1', + }; + const props = { + name: 'name', + validator: jest.fn(), + }; + const wrapper = mount(, { context }); + expect(context.setValidator).toHaveBeenCalled(); + wrapper.unmount(); + expect(context.removeValidator).toHaveBeenCalled(); + }); + it('should call validator passed by props', () => { + const context = { + setValidator: jest.fn(), + removeValidator: jest.fn(), + getPath: () => 'form.user.user-1', + getModel: () => 'foo', + }; + const props = { + name: 'name', + validator: jest.fn(), + }; + const wrapper = mount(, { context }); + wrapper.instance().fieldValidators[0]('bar', 'foo'); + expect(props.validator).toHaveBeenCalledWith('foo', 'bar', 'foo'); + }); + it('should call validator passed by props when getModel method not exist', () => { + const context = { + setValidator: jest.fn(), + removeValidator: jest.fn(), + getPath: () => 'form.user.user-1', + }; + const props = { + name: 'name', + validator: jest.fn(), + }; + const wrapper = mount(, { context }); + wrapper.instance().fieldValidators[0]('bar', 'foo'); + expect(props.validator).toHaveBeenCalledWith('', 'bar', 'foo'); + }); }); diff --git a/tests/integration/Form.test.jsx b/tests/integration/Form.test.jsx index 3e38d6e..0564fee 100644 --- a/tests/integration/Form.test.jsx +++ b/tests/integration/Form.test.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { mount } from 'enzyme'; import Schema from 'form-schema-validation'; +import PropTypes from 'prop-types'; import { Form, TextField, @@ -11,7 +12,8 @@ import { ErrorField, } from '../../src/components'; import FormController from '../../src/components/FormController'; -import { titleSchema, bookSchema } from '../data/schemas'; +import FieldConnect from '../../src/components/FieldConnect'; +import { titleSchema, bookSchema, fooBarSchema } from '../data/schemas'; describe('Form', () => { @@ -634,4 +636,164 @@ describe('Form', () => { formInstance.markFieldAsTouched('form.title'); expect(mockedSetState).toHaveBeenCalledTimes(1); }); + describe('fieldValidators', () => { + const barValidator = value => (value === 'test' ? true : 'barError'); + class InputWithValidator extends React.Component { + componentWillMount() { + this.context.setFieldValidator(this.fieldValidator); + } + onChangeValue = ({ target: { value } }) => { + this.props.onChange(value); + }; + fieldValidator = () => { + if (this.props.value === 'test') { + return true; + } + return 'fooError'; + }; + render() { + const { name, value } = this.props; + return ( +
+ +
+ ); + } + } + InputWithValidator.contextTypes = { + setFieldValidator: PropTypes.func, + }; + InputWithValidator.propTypes = { + onChange: PropTypes.func.isRequired, + value: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }; + + const FieldWithValidator = FieldConnect(InputWithValidator); + it('should validate field using field validator - with errors', () => { + const model = { + foo: 'foo', + bar: 'bar', + }; + + const wrapper = mount( +
{}} + model={model} + schema={fooBarSchema} + validateOnChange + > + + + + , + ); + + const formInstance = wrapper.instance(); + formInstance.submitForm(); + expect(formInstance.state.validationErrors).toEqual({ + bar: ['barError'], + foo: ['fooError'], + }); + wrapper.unmount(); + }); + it('should validate field using field validator - without errors', () => { + const model = { + foo: 'test', + bar: 'test', + }; + + const wrapper = mount( +
{}} + model={model} + schema={fooBarSchema} + validateOnChange + > + + + + , + ); + + const formInstance = wrapper.instance(); + formInstance.submitForm(); + expect(formInstance.state.validationErrors).toEqual({}); + }); + it('should set field validator and remove field validator with schema', () => { + const model = { + foo: 'test', + bar: 'test', + }; + + const wrapper = mount( +
{}} + model={model} + schema={fooBarSchema} + validateOnChange + > + + + , + ); + + const formInstance = wrapper.instance(); + formInstance.submitForm(); + expect(formInstance.fieldsValidators.length).toBe(1); + formInstance.setValidator('foo', barValidator); + expect(formInstance.fieldsValidators.length).toBe(2); + formInstance.setValidator('foo', barValidator); + expect(formInstance.fieldsValidators.length).toBe(2); + formInstance.removeValidator(barValidator); + expect(formInstance.fieldsValidators.length).toBe(1); + formInstance.removeValidator(barValidator); + expect(formInstance.fieldsValidators.length).toBe(1); + }); + it('should set field validator and remove field validator without schema', () => { + const model = { + foo: 'test', + bar: 'test', + }; + + const wrapper = mount( +
{}} + model={model} + validateOnChange + > + + + , + ); + + const formInstance = wrapper.instance(); + formInstance.submitForm(); + expect(formInstance.fieldsValidators.length).toBe(1); + formInstance.setValidator('foo', barValidator); + expect(formInstance.fieldsValidators.length).toBe(2); + formInstance.setValidator('foo', barValidator); + expect(formInstance.fieldsValidators.length).toBe(2); + formInstance.removeValidator(barValidator); + expect(formInstance.fieldsValidators.length).toBe(1); + formInstance.removeValidator(barValidator); + expect(formInstance.fieldsValidators.length).toBe(1); + }); + }); }); diff --git a/tests/unit/helpers.test.js b/tests/unit/helpers.test.js deleted file mode 100644 index 0ef1cb3..0000000 --- a/tests/unit/helpers.test.js +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import { get, cloneObject, cloneArray, isNotEqualObject, isReactComponentOrElement } from '../../src/helpers'; - -describe('helpers', () => { - describe('get', () => { - const valueABC = 'testABC'; - const valueBC = 'testBC'; - const test = { - a: { - b: { - c: valueABC, - }, - }, - b: { - c: valueBC, - }, - }; - const test2 = 'test'; - it('should get property from object', () => { - expect(get(test, 'a.b.c')).toBe(valueABC); - }); - it('should return default value', () => { - expect(get(test, 'a.b.d', 'test')).toBe('test'); - }); - it('should return default value if property not exists', () => { - expect(get(test, 'b', 'test').c).toBe(valueBC); - }); - it('should return default value if first attribute isnt object', () => { - expect(get(test2, 'b', 'test')).toBe('test'); - }); - }); - - describe('cloneArray', () => { - const array = ['test', 'test1', 'test2']; - const arrayOfObjects = [{ test: 'test1' }, new Date()]; - const arrayOfArrays = [array, arrayOfObjects]; - it('should clone simple array', () => { - const clonedArray = cloneArray(array); - expect(clonedArray).not.toBe(array); - expect(clonedArray).toEqual(array); - }); - it('should clone array of objects', () => { - const clonedArray = cloneArray(arrayOfObjects); - expect(clonedArray).not.toBe(arrayOfObjects); - expect(clonedArray).toEqual(arrayOfObjects); - }); - it('should clone array of arrays', () => { - const clonedArray = cloneArray(arrayOfArrays); - expect(clonedArray).not.toBe(arrayOfArrays); - expect(clonedArray).toEqual(arrayOfArrays); - }); - }); - - describe('cloneObject', () => { - const object = { a: 'test', b: 'test1', c: 'test2' }; - const objectWithObjects = { a: { test: 'test1' }, b: new Date() }; - const objectWithArrays = { a: ['test', 'test2'], b: objectWithObjects }; - it('should clone simple object', () => { - const clonedObject = cloneObject(object); - expect(clonedObject).not.toBe(object); - expect(clonedObject).toEqual(object); - }); - it('should clone object with objects', () => { - const clonedObject = cloneObject(objectWithObjects); - expect(clonedObject).not.toBe(objectWithObjects); - expect(clonedObject).toEqual(objectWithObjects); - }); - it('should clone object with arrays', () => { - const clonedObject = cloneObject(objectWithArrays); - expect(clonedObject).not.toBe(objectWithArrays); - expect(clonedObject).toEqual(objectWithArrays); - }); - }); -});