Complete change password flow prototype

This commit is contained in:
SleepWalker 2016-05-01 20:50:55 +03:00
parent 48a726567a
commit e2a782f0b7
21 changed files with 672 additions and 67 deletions

View File

@ -4,6 +4,10 @@
"env": {
"development": {
"presets": ["react-hmre"]
},
"test": {
// airbnb some how disables stage-0, so forcing it after airbnb
"presets": ["airbnb", "stage-0"]
}
}
}

View File

@ -21,7 +21,7 @@
"intl-format-cache": "^2.0.4",
"intl-messageformat": "^1.1.0",
"react": "^15.0.0",
"react-dom": "^15.0.0-rc.2",
"react-dom": "^15.0.0",
"react-helmet": "^2.3.1",
"react-intl": "^2.0.0",
"react-motion": "^0.4.0",
@ -38,6 +38,7 @@
"babel-loader": "^6.0.0",
"babel-plugin-react-intl": "~2.0.0",
"babel-plugin-transform-runtime": "^6.3.13",
"babel-preset-airbnb": "^1.1.1",
"babel-preset-es2015": "^6.3.13",
"babel-preset-react": "^6.3.13",
"babel-preset-react-hmre": "^1.0.1",
@ -47,6 +48,7 @@
"chokidar": "^1.2.0",
"css-loader": "^0.23.0",
"cssnano": "^3.4.0",
"enzyme": "^2.2.0",
"eslint": "^1.10.3",
"eslint-plugin-react": "^3.13.1",
"exports-loader": "^0.6.3",
@ -54,6 +56,7 @@
"file-loader": "^0.8.5",
"html-webpack-plugin": "^1.7.0",
"imports-loader": "^0.6.5",
"json-loader": "^0.5.4",
"karma": "*",
"karma-chai": "*",
"karma-es5-shim": "*",
@ -70,7 +73,7 @@
"phantomjs-prebuilt": "^2.0.0",
"postcss-loader": "^0.8.0",
"postcss-url": "^5.1.1",
"react-addons-test-utils": "^15.0.0",
"react-addons-test-utils": "^15.0.2",
"sass-loader": "^3.1.2",
"sinon": "^1.15.3",
"style-loader": "^0.13.0",

View File

@ -1,61 +1,87 @@
import React, { Component } from 'react';
import React, { Component, PropTypes } from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Link } from 'react-router';
import Helmet from 'react-helmet';
import { LabeledInput, Button } from 'components/ui/Form';
import FormModel from 'models/Form';
import { Form } from 'components/ui/Form';
import styles from 'components/profile/profileForm.scss';
import messages from './ChangePassword.messages';
export default class ChangePassword extends Component {
displayName = 'ChangePassword';
static displayName = 'ChangePassword';
static propTypes = {
onSubmit: PropTypes.func.isRequired
};
form = new FormModel();
render() {
const {form} = this;
return (
<div className={styles.contentWithBackButton}>
<Link className={styles.backButton} to="/" />
<Form onSubmit={this.onFormSubmit}>
<div className={styles.contentWithBackButton}>
<Link className={styles.backButton} to="/" />
<div className={styles.form}>
<div className={styles.formBody}>
<Message {...messages.changePasswordTitle}>
{(pageTitle) => (
<h3 className={styles.title}>
<Helmet title={pageTitle} />
{pageTitle}
</h3>
)}
</Message>
<div className={styles.form}>
<div className={styles.formBody}>
<Message {...messages.changePasswordTitle}>
{(pageTitle) => (
<h3 className={styles.title}>
<Helmet title={pageTitle} />
{pageTitle}
</h3>
)}
</Message>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.changePasswordDescription} />
<br/>
<b>
<Message {...messages.achievementLossWarning} />
</b>
</p>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.changePasswordDescription} />
<br/>
<b>
<Message {...messages.achievementLossWarning} />
</b>
</p>
</div>
<div className={styles.formRow}>
<LabeledInput {...form.bindField('newPassword')}
type="password"
required
skin="light"
label={messages.newPasswordLabel}
/>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.passwordRequirements} />
</p>
</div>
<div className={styles.formRow}>
<LabeledInput {...form.bindField('newRePassword')}
type="password"
required
skin="light"
label={messages.repeatNewPasswordLabel}
/>
</div>
</div>
<div className={styles.formRow}>
<LabeledInput skin="light" label={messages.newPasswordLabel} />
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.passwordRequirements} />
</p>
</div>
<div className={styles.formRow}>
<LabeledInput skin="light" label={messages.repeatNewPasswordLabel} />
</div>
<Button color="green" block label={messages.changePasswordButton} />
</div>
<Button color="green" block label={messages.changePasswordButton} />
</div>
</div>
</Form>
);
}
onFormSubmit = () => {
this.props.onSubmit(this.form.serialize());
};
}

