diff --git a/client/src/core-components/__tests__/form-test.js b/client/src/core-components/__tests__/form-test.js index b98fd40d..ac73aca7 100644 --- a/client/src/core-components/__tests__/form-test.js +++ b/client/src/core-components/__tests__/form-test.js @@ -1,57 +1,146 @@ -const Form = require('core-components/form'); -const Input = require('core-components/input'); +// MOCKS +const ValidationFactoryMock = require('lib-app/__mocks__/validations/validation-factory-mock'); +const Input = ReactMock(); + +// COMPONENTS +const Form = requireUnit('core-components/form', { + 'lib-app/validations/validations-factory': ValidationFactoryMock, + 'core-components/input': Input +}); describe('Form component', function () { - let form, inputs, onSubmit = stub(); + let form, fields, onSubmit = stub(); - beforeEach(function () { + function renderForm() { form = TestUtils.renderIntoDocument(
- - + +
); - inputs = TestUtils.scryRenderedComponentsWithType(form, Input); - }); + fields = TestUtils.scryRenderedComponentsWithType(form, Input); + } - it('should store input value in form state', function () { - expect(form.state.form).to.deep.equal({ - first: 'value1', - second: 'value2', - third: 'value3' + function resetStubs() { + ValidationFactoryMock.validators.defaultValidatorMock.validate = stub(); + ValidationFactoryMock.validators.customValidatorMock.validate = stub(); + ValidationFactoryMock.getValidator.reset(); + onSubmit.reset(); + } + + describe('when mounting the form', function () { + beforeEach(renderForm); + afterEach(resetStubs); + + it('should store fields values in form state', function () { + expect(form.state.form).to.deep.equal({ + first: 'value1', + second: 'value2', + third: 'value3' + }); + }); + + it('should set validations for required fields', function () { + expect(ValidationFactoryMock.getValidator).to.have.been.calledWith('DEFAULT'); + expect(ValidationFactoryMock.getValidator).to.have.been.calledWith('CUSTOM'); + expect(ValidationFactoryMock.getValidator).to.have.been.calledTwice; + + expect(form.state.validations).to.deep.equal({ + first: ValidationFactoryMock.validators.defaultValidatorMock, + second: ValidationFactoryMock.validators.customValidatorMock + }); }); }); - it('should update form state if an input value changes', function () { - inputs[0].props.onChange({ target: {value: 'value4'}}); + describe('when interacting with fields', function () { + beforeEach(renderForm); + afterEach(resetStubs); - expect(form.state.form).to.deep.equal({ - first: 'value4', - second: 'value2', - third: 'value3' + it('should update form state if a field value changes', function () { + fields[0].props.onChange({ target: {value: 'value4'}}); + + expect(form.state.form).to.deep.equal({ + first: 'value4', + second: 'value2', + third: 'value3' + }); + }); + + it('should update field value if state value changes', function () { + form.setState({ + form: { + first: 'value6', + second: 'value7', + third: 'value8' + } + }); + + expect(fields[0].props.value).to.equal('value6'); + expect(fields[1].props.value).to.equal('value7'); + expect(fields[2].props.value).to.equal('value8'); + }); + + it('should validate required fields when blurring', function () { + ValidationFactoryMock.validators.defaultValidatorMock.validate = stub().returns('MOCK_ERROR'); + ValidationFactoryMock.validators.customValidatorMock.validate = stub().returns('MOCK_ERROR_2'); + expect(fields[0].props.error).to.equal(undefined); + expect(fields[0].props.error).to.equal(undefined); + expect(fields[0].props.error).to.equal(undefined); + + TestUtils.Simulate.blur(ReactDOM.findDOMNode(fields[0])); + expect(fields[0].props.error).to.equal('MOCK_ERROR'); + + TestUtils.Simulate.blur(ReactDOM.findDOMNode(fields[1])); + expect(fields[1].props.error).to.equal('MOCK_ERROR_2'); + + TestUtils.Simulate.blur(ReactDOM.findDOMNode(fields[2])); + expect(fields[2].props.error).to.equal(undefined); }); }); - it('should update input value if state value changes', function () { - form.setState({ - form: { - first: 'value6', - second: 'value7', - third: 'value8' - } + describe('when submitting the form', function () { + beforeEach(renderForm); + afterEach(resetStubs); + + it('should call onSubmit callback', function () { + TestUtils.Simulate.submit(ReactDOM.findDOMNode(form)); + + expect(form.props.onSubmit).to.have.been.calledWith(form.state.form); }); - expect(inputs[0].props.value).to.equal('value6'); - expect(inputs[1].props.value).to.equal('value7'); - expect(inputs[2].props.value).to.equal('value8'); - }); + it('should validate all fields and not call onSubmit if there are errors', function () { + ValidationFactoryMock.validators.defaultValidatorMock.validate = stub().returns('MOCK_ERROR'); + ValidationFactoryMock.validators.customValidatorMock.validate = stub().returns('MOCK_ERROR_2'); + fields[0].focus = spy(fields[0].focus); + fields[1].focus = spy(fields[1].focus); - it('should call onSubmit callback when form is submitted', function () { - TestUtils.Simulate.submit(ReactDOM.findDOMNode(form)); + TestUtils.Simulate.submit(ReactDOM.findDOMNode(form)); - expect(form.props.onSubmit).to.have.been.calledWith(form.state.form); + expect(fields[0].props.error).to.equal('MOCK_ERROR'); + expect(fields[1].props.error).to.equal('MOCK_ERROR_2'); + expect(fields[2].props.error).to.equal(undefined); + expect(form.props.onSubmit).to.not.have.been.called; + }); + + it('should focus the first field with error', function () { + ValidationFactoryMock.validators.defaultValidatorMock.validate = stub().returns('MOCK_ERROR'); + ValidationFactoryMock.validators.customValidatorMock.validate = stub().returns('MOCK_ERROR_2'); + fields[0].focus = spy(fields[0].focus); + fields[1].focus = spy(fields[1].focus); + + TestUtils.Simulate.submit(ReactDOM.findDOMNode(form)); + expect(fields[0].focus).to.have.been.called; + + ValidationFactoryMock.validators.defaultValidatorMock.validate = stub(); + fields[0].focus.reset(); + fields[1].focus.reset(); + + TestUtils.Simulate.submit(ReactDOM.findDOMNode(form)); + expect(fields[0].focus).to.not.have.been.called; + expect(fields[1].focus).to.have.been.called; + }); }); }); diff --git a/client/src/core-components/form.js b/client/src/core-components/form.js index 3fd0c51f..b05508b2 100644 --- a/client/src/core-components/form.js +++ b/client/src/core-components/form.js @@ -19,35 +19,13 @@ const Form = React.createClass({ }, componentDidMount() { - let formState = {}; - let validations = {}; - - reactDFS(this.props.children, (child) => { - - if (this.isValidInputType(child)) { - if (child.type === Input) { - formState[child.props.name] = child.props.value || ''; - } - else if (child.type === Checkbox) { - formState[child.props.name] = child.props.checked || false; - } - - if (child.props.required) { - validations[child.props.name] = ValidationFactory.getValidator(child.props.validation || 'DEFAULT'); - } - } - }); - - this.setState({ - form: formState, - validations: validations - }); + this.setState(this.getInitialFormAndValidations()); }, render() { return (
- {renderChildrenWithProps(this.props.children, this.getInputProps)} + {renderChildrenWithProps(this.props.children, this.getFieldProps)}
); }, @@ -60,54 +38,121 @@ const Form = React.createClass({ return props; }, - getInputProps({props, type}) { + getFieldProps({props, type}) { let additionalProps = {}; if (type === Input || type === Checkbox) { - let inputName = props.name; + let fieldName = props.name; additionalProps = { - ref: inputName, - value: this.state.form[inputName] || props.value, - error: this.state.errors[inputName], - onChange: this.handleInputChange.bind(this, inputName, type) + ref: fieldName, + value: this.state.form[fieldName] || props.value, + error: this.state.errors[fieldName], + onChange: this.handleFieldChange.bind(this, fieldName, type), + onBlur: this.validateField.bind(this, fieldName) } } return additionalProps; }, + getFirstErrorField() { + let fieldName = _.findKey(this.state.errors); + let fieldNode; + + if (fieldName) { + fieldNode = this.refs[fieldName]; + } + + return fieldNode; + }, + + getAllFieldErrors() { + let form = this.state.form; + let fields = Object.keys(this.state.form); + let errors = {}; + + _.each(fields, (fieldName) => { + errors = this.getErrorsWithValidatedField(fieldName, form, errors); + }); + + return errors; + }, + + getErrorsWithValidatedField(fieldName, form = this.state.form, errors = this.state.errors) { + let newErrors = _.clone(errors); + + if (this.state.validations[fieldName]) { + newErrors[fieldName] = this.state.validations[fieldName].validate(form[fieldName], form); + } + + return newErrors; + }, + + getInitialFormAndValidations() { + let form = {}; + let validations = {}; + + reactDFS(this.props.children, (child) => { + + if (this.isValidFieldType(child)) { + if (child.type === Input) { + form[child.props.name] = child.props.value || ''; + } + else if (child.type === Checkbox) { + form[child.props.name] = child.props.checked || false; + } + + if (child.props.required) { + validations[child.props.name] = ValidationFactory.getValidator(child.props.validation || 'DEFAULT'); + } + } + }); + + return { + form: form, + validations: validations + } + }, + handleSubmit(event) { event.preventDefault(); if (this.hasFormErrors()) { this.setState({ - errors: this.validateAllFields() + errors: this.getAllFieldErrors() }, this.focusFirstErrorField); } else if (this.props.onSubmit) { this.props.onSubmit(this.state.form); } }, - handleInputChange(inputName, type, event) { + handleFieldChange(fieldName, type, event) { let form = _.clone(this.state.form); - let errors; - form[inputName] = event.target.value; + form[fieldName] = event.target.value; if (type === Checkbox) { - form[inputName] = event.target.checked || false; + form[fieldName] = event.target.checked || false; } - errors = this.validateField(inputName, form); this.setState({ - form: form, - errors: errors + form: form }); }, + isValidFieldType(child) { + return child.type === Input || child.type === Checkbox; + }, + hasFormErrors() { - return _.some(this.validateAllFields(), (error) => error); + return _.some(this.getAllFieldErrors()); + }, + + validateField(fieldName) { + this.setState({ + errors: this.getErrorsWithValidatedField(fieldName) + }); }, focusFirstErrorField() { @@ -116,43 +161,6 @@ const Form = React.createClass({ if (firstErrorField) { firstErrorField.focus(); } - }, - - getFirstErrorField() { - let fieldName = _.findKey(this.state.errors); - let fieldNode; - - if (fieldName) { - fieldNode = ReactDOM.findDOMNode(this.refs[fieldName]); - } - - return fieldNode; - }, - - isValidInputType(child) { - return child.type === Input || child.type === Checkbox; - }, - - validateAllFields() { - let form = this.state.form; - let inputList = Object.keys(this.state.form); - let errors = {}; - - _.each(inputList, (inputName) => { - errors = this.validateField(inputName, form, errors); - }); - - return errors; - }, - - validateField(inputName, form = this.state.form, errors = this.state.errors) { - let newErrors = _.clone(errors); - - if (this.state.validations[inputName]) { - newErrors[inputName] = this.state.validations[inputName].validate(form[inputName], form); - } - - return newErrors; } }); diff --git a/client/src/core-components/input.js b/client/src/core-components/input.js index fb84eaf9..e74afede 100644 --- a/client/src/core-components/input.js +++ b/client/src/core-components/input.js @@ -34,6 +34,7 @@ const Input = React.createClass({ props.required = null; props['aria-required'] = this.props.required; props.type = (this.props.password) ? 'password' : 'text'; + props.ref = 'nativeInput'; return props; }, @@ -47,6 +48,12 @@ const Input = React.createClass({ }; return classNames(classes); + }, + + focus() { + if (this.refs.nativeInput) { + this.refs.nativeInput.focus(); + } } }); diff --git a/client/src/lib-app/__mocks__/validations/validation-factory-mock.js b/client/src/lib-app/__mocks__/validations/validation-factory-mock.js new file mode 100644 index 00000000..0724ac1a --- /dev/null +++ b/client/src/lib-app/__mocks__/validations/validation-factory-mock.js @@ -0,0 +1,12 @@ +let customValidatorMock = {validate: stub()}; +let defaultValidatorMock = {validate: stub()}; + +export default { + getValidator: spy(function (validation) { + return (validation === 'CUSTOM') ? customValidatorMock : defaultValidatorMock; + }), + validators: { + customValidatorMock: customValidatorMock, + defaultValidatorMock: defaultValidatorMock + } +}; \ No newline at end of file diff --git a/client/src/lib-test/preprocessor.js b/client/src/lib-test/preprocessor.js index a052d635..78afdb67 100644 --- a/client/src/lib-test/preprocessor.js +++ b/client/src/lib-test/preprocessor.js @@ -11,6 +11,7 @@ global.chai = require('chai'); global.expect = chai.expect; global.sinon = require('sinon'); global.stub = sinon.stub; +global.spy = sinon.spy; global.proxyquire = require('proxyquire'); global.ReactMock = require('lib-test/react-mock'); chai.use(require('sinon-chai'));