Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change email dialog #22309

Merged
merged 29 commits into from
May 15, 2018
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d9d55da
Nonfunctional dialog layout with story
islemaster May 9, 2018
5728937
Attach ChangeEmailModal to account page
islemaster May 9, 2018
64c8ee9
Interactable fields and client-side validation
islemaster May 9, 2018
7598a61
Able to successfully update email address
islemaster May 10, 2018
b6cb184
Better documentation of the Rails UJS scheme
islemaster May 10, 2018
c25c79f
Remove copypasta'd comment
islemaster May 10, 2018
907957b
Server sends back validation errors, client handles them gracefully
islemaster May 10, 2018
249f179
Proper JSON response and handling of unknown error
islemaster May 11, 2018
0b80087
Disable when saving
islemaster May 11, 2018
a7da285
Submit on enter key
islemaster May 11, 2018
4aeb229
Not changing email is a validation error
islemaster May 11, 2018
a73dfe9
Break out header and footer components
islemaster May 11, 2018
f5dbf38
Update email on the account page after successful submission
islemaster May 11, 2018
2a9db7f
Focus email input when dialog opens
islemaster May 11, 2018
8c28d54
Fix id collision
islemaster May 11, 2018
a16420b
Use 'namespace' feature
islemaster May 11, 2018
e31b996
Remove old email update code
islemaster May 11, 2018
865465e
Formatting cleanup
islemaster May 11, 2018
a97ab50
Extract ChangeEmailForm
islemaster May 11, 2018
ed93558
Change state format
islemaster May 11, 2018
f434008
Tests for ChangeEmailForm
islemaster May 11, 2018
2f7b454
ChangeEmailModalTest
islemaster May 12, 2018
fb28643
Test success and failure handlers
islemaster May 12, 2018
4d146da
Deduplicate hashing logic
islemaster May 12, 2018
3fbadeb
Fix test name
islemaster May 12, 2018
29ed7e6
Fix ChangeEmailModal story
islemaster May 14, 2018
1e1b16c
Extract and test can_change_own_user_type?
islemaster May 14, 2018
df13710
Temporary constraint on changing user type
islemaster May 14, 2018
dd54d6f
Remove UI test no longer relevant under new spec
islemaster May 14, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/i18n/common/en_us.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@
"challengeLevelSkip": "Skip for now",
"challengeLevelStart": "I'm ready!",
"challengeLevelTitle": "Challenge Puzzle!",
"changeEmailModal_currentPassword_isRequired": "Current password is required.",
"changeEmailModal_currentPassword_label": "Current password",
"changeEmailModal_emailOptIn_description": "Would you like to be added to our mailing list so that we can tell you about updates to our courses, local learning opportunities, or other computer science news? This will not impact your subscription preferences for your old email address.",
"changeEmailModal_emailOptIn_isRequired": "This field is required.",
"changeEmailModal_newEmail_invalid": "The email address you provided is not valid.",
"changeEmailModal_newEmail_isRequired": "A new email address is required.",
"changeEmailmodal_newEmail_mustBeDifferent": "New email address must not match old email address.",
"changeEmailModal_newEmail_label": "New email address",
"changeEmailModal_save": "Update email address",
"changeEmailModal_title": "Update email address",
"changeEmailModal_unexpectedError": "An unexpected error has occurred. Please wait a moment and try again.",
"changeLoginType": "Change login type",
"changeLoginTypeQuestion": "Change student login type?",
"changeLoginTypeToPicture_button": "Change to picture login",
Expand Down
341 changes: 341 additions & 0 deletions apps/src/lib/ui/ChangeEmailModal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,341 @@
import React, {PropTypes} from 'react';
import BaseDialog from '@cdo/apps/templates/BaseDialog';
import i18n from '@cdo/locale';
import color from '@cdo/apps/util/color';
import {isEmail} from '../../util/formatValidation';
import $ from 'jquery';
import MD5 from 'crypto-js/md5';
import {Header, ConfirmCancelFooter} from './SystemDialog/SystemDialog';

/*

Note: This feature is in active development, so there are still some rough edges.
(Brad Buchanan, 2018-05-10)

Spec:
https://docs.google.com/document/d/1eKDnrgorG9koHQF3OdY6nxiO4PIfJ-JCNHGGQu0-G9Y/edit

Task list:
Testing!
Send the email opt-in to the server and handle it correctly
Deduplicate and test client-side email hashing logic
A less clumsy way to use Rails UJS?

*/

const STATE_INITIAL = 'initial';
const STATE_SAVING = 'saving';
const STATE_UNKNOWN_ERROR = 'unknown-error';