View File

@ -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 (
<Form onSubmit={this.onSubmit}>
<h2>
<Message {...messages.title} />
</h2>
<Input {...this.form.bindField('password')}
type="password"
required
autoFocus
color="green"
skin="light"
icon="key"
placeholder={messages.pleaseEnterPassword}
/>
<Button color="green" label="OK" block />
</Form>
);
}
onSubmit = () => {
this.props.onSubmit(this.form.value('password'));
};
}

View File

@ -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'
}
});

View File

@ -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;

View File

@ -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 (
<div>
{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 (
<div className={styles.overlay} key={popup.type + index}>
<div className={styles.popup}>
<Popup {...props} />
</div>
</div>
);
})}
</div>
);
}
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);

View File

@ -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
};
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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 (
<ChangePassword onSubmit={this.onSubmit} />
);
}
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);

View File

@ -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 (
<div className={styles.root}>
<div className={styles.header}>
<div className={styles.headerContent}>
<Link to="/" className={styles.logo}>
Ely.by
</Link>
<div className={styles.userbar}>
<Userbar {...props} onLogout={props.logout} />
<div className={classNames(styles.root, {
[styles.isPopupActive]: props.isPopupActive
})}>
<div className={styles.header}>
<div className={styles.headerContent}>
<Link to="/" className={styles.logo}>
Ely.by
</Link>
<div className={styles.userbar}>
<Userbar {...props} onLogout={props.logout} />
</div>
</div>
</div>
<div className={styles.body}>
{props.children}
</div>
</div>
<div className={styles.body}>
{props.children}
</div>
<PopupStack />
</div>
);
}
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);

View File

@ -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;

View File

@ -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
};

View File

@ -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) {
</Route>
<Route path="profile" component={ProfilePage}>
<Route path="change-password" component={ProfileChangePassword} />
<Route path="change-password" component={ProfileChangePasswordPage} />
</Route>
</Route>
);

View File

@ -0,0 +1,23 @@
import React from 'react';
import { shallow } from 'enzyme';
import ChangePassword from 'components/profile/changePassword/ChangePassword';
describe('<ChangePassword />', () => {
it('renders two <LabeledInput /> components', () => {
const component = shallow(<ChangePassword onSubmit={() => {}} />);
expect(component.find('LabeledInput')).to.have.length(2);
});
it('should call onSubmit if passwords entered', () => {
const onSubmit = sinon.spy();
const component = shallow(<ChangePassword onSubmit={onSubmit} />);
component.find('Form').simulate('submit');
sinon.assert.calledOnce(onSubmit);
});
});

View File

@ -0,0 +1,118 @@
import React from 'react';
import { shallow } from 'enzyme';
import { PopupStack } from 'components/ui/popup/PopupStack';
function DummyPopup() {}
describe('<PopupStack />', () => {
it('renders all popup components', () => {
const props = {
pool: {
dummy: DummyPopup
},
destroy: () => {},
popups: [
{
type: 'dummy'
},
{
type: 'dummy'
}
]
};
const component = shallow(<PopupStack {...props} />);
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(<PopupStack {...props} />);
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(<PopupStack {...props} />);
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(<PopupStack {...props} />);
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(<PopupStack {...props} />);
}).to.throw('Unknown popup type: notExists');
});
});

View File

@ -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');
});
});
});

View File

@ -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);

View File

@ -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'
}
]
},