-
-
- {(pageTitle) => (
-
-
- {pageTitle}
-
- )}
-
+
+
+
+ {(pageTitle) => (
+
+
+ {pageTitle}
+
+ )}
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
-
-
-
+
);
}
+
+ onFormSubmit = () => {
+ this.props.onSubmit(this.form.serialize());
+ };
}
diff --git a/src/components/profile/passwordRequestForm/PasswordRequestForm.jsx b/src/components/profile/passwordRequestForm/PasswordRequestForm.jsx
new file mode 100644
index 0000000..f0f6b36
--- /dev/null
+++ b/src/components/profile/passwordRequestForm/PasswordRequestForm.jsx
@@ -0,0 +1,43 @@
+import React, { Component, PropTypes } from 'react';
+
+import { FormattedMessage as Message } from 'react-intl';
+
+import FormModel from 'models/Form';
+import { Form, Button, Input } from 'components/ui/Form';
+
+import messages from './PasswordRequestForm.messages';
+
+export default class PasswordRequestForm extends Component {
+ static displayName = 'PasswordRequestForm';
+
+ static propTypes = {
+ onSubmit: PropTypes.func.isRequired
+ };
+
+ form = new FormModel();
+
+ render() {
+ return (
+
+ );
+ }
+
+ onSubmit = () => {
+ this.props.onSubmit(this.form.value('password'));
+ };
+}
diff --git a/src/components/profile/passwordRequestForm/PasswordRequestForm.messages.js b/src/components/profile/passwordRequestForm/PasswordRequestForm.messages.js
new file mode 100644
index 0000000..b522c08
--- /dev/null
+++ b/src/components/profile/passwordRequestForm/PasswordRequestForm.messages.js
@@ -0,0 +1,12 @@
+import { defineMessages } from 'react-intl';
+
+export default defineMessages({
+ pleaseEnterPassword: {
+ id: 'pleaseEnterPassword',
+ defaultMessage: 'Please, enter your current password'
+ },
+ title: {
+ id: 'title',
+ defaultMessage: 'Confirm your action'
+ }
+});
diff --git a/src/components/ui/form.scss b/src/components/ui/form.scss
index 25519f4..2e12d8c 100644
--- a/src/components/ui/form.scss
+++ b/src/components/ui/form.scss
@@ -85,16 +85,6 @@
}
}
-.darkTextField {
- background: $black;
- border-color: lighter($black);
-}
-
-.lightTextField {
- background: #fff;
- border-color: #dcd8cd;
-}
-
.textFieldIcon {
box-sizing: border-box;
position: absolute;
@@ -104,13 +94,31 @@
width: 50px;
line-height: 46px;
text-align: center;
- border: 2px solid lighter($black);
+ border: 2px solid;
color: #444;
cursor: default;
@include form-transition();
}
+.darkTextField {
+ background: $black;
+
+ &,
+ ~ .textFieldIcon {
+ border-color: lighter($black);
+ }
+}
+
+.lightTextField {
+ background: #fff;
+
+ &,
+ ~ .textFieldIcon {
+ border-color: #dcd8cd;
+ }
+}
+
.textFieldLabel {
font-family: $font-family-title;
color: #666;
diff --git a/src/components/ui/popup/PopupStack.jsx b/src/components/ui/popup/PopupStack.jsx
new file mode 100644
index 0000000..dc5de6b
--- /dev/null
+++ b/src/components/ui/popup/PopupStack.jsx
@@ -0,0 +1,60 @@
+import React, { Component, PropTypes } from 'react';
+
+import styles from './popup.scss';
+
+export class PopupStack extends Component {
+ static displayName = 'PopupStack';
+
+ static propTypes = {
+ popups: PropTypes.arrayOf(PropTypes.shape({
+ type: PropTypes.string.isRequired,
+ props: PropTypes.oneOfType([PropTypes.object, PropTypes.func])
+ })),
+ pool: PropTypes.object.isRequired,
+ destroy: PropTypes.func.isRequired
+ };
+
+ render() {
+ const {popups, pool} = this.props;
+
+ return (
+
+ {popups.map((popup, index) => {
+ const Popup = pool[popup.type];
+
+ if (!Popup) {
+ throw new Error(`Unknown popup type: ${popup.type}`);
+ }
+
+ const defaultProps = {
+ onClose: this.onClose(popup)
+ };
+ const props = typeof popup.props === 'function'
+ ? popup.props(defaultProps)
+ : {...defaultProps, ...popup.props};
+
+ return (
+
+ );
+ })}
+
+ );
+ }
+
+ onClose(popup) {
+ return this.props.destroy.bind(null, popup);
+ }
+}
+
+import { connect } from 'react-redux';
+import { destroy } from 'components/ui/popup/actions';
+
+export default connect((state) => ({
+ ...state.popup
+}), {
+ destroy
+})(PopupStack);
diff --git a/src/components/ui/popup/actions.js b/src/components/ui/popup/actions.js
new file mode 100644
index 0000000..948b48f
--- /dev/null
+++ b/src/components/ui/popup/actions.js
@@ -0,0 +1,29 @@
+export const POPUP_REGISTER = 'POPUP_REGISTER';
+export function register(type, component) {
+ return {
+ type: POPUP_REGISTER,
+ payload: {
+ type,
+ component
+ }
+ };
+}
+
+export const POPUP_CREATE = 'POPUP_CREATE';
+export function create(type, props = {}) {
+ return {
+ type: POPUP_CREATE,
+ payload: {
+ type,
+ props
+ }
+ };
+}
+
+export const POPUP_DESTROY = 'POPUP_DESTROY';
+export function destroy(popup) {
+ return {
+ type: POPUP_DESTROY,
+ payload: popup
+ };
+}
diff --git a/src/components/ui/popup/popup.scss b/src/components/ui/popup/popup.scss
new file mode 100644
index 0000000..c8329a7
--- /dev/null
+++ b/src/components/ui/popup/popup.scss
@@ -0,0 +1,42 @@
+.overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+
+ z-index: 200;
+
+ background: rgba(255, 255, 255, 0.8);
+
+ text-align: center;
+ vertical-align: middle;
+ white-space: nowrap;
+
+ &:before {
+ content: '';
+ display: inline-block;
+ vertical-align: middle;
+ height: 100%;
+ width: 0;
+ }
+}
+
+.popup {
+ display: inline-block;
+ white-space: normal;
+
+ width: 400px;
+ max-width: 90%;
+
+ background: #fff;
+ padding: 30px;
+ box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
+
+ :invalid {
+ button {
+ opacity: 0.3;
+ pointer-events: none;
+ }
+ }
+}
diff --git a/src/components/ui/popup/reducer.js b/src/components/ui/popup/reducer.js
new file mode 100644
index 0000000..cf45fff
--- /dev/null
+++ b/src/components/ui/popup/reducer.js
@@ -0,0 +1,44 @@
+import { combineReducers } from 'redux';
+
+import { POPUP_REGISTER, POPUP_CREATE, POPUP_DESTROY } from './actions';
+
+export default combineReducers({
+ pool,
+ popups
+});
+
+function pool(pool = {}, {type, payload}) {
+ if (type === POPUP_REGISTER) {
+ if (!payload.type || !payload.component) {
+ throw new Error('Type and component are required');
+ }
+
+ return {
+ ...pool,
+ [payload.type]: payload.component
+ };
+ }
+
+ return pool;
+}
+
+function popups(popups = [], {type, payload}) {
+ switch (type) {
+ case POPUP_CREATE:
+ if (!payload.type) {
+ throw new Error('Popup type is required');
+ }
+
+ return popups.concat(payload);
+
+ case POPUP_DESTROY:
+ if (!payload.type) {
+ throw new Error('Popup type is required');
+ }
+
+ return popups.filter((popup) => popup !== payload);
+
+ default:
+ return popups;
+ }
+}
diff --git a/src/models/Form.js b/src/models/Form.js
index 0f1d240..4b96c72 100644
--- a/src/models/Form.js
+++ b/src/models/Form.js
@@ -15,6 +15,7 @@ export default class Form {
return {
name,
ref: (el) => {
+ // TODO: validate React component
this.fields[name] = el;
}
};
@@ -28,6 +29,14 @@ export default class Form {
this.fields[fieldId].focus();
}
+ value(fieldId) {
+ if (!this.fields[fieldId]) {
+ throw new Error(`The field with an id ${fieldId} does not exists`);
+ }
+
+ return this.fields[fieldId].getValue();
+ }
+
serialize() {
return Object.keys(this.fields).reduce((acc, key) => {
acc[key] = this.fields[key].getValue();
diff --git a/src/pages/profile/ChangePasswordPage.jsx b/src/pages/profile/ChangePasswordPage.jsx
new file mode 100644
index 0000000..6fd121c
--- /dev/null
+++ b/src/pages/profile/ChangePasswordPage.jsx
@@ -0,0 +1,52 @@
+import React, { Component, PropTypes } from 'react';
+
+import accounts from 'services/api/accounts';
+import ChangePassword from 'components/profile/changePassword/ChangePassword';
+import PasswordRequestForm from 'components/profile/passwordRequestForm/PasswordRequestForm';
+
+class ChangePasswordPage extends Component {
+ static displayName = 'ChangePasswordPage';
+
+ static propTypes = {
+ changePassword: PropTypes.func.isRequired
+ };
+
+ render() {
+ return (
+
+ );
+ }
+
+ onSubmit = (data) => {
+ this.props.changePassword(data);
+ };
+}
+
+import { connect } from 'react-redux';
+import { routeActions } from 'react-router-redux';
+import { register as registerPopup, create as createPopup } from 'components/ui/popup/actions';
+
+function goToProfile() {
+ return routeActions.push('/');
+}
+
+export default connect(null, {
+ changePassword: (data) => {
+ return (dispatch) => {
+ dispatch(registerPopup('requestPassword', PasswordRequestForm));
+ dispatch(createPopup('requestPassword', (props) => {
+ return {
+ onSubmit: (password) => {
+ // TODO: hide this logic in action and do not forget to update password change time
+ accounts.changePassword({
+ ...data,
+ password
+ })
+ .then(props.onClose)
+ .then(() => dispatch(goToProfile()));
+ }
+ };
+ }));
+ };
+ }
+})(ChangePasswordPage);
diff --git a/src/pages/root/RootPage.jsx b/src/pages/root/RootPage.jsx
index 5e3da14..5d4a905 100644
--- a/src/pages/root/RootPage.jsx
+++ b/src/pages/root/RootPage.jsx
@@ -1,41 +1,51 @@
import React, { PropTypes } from 'react';
import { Link } from 'react-router';
+import classNames from 'classnames';
import Userbar from 'components/userbar/Userbar';
+import PopupStack from 'components/ui/popup/PopupStack';
import styles from './root.scss';
function RootPage(props) {
return (
-
-
-
- Ely.by
-
-
-
+
-
- {props.children}
-
+
);
}
RootPage.displayName = 'RootPage';
RootPage.propTypes = {
- children: PropTypes.element
+ children: PropTypes.element,
+ logout: PropTypes.func.isRequired,
+ isPopupActive: PropTypes.bool
};
import { connect } from 'react-redux';
import { logout } from 'components/user/actions';
export default connect((state) => ({
- user: state.user
+ user: state.user,
+ isPopupActive: state.popup.popups.length > 0
}), {
logout
})(RootPage);
diff --git a/src/pages/root/root.scss b/src/pages/root/root.scss
index ba4f82d..0255f9e 100644
--- a/src/pages/root/root.scss
+++ b/src/pages/root/root.scss
@@ -7,6 +7,12 @@ $userBarHeight: 50px;
height: 100%;
}
+.isPopupActive {
+ filter: blur(5px);
+ transition: filter 0.4s ease;
+ overflow: hidden;
+}
+
.wrapper {
max-width: 756px;
margin: 0 auto;
diff --git a/src/reducers.js b/src/reducers.js
index a306dfe..a491740 100644
--- a/src/reducers.js
+++ b/src/reducers.js
@@ -1,7 +1,9 @@
import auth from 'components/auth/reducer';
import user from 'components/user/reducer';
+import popup from 'components/ui/popup/reducer';
export default {
auth,
- user
+ user,
+ popup
};
diff --git a/src/routes.js b/src/routes.js
index b563525..d44e59a 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -4,7 +4,9 @@ import { Route, IndexRoute } from 'react-router';
import RootPage from 'pages/root/RootPage';
import IndexPage from 'pages/index/IndexPage';
import AuthPage from 'pages/auth/AuthPage';
+
import ProfilePage from 'pages/profile/ProfilePage';
+import ProfileChangePasswordPage from 'pages/profile/ChangePasswordPage';
import { authenticate } from 'components/user/actions';
@@ -18,7 +20,6 @@ import ChangePassword from 'components/auth/changePassword/ChangePassword';
import ForgotPassword from 'components/auth/forgotPassword/ForgotPassword';
import Finish from 'components/auth/finish/Finish';
-import ProfileChangePassword from 'components/profile/changePassword/ChangePassword';
import authFlow from 'services/authFlow';
@@ -53,7 +54,7 @@ export default function routesFactory(store) {
-
+
);
diff --git a/tests/components/profile/changePassword/ChangePassword.test.jsx b/tests/components/profile/changePassword/ChangePassword.test.jsx
new file mode 100644
index 0000000..8659a35
--- /dev/null
+++ b/tests/components/profile/changePassword/ChangePassword.test.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { shallow } from 'enzyme';
+
+import ChangePassword from 'components/profile/changePassword/ChangePassword';
+
+describe('
', () => {
+ it('renders two
components', () => {
+ const component = shallow(
{}} />);
+
+ expect(component.find('LabeledInput')).to.have.length(2);
+ });
+
+
+ it('should call onSubmit if passwords entered', () => {
+ const onSubmit = sinon.spy();
+ const component = shallow();
+
+ component.find('Form').simulate('submit');
+
+ sinon.assert.calledOnce(onSubmit);
+ });
+});
diff --git a/tests/components/ui/popup/PopupStack.test.jsx b/tests/components/ui/popup/PopupStack.test.jsx
new file mode 100644
index 0000000..657b13a
--- /dev/null
+++ b/tests/components/ui/popup/PopupStack.test.jsx
@@ -0,0 +1,118 @@
+import React from 'react';
+
+import { shallow } from 'enzyme';
+
+import { PopupStack } from 'components/ui/popup/PopupStack';
+
+function DummyPopup() {}
+
+describe('', () => {
+ it('renders all popup components', () => {
+ const props = {
+ pool: {
+ dummy: DummyPopup
+ },
+ destroy: () => {},
+ popups: [
+ {
+ type: 'dummy'
+ },
+ {
+ type: 'dummy'
+ }
+ ]
+ };
+ const component = shallow();
+
+ expect(component.find(DummyPopup)).to.have.length(2);
+ });
+
+ it('should pass provided props', () => {
+ const expectedProps = {
+ foo: 'bar'
+ };
+
+ const props = {
+ pool: {
+ dummy: DummyPopup
+ },
+ destroy: () => {},
+ popups: [
+ {
+ type: 'dummy',
+ props: expectedProps
+ }
+ ]
+ };
+ const component = shallow();
+
+ expect(component.find(DummyPopup).prop('foo')).to.be.equal(expectedProps.foo);
+ });
+
+ it('should use props as proxy if it is function', () => {
+ const expectedProps = {
+ foo: 'bar'
+ };
+
+ const props = {
+ pool: {
+ dummy: DummyPopup
+ },
+ destroy: () => {},
+ popups: [
+ {
+ type: 'dummy',
+ props: (props) => {
+ expect(props).to.have.property('onClose');
+
+ return expectedProps;
+ }
+ }
+ ]
+ };
+ const component = shallow();
+
+ expect(component.find(DummyPopup).props()).to.be.deep.equal(expectedProps);
+ });
+
+ it('should hide popup, when onClose called', () => {
+ const props = {
+ pool: {
+ dummy: DummyPopup
+ },
+ popups: [
+ {
+ type: 'dummy'
+ },
+ {
+ type: 'dummy'
+ }
+ ],
+ destroy: sinon.stub()
+ };
+ const component = shallow();
+
+ component.find(DummyPopup).last().prop('onClose')();
+
+ sinon.assert.calledOnce(props.destroy);
+ sinon.assert.calledWith(props.destroy, sinon.match.same(props.popups[1]));
+ });
+
+ it('throws when there is no popup component in pool', () => {
+ const props = {
+ pool: {
+ dummy: DummyPopup
+ },
+ destroy: () => {},
+ popups: [
+ {
+ type: 'notExists'
+ }
+ ]
+ };
+
+ expect(() => {
+ shallow();
+ }).to.throw('Unknown popup type: notExists');
+ });
+});
diff --git a/tests/components/ui/popup/reducer.test.js b/tests/components/ui/popup/reducer.test.js
new file mode 100644
index 0000000..b3ef8bd
--- /dev/null
+++ b/tests/components/ui/popup/reducer.test.js
@@ -0,0 +1,98 @@
+import reducer from 'components/ui/popup/reducer';
+import {create, destroy, register} from 'components/ui/popup/actions';
+
+describe('popup/reducer', () => {
+ it('should have empty pool by default', () => {
+ const actual = reducer(undefined, {});
+
+ expect(actual.pool).to.be.an('object');
+ expect(actual.pool).to.be.empty;
+ });
+
+ it('should have no popups by default', () => {
+ const actual = reducer(undefined, {});
+
+ expect(actual.popups).to.be.an('array');
+ expect(actual.popups).to.be.empty;
+ });
+
+ describe('#register', () => {
+ it('should add popup components into pool', () => {
+ const actual = reducer(undefined, register('foo', function() {}));
+
+ expect(actual.pool.foo).to.be.a('function');
+ });
+
+ it('throws when no type or component provided', () => {
+ expect(() => reducer(undefined, register()), 'type').to.throw('Type and component are required');
+ expect(() => reducer(undefined, register('foo')), 'component').to.throw('Type and component are required');
+ });
+ });
+
+ describe('#create', () => {
+ it('should create popup', () => {
+ const actual = reducer(undefined, create('foo'));
+
+ expect(actual.popups[0]).to.be.deep.equal({
+ type: 'foo',
+ props: {}
+ });
+ });
+
+ it('should store props', () => {
+ const expectedProps = {foo: 'bar'};
+ const actual = reducer(undefined, create('foo', expectedProps));
+
+ expect(actual.popups[0]).to.be.deep.equal({
+ type: 'foo',
+ props: expectedProps
+ });
+ });
+
+ it('should not remove existed popups', () => {
+ let actual = reducer(undefined, create('foo'));
+ actual = reducer(actual, create('foo2'));
+
+ expect(actual.popups[1]).to.be.deep.equal({
+ type: 'foo2',
+ props: {}
+ });
+ });
+
+ it('throws when no type provided', () => {
+ expect(() => reducer(undefined, create())).to.throw('Popup type is required');
+ });
+ });
+
+ describe('#destroy', () => {
+ let state;
+ let popup;
+
+ beforeEach(() => {
+ state = reducer(undefined, register('foo', () => {}));
+ state = reducer(state, create('foo'));
+ popup = state.popups[0];
+ });
+
+ it('should remove popup', () => {
+ expect(state.popups).to.have.length(1);
+
+ state = reducer(state, destroy(popup));
+
+ expect(state.popups).to.have.length(0);
+ });
+
+ it('should not remove something, that it should not', () => {
+ state = reducer(state, create('foo'));
+
+ state = reducer(state, destroy(popup));
+
+ expect(state.popups).to.have.length(1);
+ expect(state.popups[0]).to.not.equal(popup);
+ });
+
+ it('throws when no type provided', () => {
+ expect(() => reducer(undefined, destroy({}))).to.throw('Popup type is required');
+ });
+ });
+});
diff --git a/tests/index.js b/tests/index.js
index fded395..c0813c3 100644
--- a/tests/index.js
+++ b/tests/index.js
@@ -1,4 +1,4 @@
// require all modules ending in "_test" from the
// current directory and all subdirectories
-var testsContext = require.context(".", true, /\.test\.js$/);
+var testsContext = require.context(".", true, /\.test\.jsx?$/);
testsContext.keys().forEach(testsContext);
diff --git a/webpack.config.js b/webpack.config.js
index 8913634..39699dc 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -31,6 +31,9 @@ var isTest = process.argv.some(function(arg) {
});
process.env.NODE_ENV = isProduction ? 'production' : 'development';
+if (isTest) {
+ process.env.NODE_ENV = 'test';
+}
const CSS_CLASS_TEMPLATE = isProduction ? '[hash:base64:5]' : '[path][name]-[local]';
var config;
@@ -78,6 +81,14 @@ var webpackConfig = {
extensions: ['', '.js', '.jsx']
},
+ externals: isTest ? {
+ // http://airbnb.io/enzyme/docs/guides/webpack.html
+ 'cheerio': 'window',
+ 'react/lib/ExecutionEnvironment': true,
+ 'react/lib/ReactContext': true,
+ 'react/addons': true
+ } : {},
+
devServer: {
host: 'localhost',
port: 8080,
@@ -142,6 +153,10 @@ var webpackConfig = {
{
test: /\.(png|gif|jpg)$/,
loader: 'url?limit=1000'
+ },
+ {
+ test: /\.json$/,
+ loader: 'json'
}
]
},