Add live validation to password reset

Adds a new React factory for that page to handle the logic. Also
cleans up the UI a little (centers it, stops using serif font, etc).
This commit is contained in:
Michael Terry
2018-04-02 11:12:44 -04:00
committed by Michael Terry
parent 32f9902f2e
commit c19d01a994
14 changed files with 322 additions and 98 deletions

View File

@@ -0,0 +1,11 @@
module.exports = {
extends: 'eslint-config-edx',
root: true,
settings: {
'import/resolver': {
webpack: {
config: 'webpack.dev.config.js',
},
},
},
};

View File

@@ -0,0 +1,142 @@
/* globals gettext */
import 'whatwg-fetch';
import PropTypes from 'prop-types';
import React from 'react';
import { Button, StatusAlert } from '@edx/paragon/static';
import PasswordResetInput from './PasswordResetInput';
// NOTE: Use static paragon with this because some internal classes (StatusAlert at least)
// conflict with some standard LMS ones ('alert' at least). This means that you need to do
// something like the following on any templates that use this class:
//
// <link type='text/css' rel='stylesheet' href='${STATIC_URL}paragon/static/paragon.min.css'>
//
class PasswordResetConfirmation extends React.Component {
constructor(props) {
super(props);
this.state = {
password: '',
passwordConfirmation: '',
showMatchError: false,
isValid: true,
validationMessage: '',
};
this.onBlurPassword1 = this.onBlurPassword1.bind(this);
this.onBlurPassword2 = this.onBlurPassword2.bind(this);
}
onBlurPassword1(password) {
this.updatePasswordState(password, this.state.passwordConfirmation);
this.validatePassword(password);
}
onBlurPassword2(passwordConfirmation) {
this.updatePasswordState(this.state.password, passwordConfirmation);
}
updatePasswordState(password, passwordConfirmation) {
this.setState({
password,
passwordConfirmation,
showMatchError: !!password && !!passwordConfirmation && (password !== passwordConfirmation),
});
}
validatePassword(password) {
fetch('/api/user/v1/validation/registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
password,
}),
})
.then(res => res.json())
.then((response) => {
let validationMessage = '';
// Be careful about grabbing this message, since we could have received an HTTP error or the
// endpoint didn't give us what we expect. We only care if we get a clear error message.
if (response.validation_decisions && response.validation_decisions.password) {
validationMessage = response.validation_decisions.password;
}
this.setState({
isValid: !validationMessage,
validationMessage,
});
});
}
render() {
return (
<section id="password-reset-confirm-anchor" className="form-type">
<div id="password-reset-confirm-form" className="form-wrapper" aria-live="polite">
<StatusAlert
alertType="danger"
dismissible={false}
open={!!this.props.errorMessage}
dialog={this.props.errorMessage}
/>
<form id="passwordreset-form" method="post" action="">
<h2 className="section-title lines">
<span className="text">
{gettext('Reset Your Password')}
</span>
</h2>
<p className="action-label" id="new_password_help_text">
{gettext('Enter and confirm your new password.')}
</p>
<PasswordResetInput
name="new_password1"
describedBy="new_password_help_text"
label={gettext('New Password')}
onBlur={this.onBlurPassword1}
isValid={this.state.isValid}
validationMessage={this.state.validationMessage}
/>
<PasswordResetInput
name="new_password2"
describedBy="new_password_help_text"
label={gettext('Confirm Password')}
onBlur={this.onBlurPassword2}
isValid={!this.state.showMatchError}
validationMessage={gettext('Passwords do not match.')}
/>
<input
type="hidden"
id="csrf_token"
name="csrfmiddlewaretoken"
value={this.props.csrfToken}
/>
<Button
type="submit"
className={['action', 'action-primary', 'action-update', 'js-reset']}
label={gettext('Reset My Password')}
/>
</form>
</div>
</section>
);
}
}
PasswordResetConfirmation.propTypes = {
csrfToken: PropTypes.string.isRequired,
errorMessage: PropTypes.string,
};
PasswordResetConfirmation.defaultProps = {
errorMessage: '',
};
export { PasswordResetConfirmation }; // eslint-disable-line import/prefer-default-export

View File

@@ -0,0 +1,27 @@
/* globals gettext */
import PropTypes from 'prop-types';
import React from 'react';
import { InputText } from '@edx/paragon/static';
function PasswordResetInput(props) {
return (
<div className="form-field">
<InputText
id={props.name}
type="password"
themes={['danger']}
dangerIconDescription={gettext('Error: ')}
required
{...props}
/>
</div>
);
}
PasswordResetInput.propTypes = {
name: PropTypes.string.isRequired,
};
export default PasswordResetInput;

View File

@@ -0,0 +1,68 @@
/* globals setFixtures */
import ReactDOM from 'react-dom';
import React from 'react';
import sinon from 'sinon'; // eslint-disable-line import/no-extraneous-dependencies
import { PasswordResetConfirmation } from '../PasswordResetConfirmation';
describe('PasswordResetConfirmation', () => {
beforeEach(() => {
setFixtures('<div id="wrapper"></div>');
sinon.stub(window, 'fetch');
});
afterEach(() => {
window.fetch.restore();
});
function init(submitError) {
ReactDOM.render(
React.createElement(PasswordResetConfirmation, {
csrfToken: 'csrfToken',
errorMessage: submitError,
}, null),
document.getElementById('wrapper'),
);
}
function triggerValidation() {
$('#new_password1').focus();
$('#new_password1').val('a');
$('#new_password2').focus();
expect(window.fetch.calledWithMatch(
'/api/user/v1/validation/registration',
{ body: JSON.stringify({ password: 'a' }) },
));
}
function prepareValidation(validationError, done) {
window.fetch.reset();
window.fetch.callsFake(() => {
done();
return Promise.resolve({
json: () => ({ validation_decisions: { password: validationError } }),
});
});
}
it('shows submit error', () => {
init('Submit error.');
expect($('.alert-dialog')).toExist();
expect($('.alert-dialog')).not.toBeHidden();
expect($('.alert-dialog')).toHaveText('Submit error.');
});
describe('validation', () => {
beforeEach((done) => {
init('');
prepareValidation('Validation error.', done);
triggerValidation();
});
it('shows validation error', () => {
expect($('#error-new_password1')).toContainText('Validation error.');
});
});
});