export default class ChangeEmailModal extends React.Component {
static propTypes = {
isOpen: PropTypes.bool.isRequired,
handleSubmit: PropTypes.func.isRequired,
handleCancel: PropTypes.func.isRequired,
railsForm: PropTypes.object,
userAge: PropTypes.number.isRequired,
currentHashedEmail: PropTypes.string,
};

state = {
saveState: STATE_INITIAL,
newEmail: '',
newEmailServerError: undefined,
currentPassword: '',
currentPasswordServerError: undefined,
emailOptIn: '',
};

//
// Note: This dialog submits account changes to dashboard using a
// hidden Rails-generated form. It expects that form to already exist in the
// DOM when the component mounts and to have a particular data attribute.
//
// When the user clicks the "update" button, this dialog loads the relevant
// information into the hidden Rails form and calls submit(). Rails injects
// all the JavaScript needed for the form to submit via AJAX with all the
// appropriate validation tokens, etc. The dialog subscribes to events
// emitted by the Rails helper JavaScript to detect success or errors.
//
// If the dialog can't find the Rails form anywhere it will emit a warning
// and be unable to submit anything (useful for tests and storybook).
//
// Read more:
// http://guides.rubyonrails.org/working_with_javascript_in_rails.html#rails-ujs-event-handlers
// https://github.com/rails/jquery-ujs
//

componentDidMount() {
this._form = $(this.props.railsForm);
if (this._form) {
this._form.on('ajax:success', this.onSubmitSuccess);
this._form.on('ajax:error', this.onSubmitFailure);
} else {
console.warn('The ChangeEmailModal component did not find the required ' +
'Rails UJS form, and will be unable to submit changes.');
}
this.newEmailInput.focus();
}

componentWillUnmount() {
if (this._form) {
this._form.off('ajax:success', this.onSubmitSuccess);
this._form.off('ajax:error', this.onSubmitFailure);
delete this._form;
}
}

save = () => {
if (!this._form) {
console.error('The ChangeEmailModal component did not find the required ' +
'Rails UJS form, and was unable to submit changes.');
return;
}

this.setState({saveState: STATE_SAVING}, () => {
this._form.find('#change-email-modal_user_email').val(this.props.userAge < 13 ? '' : this.state.newEmail);
this._form.find('#change-email-modal_user_hashed_email').val(hashEmail(this.state.newEmail));
this._form.find('#change-email-modal_user_current_password').val(this.state.currentPassword);
// this._form.find('#user_email_opt_in').val(this.state.emailOptIn);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why commented out? Is this a future field?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The whole reason we're converting this to a dialog is so that we can explicitly ask teachers to re-opt-in to marketing email when they change their email address. I started building this out, then realized just switching to a dialog for this flow was complicated enough, so I'll be adding the opt-in as a follow-on PR. I wasn't sure it was worth extracting the work I'd done so far though.

this._form.submit();
});
};

cancel = () => this.props.handleCancel();

onSubmitSuccess = () => {
this.props.handleSubmit(this.state.newEmail);
};

onSubmitFailure = (_, xhr) => {
const validationErrors = xhr.responseJSON;
if (validationErrors) {
this.setState({
saveState: STATE_INITIAL,
newEmailServerError: validationErrors.email && validationErrors.email[0],
currentPasswordServerError: validationErrors.current_password && validationErrors.current_password[0],
}, () => {
if (this.state.newEmailServerError) {
this.newEmailInput.focus();
} else if (this.state.currentPasswordServerError) {
this.currentPasswordInput.focus();
}
});
} else {
this.setState({
saveState: STATE_UNKNOWN_ERROR,
});
}
};

isFormValid(validationErrors) {
return Object.keys(validationErrors).every(key => !validationErrors[key]);
}

getValidationErrors() {
const {newEmailServerError, currentPasswordServerError} = this.state;
return {
newEmail: newEmailServerError || this.getNewEmailValidationError(),
currentPassword: currentPasswordServerError || this.getCurrentPasswordValidationError(),
// emailOptIn: this.getEmailOptInValidationError(),
};
}

getNewEmailValidationError = () => {
if (this.state.newEmail.trim().length === 0) {
return i18n.changeEmailModal_newEmail_isRequired();
}
if (!isEmail(this.state.newEmail.trim())) {
return i18n.changeEmailModal_newEmail_invalid();
}
if (this.props.currentHashedEmail === hashEmail(this.state.newEmail)) {
return i18n.changeEmailmodal_newEmail_mustBeDifferent();
}
return null;
};

getCurrentPasswordValidationError = () => {
if (this.state.currentPassword.length === 0) {
return i18n.changeEmailModal_currentPassword_isRequired();
}
return null;
};

getEmailOptInValidationError = () => {
if (this.state.emailOptIn.length === 0) {
return i18n.changeEmailModal_emailOptIn_isRequired();
}
return null;
};

onNewEmailChange = (event) => this.setState({
newEmail: event.target.value,
newEmailServerError: undefined,
});

onCurrentPasswordChange = (event) => this.setState({
currentPassword: event.target.value,
currentPasswordServerError: undefined,
});

onEmailOptInChange = (event) => this.setState({
emailOptIn: event.target.value,
});

onKeyDown = (event) => {
if (event.key === 'Enter' && this.isFormValid(this.getValidationErrors())) {
this.save();
}
};

render = () => {
const {saveState, newEmail, currentPassword, emailOptIn} = this.state;
const validationErrors = this.getValidationErrors();
const isFormValid = this.isFormValid(validationErrors);
return (
<BaseDialog
useUpdatedStyles
isOpen={this.props.isOpen}
handleClose={this.cancel}
uncloseable={STATE_SAVING === saveState}
>
<div style={styles.container}>
<Header text={i18n.changeEmailModal_title()}/>
<div>
<Field>
<label
htmlFor="user_email"
style={styles.label}
>
{i18n.changeEmailModal_newEmail_label()}
</label>
<input
id="user_email"
type="email"
value={newEmail}
disabled={STATE_SAVING === saveState}
onKeyDown={this.onKeyDown}
onChange={this.onNewEmailChange}
autoComplete="off"
maxLength="255"
size="255"
style={styles.input}
ref={el => this.newEmailInput = el}
/>
<FieldError>
{validationErrors.newEmail}
</FieldError>
</Field>
<Field>
<label
htmlFor="user_current_password"
style={styles.label}
>
{i18n.changeEmailModal_currentPassword_label()}
</label>
<input
id="user_current_password"
type="password"
value={currentPassword}
disabled={STATE_SAVING === saveState}
onKeyDown={this.onKeyDown}
onChange={this.onCurrentPasswordChange}
maxLength="255"
size="255"
style={styles.input}
ref={el => this.currentPasswordInput = el}
/>
<FieldError>
{validationErrors.currentPassword}
</FieldError>
</Field>
<Field style={{display: 'none'}}>
<p>
{i18n.changeEmailModal_emailOptIn_description()}
</p>
<select
value={emailOptIn}
onKeyDown={this.onKeyDown}
onChange={this.onEmailOptInChange}
disabled={STATE_SAVING === saveState}
style={{
...styles.input,
width: 100,
}}
>
<option value=""/>
<option value="yes">
{i18n.yes()}
</option>
<option value="no">
{i18n.no()}
</option>
</select>
<FieldError>
{validationErrors.emailOptIn}
</FieldError>
</Field>
</div>
<ConfirmCancelFooter
confirmText={i18n.changeEmailModal_save()}
onConfirm={this.save}
onCancel={this.cancel}
disableConfirm={STATE_SAVING === saveState || !isFormValid}
disableCancel={STATE_SAVING === saveState}
>
{(STATE_SAVING === saveState) &&
<em>{i18n.saving()}</em>}
{(STATE_UNKNOWN_ERROR === saveState) &&
<em>{i18n.changeEmailModal_unexpectedError()}</em>}
</ConfirmCancelFooter>
</div>
</BaseDialog>
);
};
}

const Field = ({children, style}) => (
<div
style={{
marginBottom: 15,
...style,
}}
>
{children}
</div>
);
Field.propTypes = {
children: PropTypes.any,
style: PropTypes.object,
};

const FieldError = ({children}) => (
<div
style={{
color: color.red,
fontStyle: 'italic',
}}
>
{children}
</div>
);
FieldError.propTypes = {children: PropTypes.string};

const styles = {
container: {
margin: 20,
color: color.charcoal,
},
label: {
display: 'block',
fontWeight: 'bold',
color: color.charcoal,
},
input: {
marginBottom: 4,
},
};

function hashEmail(cleartextEmail) {
return MD5(cleartextEmail).toString();
}
15 changes: 15 additions & 0 deletions apps/src/lib/ui/ChangeEmailModal.story.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';
import ChangeEmailModal from './ChangeEmailModal';
import {action} from '@storybook/addon-actions';

export default storybook => storybook
.storiesOf('ChangeEmailModal', module)
.add('overview', () => (
<ChangeEmailModal
isOpen
handleSubmit={action('handleSubmit callback')}
handleCancel={action('handleCancel callback')}
userAge={21}
/>
));