diff --git a/client/package.json b/client/package.json index 07a0990f..d6e54b3b 100644 --- a/client/package.json +++ b/client/package.json @@ -13,7 +13,7 @@ "npm": "^2.1.x" }, "scripts": { - "test": "export NODE_PATH=src && mocha src/lib-test/preprocessor.js --compilers js:babel-core/register --recursive src/**/__tests__/*-test.js" + "test": "export NODE_PATH=src && mocha src/lib-test/preprocessor.js --compilers js:babel-core/register --recursive src/**/**/__tests__/*-test.js" }, "devDependencies": { "babel-core": "^5.8.22", diff --git a/client/src/actions/__mocks__/common-actions-mock.js b/client/src/actions/__mocks__/common-actions-mock.js new file mode 100644 index 00000000..1027e3bf --- /dev/null +++ b/client/src/actions/__mocks__/common-actions-mock.js @@ -0,0 +1,5 @@ +export default { + changeLanguage: stub(), + logged: stub(), + loggedOut: stub() +}; \ No newline at end of file diff --git a/client/src/actions/__mocks__/user-actions-mock.js b/client/src/actions/__mocks__/user-actions-mock.js new file mode 100644 index 00000000..b8a568db --- /dev/null +++ b/client/src/actions/__mocks__/user-actions-mock.js @@ -0,0 +1,5 @@ +export default { + checkLoginStatus: stub(), + login: stub(), + logout: stub() +}; \ No newline at end of file diff --git a/client/src/actions/common-actions.js b/client/src/actions/common-actions.js index e15290ef..8112df05 100644 --- a/client/src/actions/common-actions.js +++ b/client/src/actions/common-actions.js @@ -1,7 +1,9 @@ import Reflux from 'reflux'; let CommonActions = Reflux.createActions([ - 'changeLanguage' + 'changeLanguage', + 'logged', + 'loggedOut' ]); export default CommonActions; \ No newline at end of file diff --git a/client/src/app/App.js b/client/src/app/App.js index cb34adc4..91b2ce24 100644 --- a/client/src/app/App.js +++ b/client/src/app/App.js @@ -1,13 +1,14 @@ import React from 'react'; import Reflux from 'reflux'; -import {ListenerMixin} from 'reflux'; -import {RouteHandler} from 'react-router'; -import CommonActions from 'actions/common-actions'; import CommonStore from 'stores/common-store'; let App = React.createClass({ + contextTypes: { + router: React.PropTypes.object + }, + mixins: [Reflux.listenTo(CommonStore, 'onCommonStoreChanged')], render() { @@ -19,8 +20,14 @@ let App = React.createClass({ }, onCommonStoreChanged(change) { - if (change === 'i18n') { - this.forceUpdate(); + let handle = { + 'i18n': () => {this.forceUpdate()}, + 'logged': () => {this.context.router.push('/app/dashboard')}, + 'loggedOut': () => {this.context.router.push('/app')} + }; + + if (handle[change]) { + handle[change](); } } }); diff --git a/client/src/app/__tests__/App-test.js b/client/src/app/__tests__/App-test.js new file mode 100644 index 00000000..6bc02936 --- /dev/null +++ b/client/src/app/__tests__/App-test.js @@ -0,0 +1,43 @@ +const CommonStore = require('stores/__mocks__/common-store-mock'); + +const App = requireUnit('app/App', { + 'store/common-store': CommonStore +}); + +describe('App component', function () { + describe('when reacting to CommonStore', function () { + let app; + + beforeEach(function () { + app = TestUtils.renderIntoDocument( + MOCK_CHILD + ); + + app.context = { + router: { + push: stub() + } + }; + + spy(app, 'forceUpdate'); + }); + + it('should update with i18n', function () { + app.forceUpdate.reset(); + app.onCommonStoreChanged('i18n'); + expect(app.forceUpdate).to.have.been.called; + }); + + it('should redirect when logged in', function () { + app.context.router.push.reset(); + app.onCommonStoreChanged('logged'); + expect(app.context.router.push).to.have.been.calledWith('/app/dashboard'); + }); + + it('should redirect when logged out', function () { + app.context.router.push.reset(); + app.onCommonStoreChanged('loggedOut'); + expect(app.context.router.push).to.have.been.calledWith('/app'); + }); + }); +}); diff --git a/client/src/app/main/dashboard/__tests__/dashboard-layout-test.js b/client/src/app/main/dashboard/__tests__/dashboard-layout-test.js new file mode 100644 index 00000000..29cfac8c --- /dev/null +++ b/client/src/app/main/dashboard/__tests__/dashboard-layout-test.js @@ -0,0 +1,32 @@ +const CommonActions = require('actions/__mocks__/common-actions-mock'); +const UserStore = require('stores/__mocks__/user-store-mock'); + +const DashboardLayout = requireUnit('app/main/dashboard/dashboard-layout', { + 'actions/common-actions': CommonActions, + 'stores/user-store': UserStore, + 'app/main/dashboard/dashboard-menu': ReactMock() +}); + + +describe('Dashboard page', function () { + + afterEach(function () { + UserStore.isLoggedIn.returns(false); + }); + + it('should trigger common action if user is not logged', function () { + CommonActions.loggedOut.reset(); + UserStore.isLoggedIn.returns(false); + + TestUtils.renderIntoDocument(); + expect(CommonActions.loggedOut).to.have.been.called; + }); + + it('should not trigger common action user if is logged', function () { + CommonActions.loggedOut.reset(); + UserStore.isLoggedIn.returns(true); + + TestUtils.renderIntoDocument(); + expect(CommonActions.loggedOut).to.not.have.been.called; + }); +}); \ No newline at end of file diff --git a/client/src/app/main/dashboard/dashboard-layout.js b/client/src/app/main/dashboard/dashboard-layout.js index 29f3fc69..d8bfa4bd 100644 --- a/client/src/app/main/dashboard/dashboard-layout.js +++ b/client/src/app/main/dashboard/dashboard-layout.js @@ -1,8 +1,18 @@ import React from 'react'; + +import UserStore from 'stores/user-store'; +import CommonActions from 'actions/common-actions'; + import DashboardMenu from 'app/main/dashboard/dashboard-menu'; const DashboardLayout = React.createClass({ + componentWillMount() { + if (!UserStore.isLoggedIn()) { + CommonActions.loggedOut(); + } + }, + render() { return (
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 new file mode 100644 index 00000000..e34d5965 --- /dev/null +++ b/client/src/app/main/main-home/__tests__/main-home-page-login-widget-test.js @@ -0,0 +1,75 @@ +const UserActions = require('actions/__mocks__/user-actions-mock'); +const UserStore = require('stores/__mocks__/user-store-mock'); + +const Button = ReactMock(); +const Input = ReactMock(); +const Form = ReactMock(); +const Checkbox = ReactMock(); +const Widget = ReactMock(); +const WidgetTransition = ReactMock(); + +const MainHomePageLoginWidget = requireUnit('app/main/main-home/main-home-page-login-widget', { + 'core-components/button': Button, + 'core-components/input': Input, + 'core-components/form': Form, + 'core-components/checkbox': Checkbox, + 'core-components/widget': Widget, + 'core-components/widget-transition': WidgetTransition, + 'actions/user-actions': UserActions, + 'stores/user-store': UserStore +}); + + +describe('Login/Recover Widget', function () { + describe('Login Form', function () { + let loginWidget, loginForm, widgetTransition, inputs, checkbox, component, + forgotPasswordButton; + + beforeEach(function () { + component = TestUtils.renderIntoDocument( + + ); + widgetTransition = TestUtils.scryRenderedComponentsWithType(component, WidgetTransition)[0]; + loginWidget = TestUtils.scryRenderedComponentsWithType(component, Widget)[0]; + loginForm = TestUtils.scryRenderedComponentsWithType(component, Form)[0]; + inputs = TestUtils.scryRenderedComponentsWithType(component, Input); + checkbox = TestUtils.scryRenderedComponentsWithType(component, Checkbox)[0]; + forgotPasswordButton = TestUtils.scryRenderedComponentsWithType(component, Button)[1]; + + component.refs.loginForm = { + refs: { + password: { + focus: stub() + } + } + }; + }); + + it('should control form errors by prop', function () { + expect(loginForm.props.errors).to.deep.equal({}); + loginForm.props.onValidateErrors({email: 'MOCK_ERROR'}); + expect(loginForm.props.errors).to.deep.equal({email: 'MOCK_ERROR'}); + }); + + it('should trigger login action when submitted', function () { + let mockSubmitData = {email: 'MOCK_VALUE', password: 'MOCK_VALUE'}; + + UserActions.login.reset(); + loginForm.props.onSubmit(mockSubmitData); + expect(UserActions.login).to.have.been.calledWith(mockSubmitData); + }); + + it('should add error if login fails', function () { + component.refs.loginForm.refs.password.focus.reset(); + component.onUserStoreChanged('LOGIN_FAIL'); + expect(loginForm.props.errors).to.deep.equal({password: 'Password does not match'}); + expect(component.refs.loginForm.refs.password.focus).to.have.been.called; + }); + + it('should show back side if \'Forgot your password?\' link is clicked', function () { + expect(widgetTransition.props.sideToShow).to.equal('front'); + forgotPasswordButton.props.onClick(); + expect(widgetTransition.props.sideToShow).to.equal('back'); + }); + }); +}); \ No newline at end of file diff --git a/client/src/app/main/main-home/__tests__/main-home-page-test.js b/client/src/app/main/main-home/__tests__/main-home-page-test.js new file mode 100644 index 00000000..736b0ed4 --- /dev/null +++ b/client/src/app/main/main-home/__tests__/main-home-page-test.js @@ -0,0 +1,31 @@ +const CommonActions = require('actions/__mocks__/common-actions-mock'); +const UserStore = require('stores/__mocks__/user-store-mock'); + +const MainHomePage = requireUnit('app/main/main-home/main-home-page', { + 'actions/common-actions': CommonActions, + 'stores/user-store': UserStore +}); + + +describe('Main home page', function () { + + afterEach(function () { + UserStore.isLoggedIn.returns(false); + }); + + it('should trigger common action if user is currently logged', function () { + CommonActions.logged.reset(); + UserStore.isLoggedIn.returns(true); + + TestUtils.renderIntoDocument(); + expect(CommonActions.logged).to.have.been.called; + }); + + it('should not trigger common action user if is not logged', function () { + CommonActions.logged.reset(); + UserStore.isLoggedIn.returns(false); + + TestUtils.renderIntoDocument(); + expect(CommonActions.logged).to.not.have.been.called; + }); +}); \ 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 fce0d34e..9b5f90ee 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 @@ -1,4 +1,6 @@ -const React = require( 'react'); +const React = require('react'); +const Reflux = require('reflux'); +const _ = require('lodash'); const classNames = require('classnames'); const UserActions = require('actions/user-actions'); @@ -12,10 +14,13 @@ const Widget = require('core-components/widget'); const WidgetTransition = require('core-components/widget-transition'); let MainHomePageLoginWidget = React.createClass({ + + mixins: [Reflux.listenTo(UserStore, 'onUserStoreChanged')], getInitialState() { return { - sideToShow: 'front' + sideToShow: 'front', + loginFormErrors: {} }; }, @@ -31,7 +36,7 @@ let MainHomePageLoginWidget = React.createClass({ renderLogin() { return ( -
+
@@ -41,7 +46,7 @@ let MainHomePageLoginWidget = React.createClass({
-
@@ -51,7 +56,7 @@ let MainHomePageLoginWidget = React.createClass({ renderPasswordRecovery() { return ( -
+
@@ -67,11 +72,20 @@ let MainHomePageLoginWidget = React.createClass({ }, handleLoginFormSubmit(formState) { - console.log(formState); UserActions.login(formState); }, - handleForgetPasswordClick() { + handleForgotPasswordSubmit() { + + }, + + handleLoginFormErrorsValidation(errors) { + this.setState({ + loginFormErrors: errors + }); + }, + + handleForgotPasswordClick() { this.setState({ sideToShow: 'back' }); @@ -81,6 +95,18 @@ let MainHomePageLoginWidget = React.createClass({ this.setState({ sideToShow: 'front' }); + }, + + onUserStoreChanged(event) { + if (event === 'LOGIN_FAIL') { + this.setState({ + loginFormErrors: { + password: 'Password does not match' + } + }, function () { + this.refs.loginForm.refs.password.focus() + }.bind(this)); + } } }); diff --git a/client/src/app/main/main-home/main-home-page.js b/client/src/app/main/main-home/main-home-page.js index aa7a2de7..eb3218cf 100644 --- a/client/src/app/main/main-home/main-home-page.js +++ b/client/src/app/main/main-home/main-home-page.js @@ -3,8 +3,17 @@ const React = require( 'react'); const MainHomePageLoginWidget = require('app/main/main-home/main-home-page-login-widget'); const MainHomePagePortal = require('app/main/main-home/main-home-page-portal'); +const CommonActions = require('actions/common-actions'); +const UserStore = require('stores/user-store'); + const MainHomePage = React.createClass({ + componentWillMount() { + if (UserStore.isLoggedIn()) { + CommonActions.logged(); + } + }, + render() { return (
diff --git a/client/src/app/main/main-layout-header.js b/client/src/app/main/main-layout-header.js index 12b60c08..b1c33fdc 100644 --- a/client/src/app/main/main-layout-header.js +++ b/client/src/app/main/main-layout-header.js @@ -2,6 +2,8 @@ import React from 'react'; import i18n from 'lib-app/i18n'; import CommonActions from 'actions/common-actions'; +import UserActions from 'actions/user-actions'; +import UserStore from 'stores/user-store'; import Button from 'core-components/button'; import DropDown from 'core-components/drop-down'; @@ -22,13 +24,31 @@ let MainLayoutHeader = React.createClass({ render() { return (
+ {this.renderAccessLinks()} + +
+ ); + }, + + renderAccessLinks() { + let result; + if (UserStore.isLoggedIn()) { + result = ( +
+ Welcome, pepito + +
+ ); + } else { + result = (
- -
- ); + ); + } + + return result; }, getLanguageList() { @@ -44,6 +64,10 @@ let MainLayoutHeader = React.createClass({ let language = languageList[event.index]; CommonActions.changeLanguage(codeLanguages[language]); + }, + + logout() { + UserActions.logout(); } }); diff --git a/client/src/app/main/main-signup/__tests__/main-signup-page-test.js b/client/src/app/main/main-signup/__tests__/main-signup-page-test.js new file mode 100644 index 00000000..0c267a6e --- /dev/null +++ b/client/src/app/main/main-signup/__tests__/main-signup-page-test.js @@ -0,0 +1,32 @@ +const CommonActions = require('actions/__mocks__/common-actions-mock'); +const UserStore = require('stores/__mocks__/user-store-mock'); + +const MainSignupPage = requireUnit('app/main/main-signup/main-signup-page', { + 'actions/common-actions': CommonActions, + 'stores/user-store': UserStore, + 'react-google-recaptcha': ReactMock() +}); + + +describe('Signup page', function () { + + afterEach(function () { + UserStore.isLoggedIn.returns(false); + }); + + it('should trigger common action if user is currently logged', function () { + CommonActions.logged.reset(); + UserStore.isLoggedIn.returns(true); + + TestUtils.renderIntoDocument(); + expect(CommonActions.logged).to.have.been.called; + }); + + it('should not trigger common action user if is not logged', function () { + CommonActions.logged.reset(); + UserStore.isLoggedIn.returns(false); + + TestUtils.renderIntoDocument(); + expect(CommonActions.logged).to.not.have.been.called; + }); +}); \ No newline at end of file diff --git a/client/src/app/main/main-signup/main-signup-page.js b/client/src/app/main/main-signup/main-signup-page.js index 316ab717..23b2e76e 100644 --- a/client/src/app/main/main-signup/main-signup-page.js +++ b/client/src/app/main/main-signup/main-signup-page.js @@ -11,8 +11,16 @@ import Input from 'core-components/input'; import Widget from 'core-components/widget'; import WidgetTransition from 'core-components/widget-transition'; +const CommonActions = require('actions/common-actions'); + let MainSignUpPageWidget = React.createClass({ + componentDidMount() { + if (UserStore.isLoggedIn()) { + CommonActions.logged(); + } + }, + render() { return (
diff --git a/client/src/core-components/__tests__/form-test.js b/client/src/core-components/__tests__/form-test.js index ac73aca7..a6962ffc 100644 --- a/client/src/core-components/__tests__/form-test.js +++ b/client/src/core-components/__tests__/form-test.js @@ -2,7 +2,7 @@ const ValidationFactoryMock = require('lib-app/__mocks__/validations/validation-factory-mock'); const Input = ReactMock(); -// COMPONENTS +// COMPONENT const Form = requireUnit('core-components/form', { 'lib-app/validations/validations-factory': ValidationFactoryMock, 'core-components/input': Input @@ -11,9 +11,9 @@ const Form = requireUnit('core-components/form', { describe('Form component', function () { let form, fields, onSubmit = stub(); - function renderForm() { + function renderForm(props = {}) { form = TestUtils.renderIntoDocument( - +
@@ -90,17 +90,77 @@ describe('Form component', function () { expect(fields[0].props.error).to.equal(undefined); expect(fields[0].props.error).to.equal(undefined); - TestUtils.Simulate.blur(ReactDOM.findDOMNode(fields[0])); + fields[0].props.onBlur(); expect(fields[0].props.error).to.equal('MOCK_ERROR'); - TestUtils.Simulate.blur(ReactDOM.findDOMNode(fields[1])); + fields[1].props.onBlur(); expect(fields[1].props.error).to.equal('MOCK_ERROR_2'); - TestUtils.Simulate.blur(ReactDOM.findDOMNode(fields[2])); + fields[2].props.onBlur(); expect(fields[2].props.error).to.equal(undefined); }); }); + describe('when using controlled errors', function () { + let onValidateErrors; + + beforeEach(function () { + onValidateErrors = stub(); + + ValidationFactoryMock.validators.defaultValidatorMock.validate = stub().returns('MOCK_ERROR'); + ValidationFactoryMock.validators.customValidatorMock.validate = stub().returns('MOCK_ERROR_2'); + + renderForm({ + errors: {first: 'MOCK_ERROR_CONTROLLED'}, + onValidateErrors: onValidateErrors + }); + }); + afterEach(resetStubs); + + it('should pass the errors to inputs', function () { + expect(fields[0].props.error).to.equal('MOCK_ERROR_CONTROLLED'); + expect(fields[1].props.error).to.equal(undefined); + }); + + it('should prioritize prop error over state error', function () { + fields[1].props.onBlur(); + expect(fields[1].props.error).to.equal(undefined); + }); + + it('should call onValidateErrors when state changes', function () { + fields[1].props.onBlur(); + expect(onValidateErrors).to.have.been.calledWith({second: 'MOCK_ERROR_2'}); + + }); + + it('should still working if the error prop changes', function () { + function setErrorsOrRender(errors = {}) { + form = reRenderIntoDocument( + +
+ + +
+ + + ); + fields = TestUtils.scryRenderedComponentsWithType(form, Input); + } + + setErrorsOrRender(); + expect(fields[0].props.error).to.equal(undefined); + expect(fields[1].props.error).to.equal(undefined); + + setErrorsOrRender({second: 'MOCK_ERROR_CONTROLLED_2'}); + expect(fields[0].props.error).to.equal(undefined); + expect(fields[1].props.error).to.equal('MOCK_ERROR_CONTROLLED_2'); + + setErrorsOrRender({first: 'MOCK_ERROR_CONTROLLED', second: 'MOCK_ERROR_CONTROLLED_2'}); + expect(fields[0].props.error).to.equal('MOCK_ERROR_CONTROLLED'); + expect(fields[1].props.error).to.equal('MOCK_ERROR_CONTROLLED_2'); + }); + }); + describe('when submitting the form', function () { beforeEach(renderForm); afterEach(resetStubs); diff --git a/client/src/core-components/button.js b/client/src/core-components/button.js index c0ccec07..f2cfbf5e 100644 --- a/client/src/core-components/button.js +++ b/client/src/core-components/button.js @@ -1,6 +1,10 @@ -var React = require('react'); -var classNames = require('classnames'); -var callback = require('lib-core/callback'); +// VENDOR LIBS +import React from 'react'; +import _ from 'lodash'; +import classNames from 'classnames'; + +// CORE LIBS +import callback from 'lib-core/callback'; let Button = React.createClass({ @@ -30,12 +34,24 @@ let Button = React.createClass({ render() { return ( - ); }, + getProps() { + let props = _.clone(this.props); + + props.onClick = callback(this.handleClick, this.props.onClick); + props.className = this.getClass(); + + delete props.route; + delete props.type; + + return props; + }, + getClass() { let classes = { 'button': true diff --git a/client/src/core-components/checkbox.js b/client/src/core-components/checkbox.js index 48f775a2..bd36f4fd 100644 --- a/client/src/core-components/checkbox.js +++ b/client/src/core-components/checkbox.js @@ -46,7 +46,11 @@ let CheckBox = React.createClass({ props.className = 'checkbox--box'; props.checked = this.getValue(); props.onChange = callback(this.handleChange, this.props.onChange); - props.value = null; + + delete props.alignment; + delete props.error; + delete props.label; + delete props.value; return props; }, diff --git a/client/src/core-components/form.js b/client/src/core-components/form.js index b05508b2..530edca8 100644 --- a/client/src/core-components/form.js +++ b/client/src/core-components/form.js @@ -1,5 +1,4 @@ const React = require('react'); -const ReactDOM = require('react-dom'); const _ = require('lodash'); const {reactDFS, renderChildrenWithProps} = require('lib-core/react-dfs'); @@ -10,6 +9,12 @@ const Checkbox = require('core-components/checkbox'); const Form = React.createClass({ + propTypes: { + errors: React.PropTypes.object, + onValidateErrors: React.PropTypes.func, + onSubmit: React.PropTypes.func + }, + getInitialState() { return { form: {}, @@ -34,6 +39,9 @@ const Form = React.createClass({ let props = _.clone(this.props); props.onSubmit = this.handleSubmit; + + delete props.errors; + delete props.onValidateErrors; return props; }, @@ -47,7 +55,7 @@ const Form = React.createClass({ additionalProps = { ref: fieldName, value: this.state.form[fieldName] || props.value, - error: this.state.errors[fieldName], + error: this.getFieldError(fieldName), onChange: this.handleFieldChange.bind(this, fieldName, type), onBlur: this.validateField.bind(this, fieldName) } @@ -56,6 +64,15 @@ const Form = React.createClass({ return additionalProps; }, + getFieldError(fieldName) { + let error = this.state.errors[fieldName]; + + if (this.props.errors) { + error = this.props.errors[fieldName] + } + return error; + }, + getFirstErrorField() { let fieldName = _.findKey(this.state.errors); let fieldNode; @@ -119,9 +136,7 @@ const Form = React.createClass({ event.preventDefault(); if (this.hasFormErrors()) { - this.setState({ - errors: this.getAllFieldErrors() - }, this.focusFirstErrorField); + this.updateErrors(this.getAllFieldErrors(), this.focusFirstErrorField); } else if (this.props.onSubmit) { this.props.onSubmit(this.state.form); } @@ -150,9 +165,17 @@ const Form = React.createClass({ }, validateField(fieldName) { + this.updateErrors(this.getErrorsWithValidatedField(fieldName)); + }, + + updateErrors(errors, callback) { this.setState({ - errors: this.getErrorsWithValidatedField(fieldName) - }); + errors + }, callback); + + if (this.props.onValidateErrors) { + this.props.onValidateErrors(errors); + } }, focusFirstErrorField() { diff --git a/client/src/core-components/input.js b/client/src/core-components/input.js index 66b497d0..42e6d919 100644 --- a/client/src/core-components/input.js +++ b/client/src/core-components/input.js @@ -57,11 +57,16 @@ const Input = React.createClass({ getInputProps() { let props = _.clone(this.props); - props.required = null; props['aria-required'] = this.props.required; props.type = (this.props.password) ? 'password' : 'text'; props.ref = 'nativeInput'; + delete props.required; + delete props.validation; + delete props.inputType; + delete props.error; + delete props.password; + return props; }, diff --git a/client/src/core-components/menu.js b/client/src/core-components/menu.js index 7f73faa2..7654726f 100644 --- a/client/src/core-components/menu.js +++ b/client/src/core-components/menu.js @@ -48,7 +48,11 @@ const Menu = React.createClass({ var props = _.clone(this.props); props.className = this.getClass(); - props.type = null; + + delete props.items; + delete props.onItemClick; + delete props.selectedIndex; + delete props.type; return props; }, diff --git a/client/src/data/fixtures/user-fixtures.js b/client/src/data/fixtures/user-fixtures.js index cba6af7e..f98981f2 100644 --- a/client/src/data/fixtures/user-fixtures.js +++ b/client/src/data/fixtures/user-fixtures.js @@ -22,5 +22,15 @@ module.exports = [ return response; } + }, + { + path: 'user/logout', + time: 1000, + response: function () { + return { + status: 'success', + data: {} + }; + } } ]; diff --git a/client/src/lib-app/__mocks__/api-call-mock.js b/client/src/lib-app/__mocks__/api-call-mock.js new file mode 100644 index 00000000..3fcd2003 --- /dev/null +++ b/client/src/lib-app/__mocks__/api-call-mock.js @@ -0,0 +1,3 @@ +export default { + call: stub() +}; \ No newline at end of file diff --git a/client/src/lib-app/__mocks__/session-store-mock.js b/client/src/lib-app/__mocks__/session-store-mock.js new file mode 100644 index 00000000..5ceed298 --- /dev/null +++ b/client/src/lib-app/__mocks__/session-store-mock.js @@ -0,0 +1,6 @@ +export default { + createSession: stub(), + getSessionData: stub().returns({}), + isLoggedIn: stub().returns(false), + closeSession: stub() +}; \ No newline at end of file diff --git a/client/src/lib-app/api-call.js b/client/src/lib-app/api-call.js index a2885b08..2db55bb8 100644 --- a/client/src/lib-app/api-call.js +++ b/client/src/lib-app/api-call.js @@ -1,22 +1,21 @@ const _ = require('lodash'); const APIUtils = require('lib-core/APIUtils'); -const SessionStorage = require('sessionstorage'); +const SessionStore = require('lib-app/session-store'); const root = 'http://localhost:3000/api/'; function processData (data) { - return _.extend({ - userId: SessionStorage.getItem('userId'), - token: SessionStorage.getItem('token') - }, data); + return _.extend(SessionStore.getSessionData(), data); } module.exports = { - call: function (path, data, callback) { - APIUtils.post(root + path, processData(data)).then(callback); - }, - setConfig: function (userId, token) { - SessionStorage.setItem('userId', userId); - SessionStorage.setItem('token', token); + call: function ({path, data, onSuccess, onFail}) { + APIUtils.post(root + path, processData(data)).then(function (result) { + if (result.status === 'success') { + onSuccess && onSuccess(result); + } else { + onFail && onFail(result); + } + }); } }; \ No newline at end of file diff --git a/client/src/lib-app/session-store.js b/client/src/lib-app/session-store.js new file mode 100644 index 00000000..f6a05b77 --- /dev/null +++ b/client/src/lib-app/session-store.js @@ -0,0 +1,35 @@ +import SessionStorage from 'sessionstorage'; + + +class SessionStore { + static initialize() { + if (!SessionStorage.getItem('language')) { + SessionStorage.setItem('language', 'english'); + } + } + + static createSession(userId, token) { + SessionStorage.setItem('userId', userId); + SessionStorage.setItem('token', token); + } + + static getSessionData() { + return { + userId: SessionStorage.getItem('userId'), + token: SessionStorage.getItem('token') + }; + } + + static isLoggedIn() { + return !!SessionStorage.getItem('userId'); + } + + static closeSession() { + SessionStorage.removeItem('userId'); + SessionStorage.removeItem('token'); + } +} + +SessionStore.initialize(); + +export default SessionStore; \ No newline at end of file diff --git a/client/src/lib-test/preprocessor.js b/client/src/lib-test/preprocessor.js index 78afdb67..3af8576b 100644 --- a/client/src/lib-test/preprocessor.js +++ b/client/src/lib-test/preprocessor.js @@ -19,3 +19,14 @@ global.TestUtils = require('react-addons-test-utils'); global.requireUnit = function (path, mocks) { return proxyquire(process.cwd() + '/src/' + path + '.js', mocks) }; +global.reRenderIntoDocument = (function () { + let div; + + return function (jsx) { + if (!div) { + div = document.createElement('div') + } + + return ReactDOM.render(jsx, div); + } +})(); diff --git a/client/src/lib-test/react-mock.js b/client/src/lib-test/react-mock.js index a5cd8142..ec6091ab 100644 --- a/client/src/lib-test/react-mock.js +++ b/client/src/lib-test/react-mock.js @@ -4,7 +4,7 @@ const _ = require('lodash'); module.exports = function (options) { return React.createClass(_.extend({ render() { - return
; + return
{this.props.children}
; } }, options)); }; \ No newline at end of file diff --git a/client/src/stores/__mocks__/common-store-mock.js b/client/src/stores/__mocks__/common-store-mock.js new file mode 100644 index 00000000..1a488e3d --- /dev/null +++ b/client/src/stores/__mocks__/common-store-mock.js @@ -0,0 +1,6 @@ +export default { + changeLanguage: stub(), + logged: stub(), + loggedOut: stub(), + listen: stub() +}; \ No newline at end of file diff --git a/client/src/stores/__mocks__/user-store-mock.js b/client/src/stores/__mocks__/user-store-mock.js new file mode 100644 index 00000000..abba4ba6 --- /dev/null +++ b/client/src/stores/__mocks__/user-store-mock.js @@ -0,0 +1,6 @@ +export default { + loginUser: stub(), + logoutUser: stub(), + isLoggedIn: stub().returns(false), + listen: stub() +}; \ No newline at end of file diff --git a/client/src/stores/__tests__/user-store-test.js b/client/src/stores/__tests__/user-store-test.js new file mode 100644 index 00000000..5ccc6790 --- /dev/null +++ b/client/src/stores/__tests__/user-store-test.js @@ -0,0 +1,105 @@ +// MOCKS +const CommonActions = require('actions/__mocks__/common-actions-mock'); +const SessionStore = require('lib-app/__mocks__/session-store-mock'); +const API = require('lib-app/__mocks__/api-call-mock'); +const UserActions = { + checkLoginStatus: {listen: stub()}, + login: {listen: stub()}, + logout: {listen: stub()} +}; + +const UserStore = requireUnit('stores/user-store', { + 'actions/user-actions': UserActions, + 'actions/common-actions': CommonActions, + 'lib-app/session-store': SessionStore, + 'lib-app/api-call': API +}); + +describe('UserStore', function () { + describe('when login user', function () { + it('should call /user/login api path', function () { + let mockLoginData = {email: 'mock', password: 'mock'}; + + UserStore.loginUser(mockLoginData); + expect(API.call).to.have.been.calledWith({ + path: 'user/login', + data: mockLoginData, + onSuccess: sinon.match.func, + onFail: sinon.match.func + }); + }); + + it('should create session, trigger success event and inform common action when having a successful login', function () { + let mockLoginData = {email: 'mock', password: 'mock'}; + let mockSuccessData = { + status: 'success', + data: { + userId: 12, + token: 'RANDOM_TOKEN' + } + }; + + spy(UserStore, 'trigger'); + CommonActions.logged.reset(); + SessionStore.createSession.reset(); + API.call = ({onSuccess}) => {onSuccess(mockSuccessData)}; + + UserStore.loginUser(mockLoginData); + + expect(SessionStore.createSession).to.have.been.calledWith(12, 'RANDOM_TOKEN'); + expect(UserStore.trigger).to.have.been.calledWith('LOGIN_SUCCESS'); + expect(CommonActions.logged).to.have.been.called; + UserStore.trigger.restore(); + }); + + it('should trigger fail event if login fails', function () { + let mockLoginData = {email: 'mock', password: 'mock'}; + let mockSuccessData = { + status: 'success', + data: { + userId: 12, + token: 'RANDOM_TOKEN' + } + }; + + spy(UserStore, 'trigger'); + API.call = ({onFail}) => {onFail(mockSuccessData)}; + + UserStore.loginUser(mockLoginData); + + expect(UserStore.trigger).to.have.been.calledWith('LOGIN_FAIL'); + UserStore.trigger.restore(); + }); + }); + + describe('when login out', function () { + + it('should call /user/logout api path', function () { + API.call = stub(); + + UserStore.logoutUser(); + expect(API.call).to.have.been.calledWith({ + path: 'user/logout', + onSuccess: sinon.match.func + }); + }); + + it('should delete session, trigger LOGOUT event and inform common action of logout', function () { + API.call = ({onSuccess}) => {onSuccess()}; + spy(UserStore, 'trigger'); + + UserStore.logoutUser(); + expect(SessionStore.closeSession).to.have.been.called; + expect(UserStore.trigger).to.have.been.calledWith('LOGOUT'); + expect(CommonActions.loggedOut).to.have.been.called; + UserStore.trigger.restore() + }) + }); + + it ('should inform is the user is logged based on SessionStores\' info', function () { + SessionStore.isLoggedIn.returns(true); + expect(UserStore.isLoggedIn()).to.equal(true); + SessionStore.isLoggedIn.returns(false); + expect(UserStore.isLoggedIn()).to.equal(false); + }); +}); diff --git a/client/src/stores/common-store.js b/client/src/stores/common-store.js index b24f1fd8..b4a82de7 100644 --- a/client/src/stores/common-store.js +++ b/client/src/stores/common-store.js @@ -8,11 +8,21 @@ let CommonStore = Reflux.createStore({ this.language = 'us'; this.listenTo(CommonActions.changeLanguage, this.changeLanguage); + this.listenTo(CommonActions.logged, this.logged); + this.listenTo(CommonActions.loggedOut, this.loggedOut); }, changeLanguage(lang) { this.language = lang; this.trigger('i18n'); + }, + + logged() { + this.trigger('logged'); + }, + + loggedOut() { + this.trigger('loggedOut'); } }); diff --git a/client/src/stores/user-store.js b/client/src/stores/user-store.js index 200c2ed7..93118c36 100644 --- a/client/src/stores/user-store.js +++ b/client/src/stores/user-store.js @@ -1,13 +1,14 @@ const Reflux = require('reflux'); const API = require('lib-app/api-call'); +const SessionStore = require('lib-app/session-store'); const UserActions = require('actions/user-actions'); +const CommonActions = require('actions/common-actions'); const UserStore = Reflux.createStore({ init() { this.user = null; - this.hasBeenChecked = false; this.listenTo(UserActions.checkLoginStatus, this.checkLoginStatus); this.listenTo(UserActions.login, this.loginUser); @@ -15,11 +16,37 @@ const UserStore = Reflux.createStore({ }, loginUser(loginData) { - API.call('user/login', loginData, result => { - console.log(result); - - API.setConfig(result.userId, result.token); + API.call({ + path: 'user/login', + data: loginData, + onSuccess: this.handleLoginSuccess, + onFail: this.handleLoginFail }); + }, + + logoutUser() { + API.call({ + path: 'user/logout', + onSuccess: function () { + SessionStore.closeSession(); + this.trigger('LOGOUT'); + CommonActions.loggedOut(); + }.bind(this) + }); + }, + + isLoggedIn() { + return SessionStore.isLoggedIn(); + }, + + handleLoginSuccess(result) { + SessionStore.createSession(result.data.userId, result.data.token); + this.trigger('LOGIN_SUCCESS'); + CommonActions.logged(); + }, + + handleLoginFail() { + this.trigger('LOGIN_FAIL'); } });