diff --git a/src/forgot-password/ForgotPasswordPage.jsx b/src/forgot-password/ForgotPasswordPage.jsx index 591c75fb..72d39bbc 100644 --- a/src/forgot-password/ForgotPasswordPage.jsx +++ b/src/forgot-password/ForgotPasswordPage.jsx @@ -19,7 +19,7 @@ import { } from '@edx/paragon'; import { ChevronLeft } from '@edx/paragon/icons'; -import { forgotPassword } from './data/actions'; +import { forgotPassword, setForgotPasswordFormData } from './data/actions'; import { forgotPasswordResultSelector } from './data/selectors'; import messages from './messages'; @@ -34,7 +34,7 @@ const ForgotPasswordPage = (props) => { const platformName = getConfig().SITE_NAME; const regex = new RegExp(VALID_EMAIL_REGEX, 'i'); - const [validationError, setValidationError] = useState(''); + const [validationError, setValidationError] = useState(props.emailValidationError || ''); const [key, setKey] = useState(''); const supportUrl = getConfig().LOGIN_ISSUE_SUPPORT_LINK; @@ -56,6 +56,11 @@ const ForgotPasswordPage = (props) => { return error; }; + const onBlur = (email) => { + const emailValidationError = getValidationMessage(email); + props.setForgotPasswordFormData({ email, emailValidationError }); + }; + const tabTitle = (
@@ -74,7 +79,7 @@ const ForgotPasswordPage = (props) => { )}
{ const validationMessage = getValidationMessage(values.email); @@ -110,7 +115,7 @@ const ForgotPasswordPage = (props) => { name="email" errorMessage={validationError} value={values.email} - handleBlur={() => getValidationMessage(values.email)} + handleBlur={() => onBlur(values.email)} handleChange={e => setFieldValue('email', e.target.value)} handleFocus={() => setValidationError('')} helpText={[intl.formatMessage(messages['forgot.password.email.help.text'], { platformName })]} @@ -156,13 +161,16 @@ const ForgotPasswordPage = (props) => { ForgotPasswordPage.propTypes = { intl: intlShape.isRequired, email: PropTypes.string, + emailValidationError: PropTypes.string, forgotPassword: PropTypes.func.isRequired, + setForgotPasswordFormData: PropTypes.func.isRequired, status: PropTypes.string, submitState: PropTypes.string, }; ForgotPasswordPage.defaultProps = { email: '', + emailValidationError: '', status: null, submitState: DEFAULT_STATE, }; @@ -171,5 +179,6 @@ export default connect( forgotPasswordResultSelector, { forgotPassword, + setForgotPasswordFormData, }, )(injectIntl(ForgotPasswordPage)); diff --git a/src/forgot-password/data/actions.js b/src/forgot-password/data/actions.js index dcdd871c..afbad054 100644 --- a/src/forgot-password/data/actions.js +++ b/src/forgot-password/data/actions.js @@ -1,6 +1,7 @@ import { AsyncActionType } from '../../data/utils'; export const FORGOT_PASSWORD = new AsyncActionType('FORGOT', 'PASSWORD'); +export const FORGOT_PASSWORD_PERSIST_FORM_DATA = 'FORGOT_PASSWORD_PERSIST_FORM_DATA'; // Forgot Password export const forgotPassword = email => ({ @@ -24,3 +25,8 @@ export const forgotPasswordForbidden = () => ({ export const forgotPasswordServerError = () => ({ type: FORGOT_PASSWORD.FAILURE, }); + +export const setForgotPasswordFormData = (forgotPasswordFormData) => ({ + type: FORGOT_PASSWORD_PERSIST_FORM_DATA, + payload: { forgotPasswordFormData }, +}); diff --git a/src/forgot-password/data/reducers.js b/src/forgot-password/data/reducers.js index 62d000fb..5a729fdc 100644 --- a/src/forgot-password/data/reducers.js +++ b/src/forgot-password/data/reducers.js @@ -1,4 +1,4 @@ -import { FORGOT_PASSWORD } from './actions'; +import { FORGOT_PASSWORD, FORGOT_PASSWORD_PERSIST_FORM_DATA } from './actions'; import { INTERNAL_SERVER_ERROR, PENDING_STATE } from '../../data/constants'; import { PASSWORD_RESET_FAILURE } from '../../reset-password/data/actions'; @@ -6,6 +6,7 @@ export const defaultState = { status: '', submitState: '', email: '', + emailValidationError: '', }; const reducer = (state = defaultState, action = null) => { @@ -33,8 +34,20 @@ const reducer = (state = defaultState, action = null) => { return { status: action.payload.errorCode, }; + case FORGOT_PASSWORD_PERSIST_FORM_DATA: { + const { forgotPasswordFormData } = action.payload; + return { + ...state, + email: forgotPasswordFormData.email, + emailValidationError: forgotPasswordFormData.emailValidationError, + }; + } default: - return defaultState; + return { + ...defaultState, + email: state.email, + emailValidationError: state.emailValidationError, + }; } } return state; diff --git a/src/forgot-password/data/tests/reducers.test.js b/src/forgot-password/data/tests/reducers.test.js new file mode 100644 index 00000000..e993686e --- /dev/null +++ b/src/forgot-password/data/tests/reducers.test.js @@ -0,0 +1,34 @@ +import reducer from '../reducers'; +import { + FORGOT_PASSWORD_PERSIST_FORM_DATA, +} from '../actions'; + +describe('forgot password reducer', () => { + it('should set email and emailValidationError', () => { + const state = { + status: '', + submitState: '', + email: '', + emailValidationError: '', + }; + const forgotPasswordFormData = { + email: 'test@gmail', + emailValidationError: 'Enter a valid email address', + }; + const action = { + type: FORGOT_PASSWORD_PERSIST_FORM_DATA, + payload: { forgotPasswordFormData }, + }; + + expect( + reducer(state, action), + ).toEqual( + { + status: '', + submitState: '', + email: 'test@gmail', + emailValidationError: 'Enter a valid email address', + }, + ); + }); +}); diff --git a/src/forgot-password/data/tests/sagas.test.js b/src/forgot-password/data/tests/sagas.test.js index d3e85654..dae526a4 100644 --- a/src/forgot-password/data/tests/sagas.test.js +++ b/src/forgot-password/data/tests/sagas.test.js @@ -10,7 +10,7 @@ const { loggingService } = initializeMockLogging(); describe('handleForgotPassword', () => { const params = { payload: { - formData: { + forgotPasswordFormData: { email: 'test@test.com', }, }, diff --git a/src/forgot-password/tests/ForgotPasswordPage.test.jsx b/src/forgot-password/tests/ForgotPasswordPage.test.jsx index 06a66618..5bf39fe0 100644 --- a/src/forgot-password/tests/ForgotPasswordPage.test.jsx +++ b/src/forgot-password/tests/ForgotPasswordPage.test.jsx @@ -16,6 +16,7 @@ import * as auth from '@edx/frontend-platform/auth'; import ForgotPasswordPage from '../ForgotPasswordPage'; import { INTERNAL_SERVER_ERROR, LOGIN_PAGE } from '../../data/constants'; import { PASSWORD_RESET } from '../../reset-password/data/constants'; +import { setForgotPasswordFormData } from '../data/actions'; jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/auth'); @@ -197,4 +198,24 @@ describe('ForgotPasswordPage', () => { forgotPasswordPage.update(); expect(history.location.pathname).toEqual(LOGIN_PAGE); }); + + // *********** persists form data on going back to sign in page *********** + + it('should set form data in redux store on onBlur', () => { + const forgotPasswordFormData = { + email: 'test@gmail', + emailValidationError: 'Enter a valid email address', + }; + + props = { + ...props, + email: 'test@gmail', + emailValidationError: '', + }; + + store.dispatch = jest.fn(store.dispatch); + const wrapper = mount(reduxWrapper()); + wrapper.find('input#email').simulate('blur'); + expect(store.dispatch).toHaveBeenCalledWith(setForgotPasswordFormData(forgotPasswordFormData)); + }); }); diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index 90c88e67..8dbaeb59 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -15,9 +15,11 @@ import { import { Institution } from '@edx/paragon/icons'; import AccountActivationMessage from './AccountActivationMessage'; -import { loginRequest, loginRequestFailure, loginRequestReset } from './data/actions'; +import { + loginRequest, loginRequestFailure, loginRequestReset, setLoginFormData, +} from './data/actions'; import { INVALID_FORM } from './data/constants'; -import { loginErrorSelector, loginRequestSelector } from './data/selectors'; +import { loginErrorSelector, loginFormDataSelector, loginRequestSelector } from './data/selectors'; import LoginFailureMessage from './LoginFailure'; import messages from './messages'; @@ -40,20 +42,18 @@ import { getAllPossibleQueryParam, updatePathWithQueryParams, } from '../data/utils'; -import { forgotPasswordResultSelector } from '../forgot-password'; import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess'; class LoginPage extends React.Component { constructor(props, context) { super(props, context); - sendPageEvent('login_and_registration', 'login'); this.state = { - password: '', - emailOrUsername: '', + password: this.props.loginFormData.password || '', + emailOrUsername: this.props.loginFormData.emailOrUsername || '', errors: { - emailOrUsername: '', - password: '', + emailOrUsername: this.props.loginFormData.errors.emailOrUsername || '', + password: this.props.loginFormData.errors.password || '', }, isSubmitted: false, }; @@ -78,12 +78,18 @@ class LoginPage extends React.Component { handleSubmit = (e) => { e.preventDefault(); this.setState({ isSubmitted: true }); - const { emailOrUsername, password } = this.state; const emailValidationError = this.validateEmail(emailOrUsername); const passwordValidationError = this.validatePassword(password); if (emailValidationError !== '' || passwordValidationError !== '') { + this.props.setLoginFormData({ + ...this.props.loginFormData, + errors: { + emailOrUsername: emailValidationError, + password: passwordValidationError, + }, + }); this.props.loginRequestFailure({ errorCode: INVALID_FORM, }); @@ -99,7 +105,18 @@ class LoginPage extends React.Component { handleOnFocus = (e) => { const { errors } = this.state; errors[e.target.name] = ''; - this.setState({ errors }); + this.props.setLoginFormData({ + ...this.props.loginFormData, + errors, + }); + } + + handleOnBlur = (e) => { + const payload = { + ...this.props.loginFormData, + [e.target.name]: e.target.value, + }; + this.props.setLoginFormData(payload); } handleForgotPasswordLinkClickEvent = () => { @@ -116,7 +133,6 @@ class LoginPage extends React.Component { } else { errors.emailOrUsername = ''; } - this.setState({ errors }); return errors.emailOrUsername; } @@ -124,7 +140,6 @@ class LoginPage extends React.Component { const { errors } = this.state; errors.password = password.length > 0 ? '' : this.props.intl.formatMessage(messages['password.validation.message']); - this.setState({ errors }); return errors.password; } @@ -230,6 +245,7 @@ class LoginPage extends React.Component { value={this.state.emailOrUsername} handleChange={(e) => this.setState({ emailOrUsername: e.target.value, isSubmitted: false })} handleFocus={this.handleOnFocus} + handleBlur={this.handleOnBlur} errorMessage={this.state.errors.emailOrUsername} floatingLabel={intl.formatMessage(messages['login.user.identity.label'])} /> @@ -239,6 +255,7 @@ class LoginPage extends React.Component { showRequirements={false} handleChange={(e) => this.setState({ password: e.target.value, isSubmitted: false })} handleFocus={this.handleOnFocus} + handleBlur={this.handleOnBlur} errorMessage={this.state.errors.password} floatingLabel={intl.formatMessage(messages['login.password.label'])} /> @@ -310,9 +327,16 @@ class LoginPage extends React.Component { } LoginPage.defaultProps = { - forgotPassword: null, loginResult: null, loginError: null, + loginFormData: { + emailOrUsername: '', + password: '', + errors: { + emailOrUsername: '', + password: '', + }, + }, resetPassword: false, submitState: DEFAULT_STATE, thirdPartyAuthApiStatus: 'pending', @@ -325,20 +349,25 @@ LoginPage.defaultProps = { }; LoginPage.propTypes = { - forgotPassword: PropTypes.shape({ - email: PropTypes.string, - status: PropTypes.string, - }), getThirdPartyAuthContext: PropTypes.func.isRequired, intl: intlShape.isRequired, loginError: PropTypes.objectOf(PropTypes.any), loginRequest: PropTypes.func.isRequired, loginRequestFailure: PropTypes.func.isRequired, loginRequestReset: PropTypes.func.isRequired, + setLoginFormData: PropTypes.func.isRequired, loginResult: PropTypes.shape({ redirectUrl: PropTypes.string, success: PropTypes.bool, }), + loginFormData: PropTypes.shape({ + emailOrUsername: PropTypes.string, + password: PropTypes.string, + errors: PropTypes.shape({ + emailOrUsername: PropTypes.string, + password: PropTypes.string, + }), + }), resetPassword: PropTypes.bool, submitState: PropTypes.string, thirdPartyAuthApiStatus: PropTypes.string, @@ -354,17 +383,17 @@ LoginPage.propTypes = { }; const mapStateToProps = state => { - const forgotPassword = forgotPasswordResultSelector(state); const loginResult = loginRequestSelector(state); const thirdPartyAuthContext = thirdPartyAuthContextSelector(state); const loginError = loginErrorSelector(state); + const loginFormData = loginFormDataSelector(state); return { submitState: state.login.submitState, thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus, - forgotPassword, loginError, loginResult, thirdPartyAuthContext, + loginFormData, resetPassword: state.login.resetPassword, }; }; @@ -376,5 +405,6 @@ export default connect( loginRequest, loginRequestFailure, loginRequestReset, + setLoginFormData, }, )(injectIntl(LoginPage)); diff --git a/src/login/data/actions.js b/src/login/data/actions.js index 198a88af..7d91b17a 100644 --- a/src/login/data/actions.js +++ b/src/login/data/actions.js @@ -1,6 +1,7 @@ import { AsyncActionType } from '../../data/utils'; export const LOGIN_REQUEST = new AsyncActionType('LOGIN', 'REQUEST'); +export const LOGIN_PERSIST_FORM_DATA = 'LOGIN_PERSIST_FORM_DATA'; // Login export const loginRequest = creds => ({ @@ -25,3 +26,8 @@ export const loginRequestFailure = (loginError) => ({ export const loginRequestReset = () => ({ type: LOGIN_REQUEST.RESET, }); + +export const setLoginFormData = (loginFormData) => ({ + type: LOGIN_PERSIST_FORM_DATA, + payload: { loginFormData }, +}); diff --git a/src/login/data/reducers.js b/src/login/data/reducers.js index 3937f95a..484389d1 100644 --- a/src/login/data/reducers.js +++ b/src/login/data/reducers.js @@ -1,4 +1,4 @@ -import { LOGIN_REQUEST } from './actions'; +import { LOGIN_REQUEST, LOGIN_PERSIST_FORM_DATA } from './actions'; import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants'; import { RESET_PASSWORD } from '../../reset-password'; @@ -7,6 +7,14 @@ export const defaultState = { loginError: null, loginResult: {}, resetPassword: false, + loginFormData: { + password: '', + emailOrUsername: '', + errors: { + emailOrUsername: '', + password: '', + }, + }, }; const reducer = (state = defaultState, action) => { @@ -38,6 +46,13 @@ const reducer = (state = defaultState, action) => { ...state, resetPassword: true, }; + case LOGIN_PERSIST_FORM_DATA: { + const { loginFormData } = action.payload; + return { + ...state, + loginFormData, + }; + } default: return { ...state, diff --git a/src/login/data/selectors.js b/src/login/data/selectors.js index a409a5ee..e0606808 100644 --- a/src/login/data/selectors.js +++ b/src/login/data/selectors.js @@ -13,3 +13,8 @@ export const loginErrorSelector = createSelector( loginSelector, login => login.loginError, ); + +export const loginFormDataSelector = createSelector( + loginSelector, + login => login.loginFormData, +); diff --git a/src/login/data/tests/reducers.test.js b/src/login/data/tests/reducers.test.js new file mode 100644 index 00000000..ef6e6c19 --- /dev/null +++ b/src/login/data/tests/reducers.test.js @@ -0,0 +1,46 @@ +import reducer from '../reducers'; +import { + LOGIN_PERSIST_FORM_DATA, +} from '../actions'; + +describe('login reducer', () => { + it('should set registrationFormData', () => { + const state = { + loginFormData: { + password: '', + emailOrUsername: '', + errors: { + emailOrUsername: '', + password: '', + }, + }, + }; + const loginFormData = { + password: 'johndoe', + emailOrUsername: 'john@gmail.com', + errors: { + emailOrUsername: '', + password: '', + }, + }; + const action = { + type: LOGIN_PERSIST_FORM_DATA, + payload: { loginFormData }, + }; + + expect( + reducer(state, action), + ).toEqual( + { + loginFormData: { + password: 'johndoe', + emailOrUsername: 'john@gmail.com', + errors: { + emailOrUsername: '', + password: '', + }, + }, + }, + ); + }); +}); diff --git a/src/login/data/tests/sagas.test.js b/src/login/data/tests/sagas.test.js index 377caf27..d8ccb0f8 100644 --- a/src/login/data/tests/sagas.test.js +++ b/src/login/data/tests/sagas.test.js @@ -13,7 +13,7 @@ const { loggingService } = initializeMockLogging(); describe('handleLoginRequest', () => { const params = { payload: { - formData: { + loginFormData: { email: 'test@test.com', password: 'test-password', }, diff --git a/src/login/tests/LoginPage.test.jsx b/src/login/tests/LoginPage.test.jsx index e7cea8c0..4e18510c 100644 --- a/src/login/tests/LoginPage.test.jsx +++ b/src/login/tests/LoginPage.test.jsx @@ -12,7 +12,9 @@ import * as analytics from '@edx/frontend-platform/analytics'; import * as auth from '@edx/frontend-platform/auth'; import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; -import { loginRequest, loginRequestFailure, loginRequestReset } from '../data/actions'; +import { + loginRequest, loginRequestFailure, loginRequestReset, setLoginFormData, +} from '../data/actions'; import LoginFailureMessage from '../LoginFailure'; import LoginPage from '../LoginPage'; @@ -35,6 +37,7 @@ describe('LoginPage', () => { }); let props = {}; let store = {}; + let loginFormData = {}; const reduxWrapper = children => ( @@ -82,6 +85,14 @@ describe('LoginPage', () => { handleInstitutionLogin: jest.fn(), institutionLogin: false, }; + loginFormData = { + emailOrUsername: '', + password: '', + errors: { + emailOrUsername: '', + password: '', + }, + }; }); // ******** test login form submission ******** @@ -465,4 +476,42 @@ describe('LoginPage', () => { expect(store.dispatch).toHaveBeenCalledWith(loginRequestReset()); expect(loginPage.state('errors')).toEqual(errorState); }); + + // persists form data tests + + it('should set errors in redux store on submit form for invalid input', () => { + loginFormData = { + ...loginFormData, + errors: { + emailOrUsername: 'Enter your username or email', + password: 'Enter your password', + }, + }; + store.dispatch = jest.fn(store.dispatch); + const loginPage = mount(reduxWrapper()); + + loginPage.find('input#emailOrUsername').simulate('change', { target: { value: '' } }); + loginPage.find('input#password').simulate('change', { target: { value: '' } }); + loginPage.find('button.btn-brand').simulate('click'); + + expect(store.dispatch).toHaveBeenCalledWith(setLoginFormData(loginFormData)); + }); + + it('should set form data in redux store on onBlur', () => { + store.dispatch = jest.fn(store.dispatch); + + const loginPage = mount(reduxWrapper()); + loginPage.find('input#emailOrUsername').simulate('blur'); + + expect(store.dispatch).toHaveBeenCalledWith(setLoginFormData(loginFormData)); + }); + + it('should clear form field errors in redux store on onFocus', () => { + store.dispatch = jest.fn(store.dispatch); + + const loginPage = mount(reduxWrapper()); + loginPage.find('input#emailOrUsername').simulate('focus'); + + expect(store.dispatch).toHaveBeenCalledWith(setLoginFormData(loginFormData)); + }); }); diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index ee5fd44c..a0014573 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -16,7 +16,7 @@ import { import { Error, Close } from '@edx/paragon/icons'; import FormFieldRenderer from '../field-renderer'; import { - clearUsernameSuggestions, registerNewUser, resetRegistrationForm, fetchRealtimeValidations, + clearUsernameSuggestions, registerNewUser, resetRegistrationForm, fetchRealtimeValidations, setRegistrationFormData, } from './data/actions'; import { FIELDS, FORM_SUBMISSION_ERROR, DEFAULT_SERVICE_PROVIDER_DOMAINS, DEFAULT_TOP_LEVEL_DOMAINS, COMMON_EMAIL_PROVIDERS, @@ -26,6 +26,7 @@ import { registrationRequestSelector, validationsSelector, usernameSuggestionsSelector, + registrationFormDataSelector, } from './data/selectors'; import messages from './messages'; import RegistrationFailure from './RegistrationFailure'; @@ -66,20 +67,21 @@ class RegistrationPage extends React.Component { this.tpaHint = getTpaHint(); this.state = { country: '', - email: '', - name: '', - password: '', - username: '', - marketingOptIn: true, + email: this.props.registrationFormData.email || '', + name: this.props.registrationFormData.name || '', + password: this.props.registrationFormData.password || '', + username: this.props.registrationFormData.username || '', + marketingOptIn: this.props.registrationFormData.marketingOptIn || true, errors: { - email: '', - name: '', - username: '', - password: '', + email: this.props.registrationFormData.errors.email || '', + name: this.props.registrationFormData.errors.name || '', + username: this.props.registrationFormData.errors.username || '', + password: this.props.registrationFormData.errors.password || '', country: '', }, - emailErrorSuggestion: null, - emailWarningSuggestion: null, + emailFieldBorderClass: this.props.registrationFormData.emailFieldBorderClass || '', + emailErrorSuggestion: this.props.registrationFormData.emailErrorSuggestion || null, + emailWarningSuggestion: this.props.registrationFormData.emailWarningSuggestion || null, errorCode: null, failureCount: 0, startTime: Date.now(), @@ -119,6 +121,12 @@ class RegistrationPage extends React.Component { }); return false; } + if (this.props.registrationFormData !== nextProps.registrationFormData) { + const state = { ...this.state, ...nextProps.registrationFormData }; + this.setState({ + ...state, + }); + } if (this.props.validationDecisions !== nextProps.validationDecisions) { const state = { errors: { ...this.state.errors, ...nextProps.validationDecisions } }; let validatePassword = false; @@ -287,24 +295,27 @@ class RegistrationPage extends React.Component { handleOnFocus = (e) => { const { errors } = this.state; errors[e.target.name] = ''; - const state = { errors }; if (e.target.name === 'username') { this.props.clearUsernameSuggestions(); } if (e.target.name === 'country') { - state.readOnly = false; + this.setState({ readOnly: false }); } if (e.target.name === 'passwordValidation') { - state.errors.password = ''; + errors.password = ''; } - this.setState({ ...state }); + this.props.setRegistrationFormData({ + ...this.props.registrationFormData, + errors, + }); } handleSuggestionClick = (e, suggestion) => { const { errors } = this.state; if (e.target.name === 'username') { errors.username = ''; - this.setState({ + this.props.setRegistrationFormData({ + ...this.props.registrationFormData, username: suggestion, errors, }); @@ -312,18 +323,20 @@ class RegistrationPage extends React.Component { } else if (e.target.name === 'email') { e.preventDefault(); errors.email = ''; - this.setState({ - borderClass: '', + this.props.setRegistrationFormData({ + ...this.props.registrationFormData, email: suggestion, emailErrorSuggestion: null, emailWarningSuggestion: null, + emailFieldBorderClass: '', errors, }); } } handleUsernameSuggestionClose = () => { - this.setState({ + this.props.setRegistrationFormData({ + ...this.props.registrationFormData, username: '', }); this.props.clearUsernameSuggestions(); @@ -352,11 +365,15 @@ class RegistrationPage extends React.Component { } }); - this.setState({ errors }); + this.props.setRegistrationFormData({ + ...this.props.registrationFormData, + errors, + }); return isValid; } validateInput(fieldName, value, payload) { + let state = {}; const { errors } = this.state; const { intl, statusCode } = this.props; const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i'); @@ -409,11 +426,11 @@ class RegistrationPage extends React.Component { errors.email = ''; } } - this.setState({ + state = { emailWarningSuggestion, emailErrorSuggestion, - borderClass: emailWarningSuggestion ? 'yellow-border' : null, - }); + emailFieldBorderClass: emailWarningSuggestion ? 'yellow-border' : null, + }; } break; case 'name': @@ -467,12 +484,21 @@ class RegistrationPage extends React.Component { break; } - this.setState({ errors }); + this.props.setRegistrationFormData({ + ...this.props.registrationFormData, + ...state, + [fieldName]: value, + errors, + }); + return errors; } handleOnClose() { - this.setState({ emailErrorSuggestion: null }); + this.props.setRegistrationFormData({ + ...this.props.registrationFormData, + emailErrorSuggestion: null, + }); } renderEmailFeedback() { @@ -695,7 +721,7 @@ class RegistrationPage extends React.Component { handleFocus={this.handleOnFocus} helpText={[intl.formatMessage(messages['help.text.email'])]} floatingLabel={intl.formatMessage(messages['registration.email.label'])} - borderClass={this.state.borderClass} + borderClass={this.state.emailFieldBorderClass} > {this.renderEmailFeedback()} @@ -845,6 +871,24 @@ RegistrationPage.defaultProps = { secondaryProviders: [], pipelineUserDetails: null, }, + registrationFormData: { + country: '', + email: '', + name: '', + password: '', + username: '', + marketingOptIn: true, + errors: { + email: '', + name: '', + username: '', + password: '', + country: '', + }, + emailFieldBorderClass: '', + emailErrorSuggestion: null, + emailWarningSuggestion: null, + }, validationDecisions: null, statusCode: null, usernameSuggestions: [], @@ -857,6 +901,7 @@ RegistrationPage.propTypes = { getThirdPartyAuthContext: PropTypes.func.isRequired, registerNewUser: PropTypes.func, resetRegistrationForm: PropTypes.func.isRequired, + setRegistrationFormData: PropTypes.func.isRequired, registrationResult: PropTypes.shape({ redirectUrl: PropTypes.string, success: PropTypes.bool, @@ -864,6 +909,24 @@ RegistrationPage.propTypes = { registrationErrorCode: PropTypes.string, submitState: PropTypes.string, thirdPartyAuthApiStatus: PropTypes.string, + registrationFormData: PropTypes.shape({ + country: PropTypes.string, + email: PropTypes.string, + name: PropTypes.string, + password: PropTypes.string, + username: PropTypes.string, + marketingOptIn: PropTypes.bool, + errors: PropTypes.shape({ + email: PropTypes.string, + name: PropTypes.string, + username: PropTypes.string, + password: PropTypes.string, + country: PropTypes.string, + }), + emailFieldBorderClass: PropTypes.string, + emailErrorSuggestion: PropTypes.string, + emailWarningSuggestion: PropTypes.string, + }), thirdPartyAuthContext: PropTypes.shape({ currentProvider: PropTypes.string, platformName: PropTypes.string, @@ -906,6 +969,7 @@ const mapStateToProps = state => { validationDecisions: validationsSelector(state), statusCode: state.register.statusCode, usernameSuggestions: usernameSuggestionsSelector(state), + registrationFormData: registrationFormDataSelector(state), fieldDescriptions: fieldDescriptionSelector(state), extendedProfile: extendedProfileSelector(state), }; @@ -919,5 +983,6 @@ export default connect( fetchRealtimeValidations, registerNewUser, resetRegistrationForm, + setRegistrationFormData, }, )(injectIntl(RegistrationPage)); diff --git a/src/register/data/actions.js b/src/register/data/actions.js index 002f767b..d880f5d6 100644 --- a/src/register/data/actions.js +++ b/src/register/data/actions.js @@ -4,6 +4,7 @@ export const REGISTER_NEW_USER = new AsyncActionType('REGISTRATION', 'REGISTER_N export const REGISTER_FORM_VALIDATIONS = new AsyncActionType('REGISTRATION', 'GET_FORM_VALIDATIONS'); export const REGISTRATION_FORM = new AsyncActionType('REGISTRATION', 'REGISTRATION_FORM'); export const REGISTER_CLEAR_USERNAME_SUGGESTIONS = 'REGISTRATION_CLEAR_USERNAME_SUGGESTIONS'; +export const REGISTER_PERSIST_FORM_DATA = 'REGISTER_PERSIST_FORM_DATA'; // Reset Form export const resetRegistrationForm = () => ({ @@ -53,3 +54,8 @@ export const fetchRealtimeValidationsFailure = () => ({ export const clearUsernameSuggestions = () => ({ type: REGISTER_CLEAR_USERNAME_SUGGESTIONS, }); + +export const setRegistrationFormData = (registrationFormData) => ({ + type: REGISTER_PERSIST_FORM_DATA, + payload: { registrationFormData }, +}); diff --git a/src/register/data/reducers.js b/src/register/data/reducers.js index b53a63d7..c1d65fa1 100644 --- a/src/register/data/reducers.js +++ b/src/register/data/reducers.js @@ -3,6 +3,7 @@ import { REGISTER_NEW_USER, REGISTER_FORM_VALIDATIONS, REGISTER_CLEAR_USERNAME_SUGGESTIONS, + REGISTER_PERSIST_FORM_DATA, } from './actions'; import { @@ -13,7 +14,24 @@ import { export const defaultState = { registrationError: {}, registrationResult: {}, - formData: null, + registrationFormData: { + country: '', + email: '', + name: '', + password: '', + username: '', + marketingOptIn: true, + errors: { + email: '', + name: '', + username: '', + password: '', + country: '', + }, + emailFieldBorderClass: '', + emailErrorSuggestion: null, + emailWarningSuggestion: null, + }, validations: null, statusCode: null, usernameSuggestions: [], @@ -27,6 +45,8 @@ const reducer = (state = defaultState, action) => { case REGISTRATION_FORM.RESET: return { ...defaultState, + registrationFormData: state.registrationFormData, + usernameSuggestions: state.usernameSuggestions, }; case REGISTER_NEW_USER.BEGIN: return { @@ -72,6 +92,13 @@ const reducer = (state = defaultState, action) => { ...state, usernameSuggestions: [], }; + case REGISTER_PERSIST_FORM_DATA: { + const { registrationFormData } = action.payload; + return { + ...state, + registrationFormData, + }; + } default: return state; } diff --git a/src/register/data/selectors.js b/src/register/data/selectors.js index 52969d72..d1118e35 100644 --- a/src/register/data/selectors.js +++ b/src/register/data/selectors.js @@ -41,3 +41,8 @@ export const usernameSuggestionsSelector = createSelector( registerSelector, register => register.usernameSuggestions, ); + +export const registrationFormDataSelector = createSelector( + registerSelector, + register => register.registrationFormData, +); diff --git a/src/register/data/tests/reducers.test.js b/src/register/data/tests/reducers.test.js index a92acc07..3c715a59 100644 --- a/src/register/data/tests/reducers.test.js +++ b/src/register/data/tests/reducers.test.js @@ -1,6 +1,10 @@ import reducer from '../reducers'; import { - REGISTER_CLEAR_USERNAME_SUGGESTIONS, REGISTER_FORM_VALIDATIONS, REGISTER_NEW_USER, + REGISTER_CLEAR_USERNAME_SUGGESTIONS, + REGISTER_FORM_VALIDATIONS, + REGISTER_NEW_USER, + REGISTER_PERSIST_FORM_DATA, + REGISTRATION_FORM, } from '../actions'; import { DEFAULT_STATE } from '../../../data/constants'; @@ -10,7 +14,24 @@ describe('register reducer', () => { { registrationError: {}, registrationResult: {}, - formData: null, + registrationFormData: { + country: '', + email: '', + name: '', + password: '', + username: '', + marketingOptIn: true, + errors: { + email: '', + name: '', + username: '', + password: '', + country: '', + }, + emailFieldBorderClass: '', + emailErrorSuggestion: null, + emailWarningSuggestion: null, + }, validations: null, statusCode: null, usernameSuggestions: [], @@ -75,4 +96,93 @@ describe('register reducer', () => { }, ); }); + it('should not reset username suggestions and form data in form reset', () => { + const state = { + registrationError: {}, + registrationResult: {}, + registrationFormData: { + country: 'Pakistan', + email: 'test@email.com', + name: 'John Doe', + password: 'johndoe', + username: 'john', + marketingOptIn: true, + errors: { + email: '', + name: '', + username: '', + password: '', + country: '', + }, + emailErrorSuggestion: 'test@email.com', + emailWarningSuggestion: 'test@email.com', + }, + validations: null, + statusCode: null, + extendedProfile: [], + fieldDescriptions: {}, + formRenderState: DEFAULT_STATE, + usernameSuggestions: ['test1', 'test2'], + }; + const action = { + type: REGISTRATION_FORM.RESET, + }; + + expect( + reducer(state, action), + ).toEqual( + state, + ); + }); + + it('should set registrationFormData', () => { + const state = { + registrationFormData: { + country: '', + email: '', + name: '', + password: '', + username: '', + marketingOptIn: true, + errors: { + email: '', + name: '', + username: '', + password: '', + country: '', + }, + emailErrorSuggestion: null, + emailWarningSuggestion: null, + }, + }; + const registrationFormData = { + country: 'Pakistan', + email: 'test@email.com', + name: 'John Doe', + password: 'johndoe', + username: 'john', + marketingOptIn: true, + errors: { + email: '', + name: '', + username: '', + password: '', + country: '', + }, + emailErrorSuggestion: 'test@email.com', + emailWarningSuggestion: 'test@email.com', + }; + const action = { + type: REGISTER_PERSIST_FORM_DATA, + payload: { registrationFormData }, + }; + + expect( + reducer(state, action), + ).toEqual( + { + registrationFormData, + }, + ); + }); }); diff --git a/src/register/data/tests/sagas.test.js b/src/register/data/tests/sagas.test.js index 9fb7e448..d0014bf2 100644 --- a/src/register/data/tests/sagas.test.js +++ b/src/register/data/tests/sagas.test.js @@ -15,7 +15,7 @@ const { loggingService } = initializeMockLogging(); describe('fetchRealtimeValidations', () => { const params = { payload: { - formData: { + registrationFormData: { email: 'test@test.com', username: '', password: 'test-password', @@ -112,7 +112,7 @@ describe('fetchRealtimeValidations', () => { describe('handleNewUserRegistration', () => { const params = { payload: { - formData: { + registrationFormData: { email: 'test@test.com', username: 'test-username', password: 'test-password', diff --git a/src/register/tests/RegistrationPage.test.jsx b/src/register/tests/RegistrationPage.test.jsx index b223f02e..d1fb65d7 100644 --- a/src/register/tests/RegistrationPage.test.jsx +++ b/src/register/tests/RegistrationPage.test.jsx @@ -13,7 +13,7 @@ import { IntlProvider, injectIntl, configure } from '@edx/frontend-platform/i18n import { clearUsernameSuggestions, fetchRealtimeValidations, - registerNewUser, + registerNewUser, setRegistrationFormData, resetRegistrationForm, } from '../data/actions'; import { @@ -43,6 +43,7 @@ describe('RegistrationPage', () => { let props = {}; let store = {}; + let registrationFormData = {}; const reduxWrapper = children => ( @@ -86,6 +87,24 @@ describe('RegistrationPage', () => { handleInstitutionLogin: jest.fn(), institutionLogin: false, }; + registrationFormData = { + country: '', + email: '', + name: '', + password: '', + username: '', + marketingOptIn: true, + errors: { + email: '', + name: '', + username: '', + password: '', + country: '', + }, + emailFieldBorderClass: '', + emailErrorSuggestion: null, + emailWarningSuggestion: null, + }; }); afterEach(() => { @@ -252,19 +271,6 @@ describe('RegistrationPage', () => { expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations(formPayload)); }); - it('should validate the did you mean suggestions', () => { - const registrationPage = mount(reduxWrapper()); - - registrationPage.find('input#email').simulate('blur', { target: { value: 'test@gmail.con', name: 'email' } }); - expect(registrationPage.find('RegistrationPage').state('emailErrorSuggestion')).toEqual('test@gmail.com'); - - registrationPage.find('input#email').simulate('blur', { target: { value: 'test@fmail.com', name: 'email' } }); - expect(registrationPage.find('RegistrationPage').state('emailWarningSuggestion')).toEqual('test@gmail.com'); - - registrationPage.find('input#email').simulate('blur', { target: { value: 'test@hotmail.com', name: 'email' } }); - expect(registrationPage.find('RegistrationPage').state('emailWarningSuggestion')).toEqual(null); - }); - it('should update props with validations returned by registration api', () => { store = mockStore({ ...initialState, @@ -511,8 +517,6 @@ describe('RegistrationPage', () => { registerPage.find('RegistrationPage').instance().shouldComponentUpdate(props); registerPage.find('RegistrationPage').setState({ errors: { username: 'It looks like this username is already taken' } }); expect(registerPage.find('button.username-suggestion').length).toEqual(3); - registerPage.find('button.username-suggestion').at(0).simulate('click'); - expect(registerPage.find('RegistrationPage').state('username')).toEqual('test_1'); }); it('should show username suggestions when full name is populated', () => { @@ -528,8 +532,6 @@ describe('RegistrationPage', () => { registerPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); expect(registerPage.find('button.username-suggestion').length).toEqual(3); - registerPage.find('button.username-suggestion').at(0).simulate('click'); - expect(registerPage.find('RegistrationPage').state('username')).toEqual('testname'); }); it('should clear username suggestions when close icon is clicked', () => { @@ -762,6 +764,30 @@ describe('RegistrationPage', () => { expect(registrationPage.find('RegistrationPage').state('errorCode')).toEqual(INTERNAL_SERVER_ERROR); }); + it('should update form fields state if updated in redux store', () => { + const nextProps = { + thirdPartyAuthContext, + registrationFormData: { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@example.com', + password: 'password1', + country: 'Pakistan', + emailErrorSuggestion: 'test@gmail.com', + }, + }; + + const registrationPage = mount(reduxWrapper()); + registrationPage.find('RegistrationPage').instance().shouldComponentUpdate(nextProps); + + expect(registrationPage.find('RegistrationPage').state('name')).toEqual('John Doe'); + expect(registrationPage.find('RegistrationPage').state('username')).toEqual('john_doe'); + expect(registrationPage.find('RegistrationPage').state('email')).toEqual('john.doe@example.com'); + expect(registrationPage.find('RegistrationPage').state('emailErrorSuggestion')).toEqual('test@gmail.com'); + expect(registrationPage.find('RegistrationPage').state('password')).toEqual('password1'); + expect(registrationPage.find('RegistrationPage').state('country')).toEqual('Pakistan'); + }); + it('should display opt-in/opt-out checkbox', () => { mergeConfig({ MARKETING_EMAILS_OPT_IN: 'true', @@ -774,6 +800,115 @@ describe('RegistrationPage', () => { MARKETING_EMAILS_OPT_IN: '', }); }); + + // ******** persist state tests ******** + + it('should clear form field errors in redux store on onFocus', () => { + store.dispatch = jest.fn(store.dispatch); + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#name').simulate('focus'); + expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(registrationFormData)); + }); + + it('should set username in redux store if usernameSuggestion is clicked', () => { + registrationFormData = { + ...registrationFormData, + username: 'testname', + }; + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + usernameSuggestions: ['testname', 't.name', 'test_0'], + }, + }); + store.dispatch = jest.fn(store.dispatch); + const registerPage = mount(reduxWrapper()); + registerPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); + registerPage.find('button.username-suggestion').at(0).simulate('click'); + expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(registrationFormData)); + }); + + it('should set email in redux store if emailSuggestion is clicked', () => { + registrationFormData = { + ...registrationFormData, + email: 'test@gmail.com', + }; + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + }, + }); + store.dispatch = jest.fn(store.dispatch); + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#email').simulate('blur', { target: { value: 'test@gmail.con', name: 'email' } }); + registrationPage.find('RegistrationPage').setState({ emailErrorSuggestion: 'test@gmail.com' }); + registrationPage.find('.alert-link').simulate('click'); + expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(registrationFormData)); + }); + + it('should clear username in redux store if usernameSuggestion close button is clicked', () => { + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + usernameSuggestions: ['testname', 't.name', 'test_0'], + }, + }); + store.dispatch = jest.fn(store.dispatch); + const registerPage = mount(reduxWrapper()); + registerPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } }); + registerPage.find('button.suggested-username-close-button').simulate('click'); + expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(registrationFormData)); + }); + + it('should clear emailErrorSuggestion in redux store if close button is clicked', () => { + registrationFormData = { + ...registrationFormData, + email: '', + }; + store = mockStore({ + ...initialState, + register: { + ...initialState.register, + }, + }); + store.dispatch = jest.fn(store.dispatch); + const registrationPage = mount(reduxWrapper()); + registrationPage.find('input#email').simulate('blur', { target: { value: 'test@gmail.con', name: 'email' } }); + registrationPage.find('RegistrationPage').setState({ emailErrorSuggestion: 'test@gmail.com' }); + registrationPage.find('.alert-close').at(0).simulate('click'); + expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(registrationFormData)); + }); + + it('should set errors in redux store if form payload is invalid', () => { + registrationFormData = { + ...registrationFormData, + errors: { + ...registrationFormData.errors, + name: 'Enter your full name', + }, + }; + + const payload = { + name: '', + username: 'john_doe', + email: 'john.doe@example.com', + password: 'password1', + country: 'Pakistan', + honor_code: true, + totalRegistrationTime: 0, + is_authn_mfe: true, + }; + + store.dispatch = jest.fn(store.dispatch); + const registerPage = mount(reduxWrapper()); + + populateRequiredFields(registerPage, payload); + registerPage.find('button.btn-brand').simulate('click'); + expect(store.dispatch).toHaveBeenCalledWith(setRegistrationFormData(registrationFormData)); + }); }); describe('TestDynamicFields', () => {