Form validation errors integration for ChangePassword

This commit is contained in:
SleepWalker 2016-05-02 12:20:50 +03:00
parent 5adfc60783
commit 82416f788f
13 changed files with 186 additions and 90 deletions

View File

@ -184,8 +184,8 @@ class PanelTransition extends Component {
this.body.onFormSubmit(); this.body.onFormSubmit();
}; };
onFormInvalid = (errorMessage) => { onFormInvalid = (errors) => {
this.props.setError(errorMessage); this.props.setError(Object.values(errors).shift());
}; };
willEnter = (config) => this.getTransitionStyles(config); willEnter = (config) => this.getTransitionStyles(config);

View File

@ -14,16 +14,23 @@ export default class ChangePassword extends Component {
static displayName = 'ChangePassword'; static displayName = 'ChangePassword';
static propTypes = { static propTypes = {
form: PropTypes.instanceOf(FormModel).isRequired,
onSubmit: PropTypes.func.isRequired onSubmit: PropTypes.func.isRequired
}; };
form = new FormModel(); static get defaultProps() {
return {
form: new FormModel()
};
}
render() { render() {
const {form} = this; const {form} = this.props;
return ( return (
<Form onSubmit={this.onFormSubmit}> <Form onSubmit={this.onFormSubmit}
form={form}
>
<div className={styles.contentWithBackButton}> <div className={styles.contentWithBackButton}>
<Link className={styles.backButton} to="/" /> <Link className={styles.backButton} to="/" />
@ -89,6 +96,6 @@ export default class ChangePassword extends Component {
} }
onFormSubmit = () => { onFormSubmit = () => {
this.props.onSubmit(this.form.serialize()); this.props.onSubmit(this.props.form);
}; };
} }

View File

@ -11,19 +11,22 @@ export default class PasswordRequestForm extends Component {
static displayName = 'PasswordRequestForm'; static displayName = 'PasswordRequestForm';
static propTypes = { static propTypes = {
form: PropTypes.instanceOf(FormModel).isRequired,
onSubmit: PropTypes.func.isRequired onSubmit: PropTypes.func.isRequired
}; };
form = new FormModel();
render() { render() {
const {form} = this.props;
return ( return (
<Form onSubmit={this.onSubmit}> <Form onSubmit={this.onSubmit}
form={form}
>
<h2> <h2>
<Message {...messages.title} /> <Message {...messages.title} />
</h2> </h2>
<Input {...this.form.bindField('password')} <Input {...form.bindField('password')}
type="password" type="password"
required required
autoFocus autoFocus
@ -38,6 +41,6 @@ export default class PasswordRequestForm extends Component {
} }
onSubmit = () => { onSubmit = () => {
this.props.onSubmit(this.form.value('password')); this.props.onSubmit(this.props.form);
}; };
} }

View File

@ -1,11 +1,12 @@
import React, { Component, PropTypes } from 'react'; import React, { PropTypes } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { intlShape } from 'react-intl';
import buttons from 'components/ui/buttons.scss'; import buttons from 'components/ui/buttons.scss';
export default class Button extends Component { import FormComponent from './FormComponent';
export default class Button extends FormComponent {
static displayName = 'Button'; static displayName = 'Button';
static propTypes = { static propTypes = {
@ -19,10 +20,6 @@ export default class Button extends Component {
color: PropTypes.oneOf(['green', 'blue', 'red', 'lightViolet', 'darkBlue']) color: PropTypes.oneOf(['green', 'blue', 'red', 'lightViolet', 'darkBlue'])
}; };
static contextTypes = {
intl: intlShape.isRequired
};
render() { render() {
const { color = 'green', block } = this.props; const { color = 'green', block } = this.props;
@ -30,9 +27,7 @@ export default class Button extends Component {
...this.props ...this.props
}; };
if (props.label.id) { props.label = this.formatMessage(props.label);
props.label = this.context.intl.formatMessage(props.label);
}
return ( return (
<button className={classNames(buttons[color], { <button className={classNames(buttons[color], {

View File

@ -1,11 +1,11 @@
import React, { Component, PropTypes } from 'react'; import React, { PropTypes } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { intlShape } from 'react-intl';
import styles from './form.scss'; import styles from './form.scss';
import FormInputComponent from './FormInputComponent';
export default class Checkbox extends Component { export default class Checkbox extends FormInputComponent {
static displayName = 'Checkbox'; static displayName = 'Checkbox';
static propTypes = { static propTypes = {
@ -19,16 +19,10 @@ export default class Checkbox extends Component {
]).isRequired ]).isRequired
}; };
static contextTypes = {
intl: intlShape.isRequired
};
render() { render() {
let { label, color = 'green', skin = 'dark' } = this.props; let { label, color = 'green', skin = 'dark' } = this.props;
if (label && label.id) { label = this.formatMessage(label);
label = this.context.intl.formatMessage(label);
}
return ( return (
<div className={classNames(styles[`${color}CheckboxRow`], styles[`${skin}CheckboxRow`])}> <div className={classNames(styles[`${color}CheckboxRow`], styles[`${skin}CheckboxRow`])}>
@ -37,14 +31,11 @@ export default class Checkbox extends Component {
<div className={styles.checkbox} /> <div className={styles.checkbox} />
{label} {label}
</label> </label>
{this.renderError()}
</div> </div>
); );
} }
setEl = (el) => {
this.el = el;
};
getValue() { getValue() {
return this.el.checked ? 1 : 0; return this.el.checked ? 1 : 0;
} }

View File

@ -2,6 +2,8 @@ import React, { Component, PropTypes } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import FormModel from 'models/Form';
import styles from './form.scss'; import styles from './form.scss';
export default class Form extends Component { export default class Form extends Component {
@ -10,6 +12,7 @@ export default class Form extends Component {
static propTypes = { static propTypes = {
id: PropTypes.string, // and id, that uniquely identifies form contents id: PropTypes.string, // and id, that uniquely identifies form contents
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
form: PropTypes.instanceOf(FormModel),
onSubmit: PropTypes.func, onSubmit: PropTypes.func,
onInvalid: PropTypes.func, onInvalid: PropTypes.func,
children: PropTypes.oneOfType([ children: PropTypes.oneOfType([
@ -71,17 +74,30 @@ export default class Form extends Component {
if (form.checkValidity()) { if (form.checkValidity()) {
this.props.onSubmit(); this.props.onSubmit();
} else { } else {
const firstError = form.querySelectorAll(':invalid')[0]; const invalidEls = form.querySelectorAll(':invalid');
firstError.focus(); const errors = {};
invalidEls[0].focus(); // focus on first error
let errorMessage = firstError.validationMessage; Array.from(invalidEls).reduce((errors, el) => {
if (firstError.validity.valueMissing) { if (!el.name) {
errorMessage = `error.${firstError.name}_required`; console.warn('Found an element without name', el);
} else if (firstError.validity.typeMismatch) { return errors;
errorMessage = `error.${firstError.name}_invalid`; }
}
this.props.onInvalid(errorMessage); let errorMessage = el.validationMessage;
if (el.validity.valueMissing) {
errorMessage = `error.${el.name}_required`;
} else if (el.validity.typeMismatch) {
errorMessage = `error.${el.name}_invalid`;
}
errors[el.name] = errorMessage;
return errors;
}, errors);
this.props.form && this.props.form.setErrors(errors);
this.props.onInvalid(errors);
} }
}; };
} }

View File

@ -0,0 +1,19 @@
import React, { Component } from 'react';
import { intlShape } from 'react-intl';
export default class FormComponent extends Component {
static displayName = 'FormComponent';
static contextTypes = {
intl: intlShape.isRequired
};
formatMessage(message) {
if (message && message.id && this.context && this.context.intl) {
message = this.context.intl.formatMessage(message);
}
return message;
}
}

View File

@ -0,0 +1,44 @@
import React, { PropTypes } from 'react';
import errorsDict from 'services/errorsDict';
import styles from './form.scss';
import FormComponent from './FormComponent';
export default class FormInputComponent extends FormComponent {
static displayName = 'FormInputComponent';
static propTypes = {
error: PropTypes.string
};
componentWillReceiveProps() {
if (this.state && this.state.error) {
Reflect.deleteProperty(this.state, 'error');
this.setState(this.state);
}
}
setEl = (el) => {
this.el = el;
};
renderError() {
const error = this.state && this.state.error || this.props.error;
if (error) {
return (
<div className={styles.fieldError}>
{errorsDict.resolve(error)}
</div>
);
}
return null;
}
setError(error) {
this.setState({error});
}
}

View File

@ -1,14 +1,14 @@
import React, { Component, PropTypes } from 'react'; import React, { PropTypes } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { intlShape } from 'react-intl';
import { uniqueId } from 'functions'; import { uniqueId } from 'functions';
import icons from 'components/ui/icons.scss'; import icons from 'components/ui/icons.scss';
import styles from './form.scss'; import styles from './form.scss';
import FormInputComponent from './FormInputComponent';
export default class Input extends Component { export default class Input extends FormInputComponent {
static displayName = 'Input'; static displayName = 'Input';
static propTypes = { static propTypes = {
@ -23,19 +23,15 @@ export default class Input extends Component {
id: PropTypes.string id: PropTypes.string
}), }),
PropTypes.string PropTypes.string
]).isRequired, ]),
error: PropTypes.string, error: PropTypes.string,
icon: PropTypes.string, icon: PropTypes.string,
skin: PropTypes.oneOf(['dark', 'light']), skin: PropTypes.oneOf(['dark', 'light']),
color: PropTypes.oneOf(['green', 'blue', 'red', 'lightViolet', 'darkBlue']) color: PropTypes.oneOf(['green', 'blue', 'red', 'lightViolet', 'darkBlue'])
}; };
static contextTypes = {
intl: intlShape.isRequired
};
render() { render() {
let { icon, color = 'green', skin = 'dark', error, label } = this.props; let { icon, color = 'green', skin = 'dark', label } = this.props;
const props = { const props = {
type: 'text', type: 'text',
@ -47,9 +43,7 @@ export default class Input extends Component {
props.id = uniqueId('input'); props.id = uniqueId('input');
} }
if (label.id) { label = this.formatMessage(label);
label = this.context.intl.formatMessage(label);
}
label = ( label = (
<label className={styles.textFieldLabel} htmlFor={props.id}> <label className={styles.textFieldLabel} htmlFor={props.id}>
@ -58,9 +52,7 @@ export default class Input extends Component {
); );
} }
if (props.placeholder && props.placeholder.id) { props.placeholder = this.formatMessage(props.placeholder);
props.placeholder = this.context.intl.formatMessage(props.placeholder);
}
let baseClass = styles.formRow; let baseClass = styles.formRow;
if (icon) { if (icon) {
@ -70,33 +62,24 @@ export default class Input extends Component {
); );
} }
if (error) {
error = (
<div className={styles.fieldError}>
error
</div>
);
}
return ( return (
<div className={baseClass}> <div className={baseClass}>
{label} {label}
<div className={styles.textFieldContainer}> <div className={styles.textFieldContainer}>
<input ref={this.setEl} className={classNames( <input ref={this.setEl}
styles[`${skin}TextField`], className={classNames(
styles[`${color}TextField`] styles[`${skin}TextField`],
)} {...props} /> styles[`${color}TextField`]
)}
{...props}
/>
{icon} {icon}
</div> </div>
{error} {this.renderError()}
</div> </div>
); );
} }
setEl = (el) => {
this.el = el;
};
getValue() { getValue() {
return this.el.value; return this.el.value;
} }

View File

@ -1,5 +1,8 @@
import FormInputComponent from 'components/ui/form/FormInputComponent';
export default class Form { export default class Form {
fields = {}; fields = {};
errors = {};
/** /**
* Connects form with React's component * Connects form with React's component
@ -12,13 +15,22 @@ export default class Form {
* @return {Object} ref and name props for component * @return {Object} ref and name props for component
*/ */
bindField(name) { bindField(name) {
return { const props = {
name, name,
ref: (el) => { ref: (el) => {
// TODO: validate React component if (!(el instanceof FormInputComponent)) {
throw new Error('Expected a component from components/ui/form module');
}
this.fields[name] = el; this.fields[name] = el;
} }
}; };
if (this.getError(name)) {
props.error = this.getError(name);
}
return props;
} }
focus(fieldId) { focus(fieldId) {
@ -37,9 +49,24 @@ export default class Form {
return this.fields[fieldId].getValue(); return this.fields[fieldId].getValue();
} }
setErrors(errors) {
const oldErrors = this.errors;
this.errors = errors;
Object.keys(this.fields).forEach((fieldId) => {
if (oldErrors[fieldId] || errors[fieldId]) {
this.fields[fieldId].setError(errors[fieldId] || null);
}
});
}
getError(fieldId) {
return this.errors[fieldId] || null;
}
serialize() { serialize() {
return Object.keys(this.fields).reduce((acc, key) => { return Object.keys(this.fields).reduce((acc, fieldId) => {
acc[key] = this.fields[key].getValue(); acc[fieldId] = this.fields[fieldId].getValue();
return acc; return acc;
}, {}); }, {});

View File

@ -1,6 +1,7 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import accounts from 'services/api/accounts'; import accounts from 'services/api/accounts';
import FormModel from 'models/Form';
import ChangePassword from 'components/profile/changePassword/ChangePassword'; import ChangePassword from 'components/profile/changePassword/ChangePassword';
import PasswordRequestForm from 'components/profile/passwordRequestForm/PasswordRequestForm'; import PasswordRequestForm from 'components/profile/passwordRequestForm/PasswordRequestForm';
@ -11,14 +12,16 @@ class ChangePasswordPage extends Component {
changePassword: PropTypes.func.isRequired changePassword: PropTypes.func.isRequired
}; };
form = new FormModel();
render() { render() {
return ( return (
<ChangePassword onSubmit={this.onSubmit} /> <ChangePassword onSubmit={this.onSubmit} form={this.form} />
); );
} }
onSubmit = (data) => { onSubmit = () => {
this.props.changePassword(data); this.props.changePassword(this.form);
}; };
} }
@ -32,16 +35,23 @@ function goToProfile() {
} }
export default connect(null, { export default connect(null, {
changePassword: (data) => { changePassword: (form) => {
return (dispatch) => { return (dispatch) => {
// TODO: судя по всему registerPopup было явно лишним. Надо еще раз
// обдумать API и переписать
dispatch(registerPopup('requestPassword', PasswordRequestForm)); dispatch(registerPopup('requestPassword', PasswordRequestForm));
dispatch(createPopup('requestPassword', (props) => { dispatch(createPopup('requestPassword', (props) => {
return { return {
onSubmit: (password) => { form,
onSubmit: () => {
// TODO: hide this logic in action // TODO: hide this logic in action
accounts.changePassword({ accounts.changePassword(form.serialize())
...data, .catch((resp) => {
password if (resp.errors) {
form.setErrors(resp.errors);
}
return Promise.reject(resp);
}) })
.then(() => { .then(() => {
dispatch(updateUser({ dispatch(updateUser({

View File

@ -8,13 +8,14 @@ export default {
resolve(error) { resolve(error) {
return errorsMap[error] ? errorsMap[error]() : error; return errorsMap[error] ? errorsMap[error]() : error;
} }
} };
const errorsMap = { const errorsMap = {
'error.login_required': () => <Message {...messages.loginRequired} />, 'error.login_required': () => <Message {...messages.loginRequired} />,
'error.login_not_exist': () => <Message {...messages.loginNotExist} />, 'error.login_not_exist': () => <Message {...messages.loginNotExist} />,
'error.password_required': () => <Message {...messages.passwordRequired} />, 'error.password_required': () => <Message {...messages.passwordRequired} />,
'error.password_invalid': () => <Message {...messages.invalidPassword} />,
'error.password_incorrect': () => ( 'error.password_incorrect': () => (
<span> <span>
<Message {...messages.invalidPassword} /> <Message {...messages.invalidPassword} />

View File

@ -3,7 +3,7 @@ import { defineMessages } from 'react-intl';
export default defineMessages({ export default defineMessages({
invalidPassword: { invalidPassword: {
id: 'invalidPassword', id: 'invalidPassword',
defaultMessage: 'You entered wrong account password.' defaultMessage: 'You have entered wrong account password.'
}, },
suggestResetPassword: { suggestResetPassword: {