diff --git a/client/src/actions/__mocks__/user-actions-mock.js b/client/src/actions/__mocks__/user-actions-mock.js index b8a568db..cb0e9a4a 100644 --- a/client/src/actions/__mocks__/user-actions-mock.js +++ b/client/src/actions/__mocks__/user-actions-mock.js @@ -1,5 +1,7 @@ export default { checkLoginStatus: stub(), + sendRecoverPassword: stub(), + recoverPassword: stub(), login: stub(), logout: stub() }; \ No newline at end of file diff --git a/client/src/actions/user-actions.js b/client/src/actions/user-actions.js index dc9cd0b6..9279fa53 100644 --- a/client/src/actions/user-actions.js +++ b/client/src/actions/user-actions.js @@ -4,7 +4,7 @@ const UserActions = Reflux.createActions([ 'checkLoginStatus', 'login', 'logout', - 'sendRecover', + 'sendRecoverPassword', 'recoverPassword' ]); diff --git a/client/src/app/main/main-home/__tests__/main-home-page-login-widget-test.js b/client/src/app/main/main-home/__tests__/main-home-page-login-widget-test.js index 9e1a6b1c..2d2fe3fa 100644 --- a/client/src/app/main/main-home/__tests__/main-home-page-login-widget-test.js +++ b/client/src/app/main/main-home/__tests__/main-home-page-login-widget-test.js @@ -1,18 +1,22 @@ const UserActions = require('actions/__mocks__/user-actions-mock'); const UserStore = require('stores/__mocks__/user-store-mock'); +const SubmitButton = ReactMock(); const Button = ReactMock(); const Input = ReactMock(); const Form = ReactMock(); const Checkbox = ReactMock(); +const Message = ReactMock(); const Widget = ReactMock(); const WidgetTransition = ReactMock(); const MainHomePageLoginWidget = requireUnit('app/main/main-home/main-home-page-login-widget', { + 'core-components/submit-button': SubmitButton, 'core-components/button': Button, 'core-components/input': Input, 'core-components/form': Form, 'core-components/checkbox': Checkbox, + 'core-components/message': Message, 'core-components/widget': Widget, 'core-components/widget-transition': WidgetTransition, 'actions/user-actions': UserActions, @@ -23,7 +27,7 @@ const MainHomePageLoginWidget = requireUnit('app/main/main-home/main-home-page-l describe('Login/Recover Widget', function () { describe('Login Form', function () { let loginWidget, loginForm, widgetTransition, inputs, checkbox, component, - forgotPasswordButton; + forgotPasswordButton, submitButton; beforeEach(function () { component = TestUtils.renderIntoDocument( @@ -34,7 +38,8 @@ describe('Login/Recover Widget', function () { loginForm = TestUtils.scryRenderedComponentsWithType(component, Form)[0]; inputs = TestUtils.scryRenderedComponentsWithType(component, Input); checkbox = TestUtils.scryRenderedComponentsWithType(component, Checkbox)[0]; - forgotPasswordButton = TestUtils.scryRenderedComponentsWithType(component, Button)[1]; + submitButton = TestUtils.scryRenderedComponentsWithType(component, SubmitButton)[0]; + forgotPasswordButton = TestUtils.scryRenderedComponentsWithType(component, Button)[0]; component.refs.loginForm = { refs: { @@ -58,11 +63,19 @@ describe('Login/Recover Widget', function () { loginForm.props.onSubmit(mockSubmitData); expect(UserActions.login).to.have.been.calledWith(mockSubmitData); }); - - it('should add error if login fails', function () { + + it('should set loading true in the form when submitted', function () { + let mockSubmitData = {email: 'MOCK_VALUE', password: 'MOCK_VALUE'}; + + loginForm.props.onSubmit(mockSubmitData); + expect(loginForm.props.loading).to.equal(true); + }); + + it('should add error and stop loading if login fails', function () { component.refs.loginForm.refs.password.focus.reset(); component.onUserStoreChanged('LOGIN_FAIL'); expect(loginForm.props.errors).to.deep.equal({password: 'Invalid password'}); + expect(loginForm.props.loading).to.equal(false); expect(component.refs.loginForm.refs.password.focus).to.have.been.called; }); @@ -72,4 +85,76 @@ describe('Login/Recover Widget', function () { expect(widgetTransition.props.sideToShow).to.equal('back'); }); }); + + describe('Recover Password form', function () { + let recoverWidget, recoverForm, widgetTransition, emailInput, component, + backToLoginButton, submitButton; + + beforeEach(function () { + component = TestUtils.renderIntoDocument( + + ); + widgetTransition = TestUtils.scryRenderedComponentsWithType(component, WidgetTransition)[0]; + recoverWidget = TestUtils.scryRenderedComponentsWithType(component, Widget)[1]; + recoverForm = TestUtils.scryRenderedComponentsWithType(component, Form)[1]; + emailInput = TestUtils.scryRenderedComponentsWithType(component, Input)[2]; + submitButton = TestUtils.scryRenderedComponentsWithType(component, SubmitButton)[1]; + backToLoginButton = TestUtils.scryRenderedComponentsWithType(component, Button)[1]; + + component.refs.recoverForm = { + refs: { + email: { + focus: stub() + } + } + }; + }); + + it('should control form errors by prop', function () { + expect(recoverForm.props.errors).to.deep.equal({}); + recoverForm.props.onValidateErrors({email: 'MOCK_ERROR'}); + expect(recoverForm.props.errors).to.deep.equal({email: 'MOCK_ERROR'}); + }); + + it('should trigger sendRecoverPassword action when submitted', function () { + let mockSubmitData = {email: 'MOCK_VALUE'}; + + UserActions.sendRecoverPassword.reset(); + recoverForm.props.onSubmit(mockSubmitData); + expect(UserActions.sendRecoverPassword).to.have.been.calledWith(mockSubmitData); + }); + + it('should set loading true in the form when submitted', function () { + let mockSubmitData = {email: 'MOCK_VALUE'}; + + recoverForm.props.onSubmit(mockSubmitData); + expect(recoverForm.props.loading).to.equal(true); + }); + + it('should add error and stop loading when send recover fails', function () { + component.refs.recoverForm.refs.email.focus.reset(); + component.onUserStoreChanged('SEND_RECOVER_FAIL'); + expect(recoverForm.props.errors).to.deep.equal({email: 'Email does not exist'}); + expect(recoverForm.props.loading).to.equal(false); + expect(component.refs.recoverForm.refs.email.focus).to.have.been.called; + }); + + it('should show message when send recover success', function () { + let message = TestUtils.scryRenderedComponentsWithType(component, Message)[0]; + expect(message).to.equal(undefined); + + component.onUserStoreChanged('SEND_RECOVER_SUCCESS'); + message = TestUtils.scryRenderedComponentsWithType(component, Message)[0]; + + expect(recoverForm.props.loading).to.equal(false); + expect(message).to.not.equal(null); + expect(message.props.type).to.equal('info'); + expect(message.props.children).to.equal('An email with recover instructions has been sent.'); + }); + + it('should show front side if \'Back to login form\' link is clicked', function () { + backToLoginButton.props.onClick(); + expect(widgetTransition.props.sideToShow).to.equal('front'); + }); + }); }); \ No newline at end of file diff --git a/client/src/app/main/main-home/main-home-page-login-widget.js b/client/src/app/main/main-home/main-home-page-login-widget.js index ff8cf267..4ba19b47 100644 --- a/client/src/app/main/main-home/main-home-page-login-widget.js +++ b/client/src/app/main/main-home/main-home-page-login-widget.js @@ -8,6 +8,7 @@ import UserStore from 'stores/user-store'; import focus from 'lib-core/focus'; import i18n from 'lib-app/i18n'; +import SubmitButton from 'core-components/submit-button'; import Button from 'core-components/button'; import Form from 'core-components/form'; import Input from 'core-components/input'; @@ -25,7 +26,9 @@ let MainHomePageLoginWidget = React.createClass({ sideToShow: 'front', loginFormErrors: {}, recoverFormErrors: {}, - recoverSent: false + recoverSent: false, + loadingLogin: false, + loadingRecover: false }; }, @@ -41,14 +44,14 @@ let MainHomePageLoginWidget = React.createClass({ renderLogin() { return ( - + - LOG IN + LOG IN {event.preventDefault()}}> @@ -61,12 +64,12 @@ let MainHomePageLoginWidget = React.createClass({ renderPasswordRecovery() { return ( - + - {i18n('RECOVER_PASSWORD')} + {i18n('RECOVER_PASSWORD')} {event.preventDefault()}}> @@ -91,12 +94,42 @@ let MainHomePageLoginWidget = React.createClass({ return status; }, + getLoginFormProps() { + return { + loading: this.state.loadingLogin, + className: 'login-widget__form', + ref: 'loginForm', + onSubmit:this.handleLoginFormSubmit, + errors: this.state.loginFormErrors, + onValidateErrors: this.handleLoginFormErrorsValidation + }; + }, + + getRecoverFormProps() { + return { + loading: this.state.loadingRecover, + className: 'login-widget__form', + ref: 'recoverForm', + onSubmit:this.handleForgotPasswordSubmit, + errors: this.state.recoverFormErrors, + onValidateErrors: this.handleRecoverFormErrorsValidation + }; + }, + handleLoginFormSubmit(formState) { UserActions.login(formState); + + this.setState({ + loadingLogin: true + }); }, handleForgotPasswordSubmit(formState) { - UserActions.sendRecover(formState); + UserActions.sendRecoverPassword(formState); + + this.setState({ + loadingRecover: true + }); }, handleLoginFormErrorsValidation(errors) { @@ -119,13 +152,15 @@ let MainHomePageLoginWidget = React.createClass({ handleBackToLoginClick() { this.setState({ - sideToShow: 'front' + sideToShow: 'front', + recoverSent: false }, this.moveFocusToCurrentSide); }, onUserStoreChanged(event) { if (event === 'LOGIN_FAIL') { this.setState({ + loadingLogin: false, loginFormErrors: { password: i18n('ERROR_PASSWORD') } @@ -136,6 +171,7 @@ let MainHomePageLoginWidget = React.createClass({ if (event === 'SEND_RECOVER_FAIL') { this.setState({ + loadingRecover: false, recoverFormErrors: { email: i18n('EMAIL_NOT_EXIST') } @@ -147,6 +183,7 @@ let MainHomePageLoginWidget = React.createClass({ if (event === 'SEND_RECOVER_SUCCESS') { this.setState({ + loadingRecover: false, recoverSent: true }); } diff --git a/client/src/app/main/main-recover-password/__tests__/main-recover-password-page-test.js b/client/src/app/main/main-recover-password/__tests__/main-recover-password-page-test.js new file mode 100644 index 00000000..7408dce4 --- /dev/null +++ b/client/src/app/main/main-recover-password/__tests__/main-recover-password-page-test.js @@ -0,0 +1,74 @@ +const CommonActions = require('actions/__mocks__/common-actions-mock'); +const UserActions = require('actions/__mocks__/user-actions-mock'); +const UserStore = require('stores/__mocks__/user-store-mock'); + +const SubmitButton = ReactMock(); +const Button = ReactMock(); +const Input = ReactMock(); +const Form = ReactMock(); +const Message = ReactMock(); +const Widget = ReactMock(); + +const MainRecoverPasswordPage = requireUnit('app/main/main-recover-password/main-recover-password-page', { + 'core-components/submit-button': SubmitButton, + 'core-components/button': Button, + 'core-components/input': Input, + 'core-components/form': Form, + 'core-components/message': Message, + 'core-components/widget': Widget, + 'actions/common-actions': CommonActions, + 'actions/user-actions': UserActions, + 'stores/user-store': UserStore +}); + +describe('Recover Password form', function () { + let recoverForm, inputs, component, submitButton; + let query = { + token: 'SOME_TOKEN', + email: 'SOME_EMAIL' + }; + + beforeEach(function () { + component = TestUtils.renderIntoDocument( + + ); + recoverForm = TestUtils.scryRenderedComponentsWithType(component, Form)[0]; + inputs = TestUtils.scryRenderedComponentsWithType(component, Input); + submitButton = TestUtils.scryRenderedComponentsWithType(component, SubmitButton)[0]; + }); + + it('should trigger recoverPassword action when submitted', function () { + UserActions.sendRecoverPassword.reset(); + recoverForm.props.onSubmit({password: 'MOCK_VALUE'}); + expect(UserActions.recoverPassword).to.have.been.calledWith({ + password: 'MOCK_VALUE', + token: 'SOME_TOKEN', + email: 'SOME_EMAIL' + }); + }); + + it('should set loading true in the form when submitted', function () { + recoverForm.props.onSubmit({password: 'MOCK_VALUE'}); + expect(recoverForm.props.loading).to.equal(true); + }); + + it('should show message when recover fails', function () { + component.onUserStoreChanged('INVALID_RECOVER'); + expect(recoverForm.props.loading).to.equal(false); + + let message = TestUtils.scryRenderedComponentsWithType(component, Message)[0]; + expect(message).to.not.equal(null); + expect(message.props.type).to.equal('error'); + expect(message.props.children).to.equal('Invalid recover data'); + }); + + it('should show message when recover success', function () { + component.onUserStoreChanged('VALID_RECOVER'); + expect(recoverForm.props.loading).to.equal(false); + + let message = TestUtils.scryRenderedComponentsWithType(component, Message)[0]; + expect(message).to.not.equal(null); + expect(message.props.type).to.equal('success'); + expect(message.props.children).to.equal('Password recovered successfully'); + }); +}); diff --git a/client/src/app/main/main-recover-password/main-recover-password-page.js b/client/src/app/main/main-recover-password/main-recover-password-page.js index afd70229..0909f457 100644 --- a/client/src/app/main/main-recover-password/main-recover-password-page.js +++ b/client/src/app/main/main-recover-password/main-recover-password-page.js @@ -10,7 +10,7 @@ import i18n from 'lib-app/i18n'; import Widget from 'core-components/widget'; import Form from 'core-components/form'; import Input from 'core-components/input'; -import Button from 'core-components/button'; +import SubmitButton from 'core-components/submit-button'; import Message from 'core-components/message'; const MainRecoverPasswordPage = React.createClass({ @@ -34,7 +34,8 @@ const MainRecoverPasswordPage = React.createClass({ getInitialState() { return { - recoverStatus: 'waiting' + recoverStatus: 'waiting', + loading: false }; }, @@ -42,13 +43,13 @@ const MainRecoverPasswordPage = React.createClass({ return ( - + - {i18n('SUBMIT')} + {i18n('SUBMIT')} {this.renderRecoverStatus()} @@ -73,17 +74,23 @@ const MainRecoverPasswordPage = React.createClass({ recoverData.token = this.props.location.query.token; recoverData.email = this.props.location.query.email; - UserActions.recoverPassword(formState); + UserActions.recoverPassword(recoverData); + this.setState({ + loading: true + }); }, onUserStoreChanged(event) { if (event === 'VALID_RECOVER') { + setTimeout(CommonActions.loggedOut, 2000); this.setState({ - recoverStatus: 'valid' + recoverStatus: 'valid', + loading: false }); } else { this.setState({ - recoverStatus: 'invalid' + recoverStatus: 'invalid', + loading: false }); } } diff --git a/client/src/core-components/__tests__/form-test.js b/client/src/core-components/__tests__/form-test.js index a6962ffc..ea1e1f7a 100644 --- a/client/src/core-components/__tests__/form-test.js +++ b/client/src/core-components/__tests__/form-test.js @@ -203,4 +203,22 @@ describe('Form component', function () { expect(fields[1].focus).to.have.been.called; }); }); + + describe('when using loading prop', function () { + it('should pass loading context in true if enabled', function () { + renderForm({ loading: true }); + + expect(fields[0].context.loading).to.equal(true); + expect(fields[1].context.loading).to.equal(true); + expect(fields[2].context.loading).to.equal(true); + }); + + it('should pass loading context in true if disabled', function () { + renderForm({ loading: false }); + + expect(fields[0].context.loading).to.equal(false); + expect(fields[1].context.loading).to.equal(false); + expect(fields[2].context.loading).to.equal(false); + }); + }); }); diff --git a/client/src/core-components/button.js b/client/src/core-components/button.js index f2cfbf5e..8b30ded0 100644 --- a/client/src/core-components/button.js +++ b/client/src/core-components/button.js @@ -6,7 +6,7 @@ import classNames from 'classnames'; // CORE LIBS import callback from 'lib-core/callback'; -let Button = React.createClass({ +const Button = React.createClass({ contextTypes: { router: React.PropTypes.object @@ -54,7 +54,8 @@ let Button = React.createClass({ getClass() { let classes = { - 'button': true + 'button': true, + 'button_disabled': this.props.disabled }; classes['button-' + this.props.type] = (this.props.type); diff --git a/client/src/core-components/button.scss b/client/src/core-components/button.scss index e177b896..974cf04e 100644 --- a/client/src/core-components/button.scss +++ b/client/src/core-components/button.scss @@ -27,4 +27,8 @@ outline: none; } } + + &_disabled { + background-color: #ec9696; + } } \ No newline at end of file diff --git a/client/src/core-components/form.js b/client/src/core-components/form.js index 530edca8..91409714 100644 --- a/client/src/core-components/form.js +++ b/client/src/core-components/form.js @@ -10,11 +10,22 @@ const Checkbox = require('core-components/checkbox'); const Form = React.createClass({ propTypes: { + loading: React.PropTypes.bool, errors: React.PropTypes.object, onValidateErrors: React.PropTypes.func, onSubmit: React.PropTypes.func }, + childContextTypes: { + loading: React.PropTypes.bool + }, + + getChildContext() { + return { + loading: this.props.loading + }; + }, + getInitialState() { return { form: {}, @@ -41,6 +52,7 @@ const Form = React.createClass({ props.onSubmit = this.handleSubmit; delete props.errors; + delete props.loading; delete props.onValidateErrors; return props; diff --git a/client/src/core-components/input.js b/client/src/core-components/input.js index 42e6d919..34ce06bf 100644 --- a/client/src/core-components/input.js +++ b/client/src/core-components/input.js @@ -6,6 +6,10 @@ const Icon = require('core-components/icon'); const Input = React.createClass({ + contextTypes: { + loading: React.PropTypes.bool + }, + propTypes: { value: React.PropTypes.string, validation: React.PropTypes.string, @@ -60,6 +64,7 @@ const Input = React.createClass({ props['aria-required'] = this.props.required; props.type = (this.props.password) ? 'password' : 'text'; props.ref = 'nativeInput'; + props.disabled = this.context.loading; delete props.required; delete props.validation; diff --git a/client/src/core-components/submit-button.js b/client/src/core-components/submit-button.js new file mode 100644 index 00000000..e901c9ac --- /dev/null +++ b/client/src/core-components/submit-button.js @@ -0,0 +1,58 @@ +// VENDOR LIBS +import React from 'react'; +import _ from 'lodash'; +import classNames from 'classnames'; + +// CORE LIBS +import Button from 'core-components/button'; + +const SubmitButton = React.createClass({ + + contextTypes: { + loading: React.PropTypes.bool + }, + + propTypes: { + children: React.PropTypes.node + }, + + getDefaultProps() { + return { + type: 'primary' + }; + }, + + render() { + return ( + + {(this.context.loading) ? this.renderLoading() : this.props.children} + + ); + }, + + renderLoading() { + return ( + + ); + }, + + getProps() { + return _.extend({}, this.props, { + disabled: this.context.loading, + className: this.getClass() + }); + }, + + getClass() { + let classes = { + 'submit-button': true, + 'submit-button_loading': this.context.loading + }; + + classes[this.props.className] = (this.props.className); + + return classNames(classes); + } +}); + +export default SubmitButton; diff --git a/client/src/core-components/submit-button.scss b/client/src/core-components/submit-button.scss new file mode 100644 index 00000000..f75110f1 --- /dev/null +++ b/client/src/core-components/submit-button.scss @@ -0,0 +1,46 @@ +.submit-button { + position: relative; + + &__loader { + position: absolute; + top: 7px; + left: 103px; + + font-size: 4px; + border-top: 1.1em solid rgba(255, 255, 255, 0.2); + border-right: 1.1em solid rgba(255, 255, 255, 0.2); + border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); + border-left: 1.1em solid #ffffff; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: turnAnimation 1.1s infinite linear; + animation: turnAnimation 1.1s infinite linear; + } + &__loader, + &__loader:after { + border-radius: 50%; + width: 30px; + height: 30px; + } + @-webkit-keyframes turnAnimation { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } + } + @keyframes turnAnimation { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } + } +} \ No newline at end of file diff --git a/client/src/data/fixtures/user-fixtures.js b/client/src/data/fixtures/user-fixtures.js index b17b4842..11523e1a 100644 --- a/client/src/data/fixtures/user-fixtures.js +++ b/client/src/data/fixtures/user-fixtures.js @@ -49,7 +49,7 @@ module.exports = [ }, { path: 'user/send-recover-password', - time: 100, + time: 2000, response: function (data) { if (data.email.length > 10) { @@ -68,7 +68,7 @@ module.exports = [ }, { path: 'user/recover-password', - time: 100, + time: 1000, response: function (data) { if (data.password.length > 6) { diff --git a/client/src/stores/user-store.js b/client/src/stores/user-store.js index 07ae890a..f70461d1 100644 --- a/client/src/stores/user-store.js +++ b/client/src/stores/user-store.js @@ -14,7 +14,7 @@ const UserStore = Reflux.createStore({ this.listenTo(UserActions.login, this.loginUser); this.listenTo(UserActions.logout, this.logoutUser); this.listenTo(UserActions.recoverPassword, this.recoverPassword); - this.listenTo(UserActions.sendRecover, this.sendRecoverPassword); + this.listenTo(UserActions.sendRecoverPassword, this.sendRecoverPassword); }, initSession() { @@ -72,7 +72,6 @@ const UserStore = Reflux.createStore({ data: recoverData }).then(() => { this.trigger('VALID_RECOVER'); - setTimeout(CommonActions.loggedOut, 1000); }, () => { this.trigger('INVALID_RECOVER') });