-
Notifications
You must be signed in to change notification settings - Fork 479
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
Change email dialog #22309
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 5728937
Attach ChangeEmailModal to account page
islemaster 64c8ee9
Interactable fields and client-side validation
islemaster 7598a61
Able to successfully update email address
islemaster b6cb184
Better documentation of the Rails UJS scheme
islemaster c25c79f
Remove copypasta'd comment
islemaster 907957b
Server sends back validation errors, client handles them gracefully
islemaster 249f179
Proper JSON response and handling of unknown error
islemaster 0b80087
Disable when saving
islemaster a7da285
Submit on enter key
islemaster 4aeb229
Not changing email is a validation error
islemaster a73dfe9
Break out header and footer components
islemaster f5dbf38
Update email on the account page after successful submission
islemaster 2a9db7f
Focus email input when dialog opens
islemaster 8c28d54
Fix id collision
islemaster a16420b
Use 'namespace' feature
islemaster e31b996
Remove old email update code
islemaster 865465e
Formatting cleanup
islemaster a97ab50
Extract ChangeEmailForm
islemaster ed93558
Change state format
islemaster f434008
Tests for ChangeEmailForm
islemaster 2f7b454
ChangeEmailModalTest
islemaster fb28643
Test success and failure handlers
islemaster 4d146da
Deduplicate hashing logic
islemaster 3fbadeb
Fix test name
islemaster 29ed7e6
Fix ChangeEmailModal story
islemaster 1e1b16c
Extract and test can_change_own_user_type?
islemaster df13710
Temporary constraint on changing user type
islemaster dd54d6f
Remove UI test no longer relevant under new spec
islemaster File filter
Filter by extension
Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
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(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | ||
/> | ||
)); | ||
|
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.