{selectedPage === LOGIN_PAGE
?
diff --git a/src/register/EmbeddableRegistrationPage.jsx b/src/register/EmbeddableRegistrationPage/EmbeddableRegistrationPage.jsx
similarity index 93%
rename from src/register/EmbeddableRegistrationPage.jsx
rename to src/register/EmbeddableRegistrationPage/EmbeddableRegistrationPage.jsx
index 53e00587..25245246 100644
--- a/src/register/EmbeddableRegistrationPage.jsx
+++ b/src/register/EmbeddableRegistrationPage/EmbeddableRegistrationPage.jsx
@@ -8,42 +8,40 @@ import { sendPageEvent } from '@edx/frontend-platform/analytics';
import {
getCountryList, getLocale, useIntl,
} from '@edx/frontend-platform/i18n';
-import { Form, StatefulButton } from '@edx/paragon';
+import { Form, FormGroup, StatefulButton } from '@edx/paragon';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
-import ConfigurableRegistrationForm from './ConfigurableRegistrationForm';
+import { PasswordField } from '../../common-components';
+import { getThirdPartyAuthContext } from '../../common-components/data/actions';
+import { fieldDescriptionSelector } from '../../common-components/data/selectors';
+import {
+ DEFAULT_STATE, LETTER_REGEX, NUMBER_REGEX, REDIRECT,
+} from '../../data/constants';
+import { getAllPossibleQueryParams, setCookie } from '../../data/utils';
+import ConfigurableRegistrationForm from '../components/ConfigurableRegistrationForm';
+import RegistrationFailure from '../components/RegistrationFailure';
import {
clearRegistertionBackendError,
clearUsernameSuggestions,
fetchRealtimeValidations,
registerNewUser,
-} from './data/actions';
+} from '../data/actions';
import {
- COUNTRY_CODE_KEY,
- COUNTRY_DISPLAY_KEY,
FORM_SUBMISSION_ERROR,
-} from './data/constants';
-import { registrationErrorSelector, validationsSelector } from './data/selectors';
-import {
- emailRegex, getSuggestionForInvalidEmail, urlRegex, validateCountryField, validateEmailAddress,
-} from './data/utils';
-import messages from './messages';
-import RegistrationFailure from './RegistrationFailure';
-import { EmailField, UsernameField } from './registrationFields';
-import {
- FormGroup, PasswordField,
-} from '../common-components';
-import { getThirdPartyAuthContext } from '../common-components/data/actions';
-import {
- fieldDescriptionSelector,
-} from '../common-components/data/selectors';
-import {
- DEFAULT_STATE, LETTER_REGEX, NUMBER_REGEX, REDIRECT, USERNAME_REGEX,
} from '../data/constants';
+import { registrationErrorSelector, validationsSelector } from '../data/selectors';
+import messages from '../messages';
+import { EmailField, UsernameField } from '../RegistrationFields';
+import { COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY } from '../RegistrationFields/CountryField/constants';
+import validateCountryField from '../RegistrationFields/CountryField/validator';
import {
- getAllPossibleQueryParams, setCookie,
-} from '../data/utils';
+ emailRegex,
+ getSuggestionForInvalidEmail,
+ validateEmailAddress,
+} from '../RegistrationFields/EmailField/validator';
+import { urlRegex } from '../RegistrationFields/NameField/constants';
+import { VALID_USERNAME_REGEX } from '../RegistrationFields/UsernameField/constants';
const EmbeddableRegistrationPage = (props) => {
const {
@@ -211,7 +209,7 @@ const EmbeddableRegistrationPage = (props) => {
}
break;
case 'username':
- if (!value.match(USERNAME_REGEX)) {
+ if (!value.match(VALID_USERNAME_REGEX)) {
fieldError = formatMessage(messages['username.format.validation.message']);
}
break;
diff --git a/src/register/RegistrationFields/CountryField/CountryField.jsx b/src/register/RegistrationFields/CountryField/CountryField.jsx
new file mode 100644
index 00000000..e940f189
--- /dev/null
+++ b/src/register/RegistrationFields/CountryField/CountryField.jsx
@@ -0,0 +1,134 @@
+import React, { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { FormAutosuggest, FormAutosuggestOption, FormControlFeedback } from '@edx/paragon';
+import PropTypes from 'prop-types';
+
+import { COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY, COUNTRY_FIELD_LABEL } from './constants';
+import validateCountryField from './validator';
+import { clearRegistertionBackendError } from '../../data/actions';
+import messages from '../../messages';
+
+const CountryField = (props) => {
+ const {
+ countryList,
+ selectedCountry,
+ onChangeHandler,
+ handleErrorChange,
+ onFocusHandler,
+ } = props;
+ const { formatMessage } = useIntl();
+ const dispatch = useDispatch();
+ const backendCountryCode = useSelector(state => state.register.backendCountryCode);
+
+ useEffect(() => {
+ if (backendCountryCode && backendCountryCode !== selectedCountry?.countryCode) {
+ let countryCode = '';
+ let countryDisplayValue = '';
+
+ const countryVal = countryList.find(
+ (country) => (country[COUNTRY_CODE_KEY].toLowerCase() === backendCountryCode.toLowerCase()),
+ );
+ if (countryVal) {
+ countryCode = countryVal[COUNTRY_CODE_KEY];
+ countryDisplayValue = countryVal[COUNTRY_DISPLAY_KEY];
+ }
+ onChangeHandler(
+ { target: { name: COUNTRY_FIELD_LABEL } },
+ { countryCode, displayValue: countryDisplayValue },
+ );
+ }
+ }, [backendCountryCode, countryList]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const handleOnBlur = (event) => {
+ // Do not run validations when drop-down arrow is clicked
+ if (event.relatedTarget && event.relatedTarget.className.includes('pgn__form-autosuggest__icon-button')) {
+ return;
+ }
+
+ const { value } = event.target;
+
+ const { countryCode, displayValue, error } = validateCountryField(
+ value.trim(), countryList, formatMessage(messages['empty.country.field.error']),
+ );
+
+ onChangeHandler({ target: { name: COUNTRY_FIELD_LABEL } }, { countryCode, displayValue });
+ handleErrorChange(COUNTRY_FIELD_LABEL, error);
+ // onBlurHandler(event);
+ };
+
+ const handleSelected = (value) => {
+ handleOnBlur({ target: { name: COUNTRY_FIELD_LABEL, value } });
+ };
+
+ const handleOnFocus = (event) => {
+ handleErrorChange(COUNTRY_FIELD_LABEL, '');
+ dispatch(clearRegistertionBackendError(COUNTRY_FIELD_LABEL));
+ onFocusHandler(event);
+ };
+
+ const handleOnChange = (value) => {
+ onChangeHandler({ target: { name: COUNTRY_FIELD_LABEL } }, { countryCode: '', displayValue: value });
+ };
+
+ const getCountryList = () => countryList.map((country) => (
+
+ {country[COUNTRY_DISPLAY_KEY]}
+
+ ));
+
+ return (
+
+ handleSelected(value)}
+ onFocus={(e) => handleOnFocus(e)}
+ onBlur={(e) => handleOnBlur(e)}
+ onChange={(value) => handleOnChange(value)}
+ >
+ {getCountryList()}
+
+ {props.errorMessage !== '' && (
+
+ {props.errorMessage}
+
+ )}
+
+ );
+};
+
+CountryField.propTypes = {
+ countryList: PropTypes.arrayOf(
+ PropTypes.shape({
+ code: PropTypes.string,
+ name: PropTypes.string,
+ }),
+ ).isRequired,
+ errorMessage: PropTypes.string,
+ onChangeHandler: PropTypes.func.isRequired,
+ handleErrorChange: PropTypes.func.isRequired,
+ onFocusHandler: PropTypes.func.isRequired,
+ selectedCountry: PropTypes.shape({
+ displayValue: PropTypes.string,
+ countryCode: PropTypes.string,
+ }),
+};
+
+CountryField.defaultProps = {
+ errorMessage: null,
+ selectedCountry: {
+ value: '',
+ },
+};
+
+export default CountryField;
diff --git a/src/register/RegistrationFields/CountryField/constants.js b/src/register/RegistrationFields/CountryField/constants.js
new file mode 100644
index 00000000..9817ad29
--- /dev/null
+++ b/src/register/RegistrationFields/CountryField/constants.js
@@ -0,0 +1,3 @@
+export const COUNTRY_FIELD_LABEL = 'country';
+export const COUNTRY_CODE_KEY = 'code';
+export const COUNTRY_DISPLAY_KEY = 'name';
diff --git a/src/register/RegistrationFields/CountryField/validator.js b/src/register/RegistrationFields/CountryField/validator.js
new file mode 100644
index 00000000..ebba8cf0
--- /dev/null
+++ b/src/register/RegistrationFields/CountryField/validator.js
@@ -0,0 +1,28 @@
+import { COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY } from './constants';
+
+const validateCountryField = (value, countryList, errorMessage) => {
+ let countryCode = '';
+ let displayValue = value;
+ let error = errorMessage;
+
+ if (value) {
+ const normalizedValue = value.toLowerCase();
+ // Handling a case here where user enters a valid country code that needs to be
+ // evaluated and set its value as a valid value.
+ const selectedCountry = countryList.find(
+ (country) => (
+ // When translations are applied, extra space added in country value, so we should trim that.
+ country[COUNTRY_DISPLAY_KEY].toLowerCase().trim() === normalizedValue
+ || country[COUNTRY_CODE_KEY].toLowerCase().trim() === normalizedValue
+ ),
+ );
+ if (selectedCountry) {
+ countryCode = selectedCountry[COUNTRY_CODE_KEY];
+ displayValue = selectedCountry[COUNTRY_DISPLAY_KEY];
+ error = '';
+ }
+ }
+ return { error, countryCode, displayValue };
+};
+
+export default validateCountryField;
diff --git a/src/register/RegistrationFields/EmailField/EmailField.jsx b/src/register/RegistrationFields/EmailField/EmailField.jsx
new file mode 100644
index 00000000..be836ba3
--- /dev/null
+++ b/src/register/RegistrationFields/EmailField/EmailField.jsx
@@ -0,0 +1,123 @@
+import React, { useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Alert, Icon } from '@edx/paragon';
+import { Close, Error } from '@edx/paragon/icons';
+import PropTypes from 'prop-types';
+
+import { CONFIRM_EMAIL_FIELD_LABEL, EMAIL_FIELD_LABEL } from './constants';
+import validateEmail from './validator';
+import { FormGroup } from '../../../common-components';
+import { backupRegistrationFormBegin, clearRegistertionBackendError, fetchRealtimeValidations } from '../../data/actions';
+import messages from '../../messages';
+
+const EmailField = (props) => {
+ const { formatMessage } = useIntl();
+ const dispatch = useDispatch();
+
+ const {
+ handleChange,
+ handleErrorChange,
+ confirmEmailValue,
+ } = props;
+
+ const {
+ registrationFormData: backedUpFormData,
+ validationApiRateLimited,
+ } = useSelector(state => state.register);
+
+ const [emailSuggestion, setEmailSuggestion] = useState({ ...backedUpFormData?.emailSuggestion });
+
+ const handleOnBlur = (e) => {
+ const { value } = e.target;
+ const { fieldError, confirmEmailError, suggestion } = validateEmail(value, confirmEmailValue, formatMessage);
+
+ handleErrorChange(CONFIRM_EMAIL_FIELD_LABEL, confirmEmailError);
+ dispatch(backupRegistrationFormBegin({
+ ...backedUpFormData,
+ emailSuggestion: { ...suggestion },
+ }));
+ setEmailSuggestion(suggestion);
+
+ if (fieldError) {
+ handleErrorChange(EMAIL_FIELD_LABEL, fieldError);
+ } else if (!validationApiRateLimited) {
+ dispatch(fetchRealtimeValidations({ email: value }));
+ }
+ };
+
+ const handleOnFocus = () => {
+ handleErrorChange(EMAIL_FIELD_LABEL, '');
+ dispatch(clearRegistertionBackendError(EMAIL_FIELD_LABEL));
+ };
+
+ const handleSuggestionClick = (event) => {
+ event.preventDefault();
+ handleErrorChange(EMAIL_FIELD_LABEL, '');
+ handleChange({ target: { name: EMAIL_FIELD_LABEL, value: emailSuggestion.suggestion } });
+ setEmailSuggestion({ suggestion: '', type: '' });
+ };
+
+ const handleSuggestionClosed = () => setEmailSuggestion({ suggestion: '', type: '' });
+
+ const renderEmailFeedback = () => {
+ if (emailSuggestion.type === 'error') {
+ return (
+
+
+ {formatMessage(messages['did.you.mean.alert.text'])}{' '}
+
+ {emailSuggestion.suggestion}
+ ?
+
+
+
+ );
+ }
+ return (
+
+ {formatMessage(messages['did.you.mean.alert.text'])}:{' '}
+
+ {emailSuggestion.suggestion}
+ ?
+
+ );
+ };
+
+ return (
+
+ {emailSuggestion.suggestion ? renderEmailFeedback() : null}
+
+ );
+};
+
+EmailField.defaultProps = {
+ errorMessage: '',
+ confirmEmailValue: null,
+};
+
+EmailField.propTypes = {
+ errorMessage: PropTypes.string,
+ value: PropTypes.string.isRequired,
+ handleChange: PropTypes.func.isRequired,
+ handleErrorChange: PropTypes.func.isRequired,
+ confirmEmailValue: PropTypes.string,
+};
+
+export default EmailField;
diff --git a/src/register/RegistrationFields/EmailField/constants.js b/src/register/RegistrationFields/EmailField/constants.js
new file mode 100644
index 00000000..1c953802
--- /dev/null
+++ b/src/register/RegistrationFields/EmailField/constants.js
@@ -0,0 +1,139 @@
+export const EMAIL_FIELD_LABEL = 'email';
+export const CONFIRM_EMAIL_FIELD_LABEL = 'confirm_email';
+
+export const COMMON_EMAIL_PROVIDERS = [
+ 'hotmail.com', 'yahoo.com', 'outlook.com', 'live.com', 'gmail.com',
+];
+
+export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+)*'
+ + '|^"([\\001-\\010\\013\\014\\016-\\037!#-\\[\\]-\\177]|\\\\[\\001-\\011\\013\\014\\016-\\177])*"'
+ + ')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\\.)+)(?:[A-Z0-9-]{2,63})'
+ + '|\\[(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\]$';
+
+export const DEFAULT_SERVICE_PROVIDER_DOMAINS = ['yahoo', 'hotmail', 'live', 'outlook', 'gmail'];
+
+export const DEFAULT_TOP_LEVEL_DOMAINS = [
+ 'aaa', 'aarp', 'abarth', 'abb', 'abbott', 'abbvie', 'abc', 'able', 'abogado', 'abudhabi', 'ac', 'academy',
+ 'accenture', 'accountant', 'accountants', 'aco', 'active', 'actor', 'ad', 'adac', 'ads', 'adult', 'ae', 'aeg', 'aero',
+ 'aetna', 'af', 'afamilycompany', 'afl', 'africa', 'ag', 'agakhan', 'agency', 'ai', 'aig', 'aigo', 'airbus', 'airforce',
+ 'airtel', 'akdn', 'al', 'alfaromeo', 'alibaba', 'alipay', 'allfinanz', 'allstate', 'ally', 'alsace', 'alstom', 'am',
+ 'amazon', 'americanexpress', 'americanfamily', 'amex', 'amfam', 'amica', 'amsterdam', 'an', 'analytics', 'android',
+ 'anquan', 'anz', 'ao', 'aol', 'apartments', 'app', 'apple', 'aq', 'aquarelle', 'ar', 'arab', 'aramco', 'archi', 'army',
+ 'arpa', 'art', 'arte', 'as', 'asda', 'asia', 'associates', 'at', 'athleta', 'attorney', 'au', 'auction', 'audi',
+ 'audible', 'audio', 'auspost', 'author', 'auto', 'autos', 'avianca', 'aw', 'aws', 'ax', 'axa', 'az', 'azure', 'ba',
+ 'baby', 'baidu', 'banamex', 'bananarepublic', 'band', 'bank', 'bar', 'barcelona', 'barclaycard', 'barclays',
+ 'barefoot', 'bargains', 'baseball', 'basketball', 'bauhaus', 'bayern', 'bb', 'bbc', 'bbt', 'bbva', 'bcg', 'bcn', 'bd',
+ 'be', 'beats', 'beauty', 'beer', 'bentley', 'berlin', 'best', 'bestbuy', 'bet', 'bf', 'bg', 'bh', 'bharti', 'bi',
+ 'bible', 'bid', 'bike', 'bing', 'bingo', 'bio', 'biz', 'bj', 'bl', 'black', 'blackfriday', 'blanco', 'blockbuster',
+ 'blog', 'bloomberg', 'blue', 'bm', 'bms', 'bmw', 'bn', 'bnl', 'bnpparibas', 'bo', 'boats', 'boehringer', 'bofa', 'bom',
+ 'bond', 'boo', 'book', 'booking', 'boots', 'bosch', 'bostik', 'boston', 'bot', 'boutique', 'box', 'bq', 'br',
+ 'bradesco', 'bridgestone', 'broadway', 'broker', 'brother', 'brussels', 'bs', 'bt', 'budapest', 'bugatti', 'build',
+ 'builders', 'business', 'buy', 'buzz', 'bv', 'bw', 'by', 'bz', 'bzh', 'ca', 'cab', 'cafe', 'cal', 'call',
+ 'calvinklein', 'cam', 'camera', 'camp', 'cancerresearch', 'canon', 'capetown', 'capital', 'capitalone', 'car',
+ 'caravan', 'cards', 'care', 'career', 'careers', 'cars', 'cartier', 'casa', 'case', 'caseih', 'cash', 'casino', 'cat',
+ 'catering', 'catholic', 'cba', 'cbn', 'cbre', 'cbs', 'cc', 'cd', 'ceb', 'center', 'ceo', 'cern', 'cf', 'cfa', 'cfd',
+ 'cg', 'ch', 'chanel', 'channel', 'charity', 'chase', 'chat', 'cheap', 'chintai', 'chloe', 'christmas', 'chrome',
+ 'chrysler', 'church', 'ci', 'cipriani', 'circle', 'cisco', 'citadel', 'citi', 'citic', 'city', 'cityeats', 'ck', 'cl',
+ 'claims', 'cleaning', 'click', 'clinic', 'clinique', 'clothing', 'cloud', 'club', 'clubmed', 'cm', 'cn', 'co', 'coach',
+ 'codes', 'coffee', 'college', 'cologne', 'com', 'comcast', 'commbank', 'community', 'company', 'compare', 'computer',
+ 'comsec', 'condos', 'construction', 'consulting', 'contact', 'contractors', 'cooking', 'cookingchannel', 'cool', 'coop',
+ 'corsica', 'country', 'coupon', 'coupons', 'courses', 'cpa', 'cr', 'credit', 'creditcard', 'creditunion', 'cricket',
+ 'crown', 'crs', 'cruise', 'cruises', 'csc', 'cu', 'cuisinella', 'cv', 'cw', 'cx', 'cy', 'cymru', 'cyou', 'cz', 'dabur',
+ 'dad', 'dance', 'data', 'date', 'dating', 'datsun', 'day', 'dclk', 'dds', 'de', 'deal', 'dealer', 'deals', 'degree',
+ 'delivery', 'dell', 'deloitte', 'delta', 'democrat', 'dental', 'dentist', 'desi', 'design', 'dev', 'dhl', 'diamonds',
+ 'diet', 'digital', 'direct', 'directory', 'discount', 'discover', 'dish', 'diy', 'dj', 'dk', 'dm', 'dnp', 'do', 'docs',
+ 'doctor', 'dodge', 'dog', 'doha', 'domains', 'doosan', 'dot', 'download', 'drive', 'dtv', 'dubai', 'duck', 'dunlop',
+ 'duns', 'dupont', 'durban', 'dvag', 'dvr', 'dz', 'earth', 'eat', 'ec', 'eco', 'edeka', 'edu', 'education', 'ee', 'eg',
+ 'eh', 'email', 'emerck', 'energy', 'engineer', 'engineering', 'enterprises', 'epost', 'epson', 'equipment', 'er',
+ 'ericsson', 'erni', 'es', 'esq', 'estate', 'esurance', 'et', 'etisalat', 'eu', 'eurovision', 'eus', 'events', 'everbank',
+ 'exchange', 'expert', 'exposed', 'express', 'extraspace', 'fage', 'fail', 'fairwinds', 'faith', 'family', 'fan', 'fans',
+ 'farm', 'farmers', 'fashion', 'fast', 'fedex', 'feedback', 'ferrari', 'ferrero', 'fi', 'fiat', 'fidelity', 'fido', 'film',
+ 'final', 'finance', 'financial', 'fire', 'firestone', 'firmdale', 'fish', 'fishing', 'fit', 'fitness', 'fj', 'fk',
+ 'flickr', 'flights', 'flir', 'florist', 'flowers', 'flsmidth', 'fly', 'fm', 'fo', 'foo', 'food', 'foodnetwork', 'football',
+ 'ford', 'forex', 'forsale', 'forum', 'foundation', 'fox', 'fr', 'free', 'fresenius', 'frl', 'frogans', 'frontdoor',
+ 'frontier', 'ftr', 'fujitsu', 'fujixerox', 'fun', 'fund', 'furniture', 'futbol', 'fyi', 'ga', 'gal', 'gallery', 'gallo',
+ 'gallup', 'game', 'games', 'gap', 'garden', 'gay', 'gb', 'gbiz', 'gd', 'gdn', 'ge', 'gea', 'gent', 'genting', 'george',
+ 'gf', 'gg', 'ggee', 'gh', 'gi', 'gift', 'gifts', 'gives', 'giving', 'gl', 'glade', 'glass', 'gle', 'global', 'globo',
+ 'gm', 'gmail', 'gmbh', 'gmo', 'gmx', 'gn', 'godaddy', 'gold', 'goldpoint', 'golf', 'goo', 'goodhands', 'goodyear', 'goog',
+ 'google', 'gop', 'got', 'gov', 'gp', 'gq', 'gr', 'grainger', 'graphics', 'gratis', 'green', 'gripe', 'grocery', 'group',
+ 'gs', 'gt', 'gu', 'guardian', 'gucci', 'guge', 'guide', 'guitars', 'guru', 'gw', 'gy', 'hair', 'hamburg', 'hangout',
+ 'haus', 'hbo', 'hdfc', 'hdfcbank', 'health', 'healthcare', 'help', 'helsinki', 'here', 'hermes', 'hgtv', 'hiphop',
+ 'hisamitsu', 'hitachi', 'hiv', 'hk', 'hkt', 'hm', 'hn', 'hockey', 'holdings', 'holiday', 'homedepot', 'homegoods',
+ 'homes', 'homesense', 'honda', 'honeywell', 'horse', 'hospital', 'host', 'hosting', 'hot', 'hoteles', 'hotels', 'hotmail',
+ 'house', 'how', 'hr', 'hsbc', 'ht', 'htc', 'hu', 'hughes', 'hyatt', 'hyundai', 'ibm', 'icbc', 'ice', 'icu', 'id', 'ie',
+ 'ieee', 'ifm', 'iinet', 'ikano', 'il', 'im', 'imamat', 'imdb', 'immo', 'immobilien', 'in', 'inc', 'industries', 'infiniti',
+ 'info', 'ing', 'ink', 'institute', 'insurance', 'insure', 'int', 'intel', 'international', 'intuit', 'investments',
+ 'io', 'ipiranga', 'iq', 'ir', 'irish', 'is', 'iselect', 'ismaili', 'ist', 'istanbul', 'it', 'itau', 'itv', 'iveco', 'iwc',
+ 'jaguar', 'java', 'jcb', 'jcp', 'je', 'jeep', 'jetzt', 'jewelry', 'jio', 'jlc', 'jll', 'jm', 'jmp', 'jnj', 'jo',
+ 'jobs', 'joburg', 'jot', 'joy', 'jp', 'jpmorgan', 'jprs', 'juegos', 'juniper', 'kaufen', 'kddi', 'ke', 'kerryhotels',
+ 'kerrylogistics', 'kerryproperties', 'kfh', 'kg', 'kh', 'ki', 'kia', 'kim', 'kinder', 'kindle', 'kitchen', 'kiwi', 'km',
+ 'kn', 'koeln', 'komatsu', 'kosher', 'kp', 'kpmg', 'kpn', 'kr', 'krd', 'kred', 'kuokgroup', 'kw', 'ky', 'kyoto', 'kz',
+ 'la', 'lacaixa', 'ladbrokes', 'lamborghini', 'lamer', 'lancaster', 'lancia', 'lancome', 'land', 'landrover', 'lanxess',
+ 'lasalle', 'lat', 'latino', 'latrobe', 'law', 'lawyer', 'lb', 'lc', 'lds', 'lease', 'leclerc', 'lefrak', 'legal',
+ 'lego', 'lexus', 'lgbt', 'li', 'liaison', 'lidl', 'life', 'lifeinsurance', 'lifestyle', 'lighting', 'like', 'lilly',
+ 'limited', 'limo', 'lincoln', 'linde', 'link', 'lipsy', 'live', 'living', 'lixil', 'lk', 'llc', 'llp', 'loan', 'loans',
+ 'locker', 'locus', 'loft', 'lol', 'london', 'lotte', 'lotto', 'love', 'lpl', 'lplfinancial', 'lr', 'ls', 'lt', 'ltd',
+ 'ltda', 'lu', 'lundbeck', 'lupin', 'luxe', 'luxury', 'lv', 'ly', 'ma', 'macys', 'madrid', 'maif', 'maison', 'makeup',
+ 'man', 'management', 'mango', 'map', 'market', 'marketing', 'markets', 'marriott', 'marshalls', 'maserati', 'mattel',
+ 'mba', 'mc', 'mcd', 'mcdonalds', 'mckinsey', 'md', 'me', 'med', 'media', 'meet', 'melbourne', 'meme', 'memorial', 'men',
+ 'menu', 'meo', 'merckmsd', 'metlife', 'mf', 'mg', 'mh', 'miami', 'microsoft', 'mil', 'mini', 'mint', 'mit', 'mitsubishi',
+ 'mk', 'ml', 'mlb', 'mls', 'mm', 'mma', 'mn', 'mo', 'mobi', 'mobile', 'mobily', 'moda', 'moe', 'moi', 'mom', 'monash',
+ 'money', 'monster', 'montblanc', 'mopar', 'mormon', 'mortgage', 'moscow', 'moto', 'motorcycles', 'mov', 'movie', 'movistar',
+ 'mp', 'mq', 'mr', 'ms', 'msd', 'mt', 'mtn', 'mtpc', 'mtr', 'mu', 'museum', 'mutual', 'mutuelle', 'mv', 'mw', 'mx', 'my',
+ 'mz', 'na', 'nab', 'nadex', 'nagoya', 'name', 'nationwide', 'natura', 'navy', 'nba', 'nc', 'ne', 'nec', 'net', 'netbank',
+ 'netflix', 'network', 'neustar', 'new', 'newholland', 'news', 'next', 'nextdirect', 'nexus', 'nf', 'nfl', 'ng', 'ngo', 'nhk',
+ 'ni', 'nico', 'nike', 'nikon', 'ninja', 'nissan', 'nissay', 'nl', 'no', 'nokia', 'northwesternmutual', 'norton', 'now',
+ 'nowruz', 'nowtv', 'np', 'nr', 'nra', 'nrw', 'ntt', 'nu', 'nyc', 'nz', 'obi', 'observer', 'off', 'office', 'okinawa',
+ 'olayan', 'olayangroup', 'oldnavy', 'ollo', 'om', 'omega', 'one', 'ong', 'onl', 'online', 'onyourside', 'ooo', 'open',
+ 'oracle', 'orange', 'org', 'organic', 'orientexpress', 'origins', 'osaka', 'otsuka', 'ott', 'ovh', 'pa', 'page',
+ 'pamperedchef', 'panasonic', 'panerai', 'paris', 'pars', 'partners', 'parts', 'party', 'passagens', 'pay', 'pccw', 'pe',
+ 'pet', 'pf', 'pfizer', 'pg', 'ph', 'pharmacy', 'phd', 'philips', 'phone', 'photo', 'photography', 'photos', 'physio',
+ 'piaget', 'pics', 'pictet', 'pictures', 'pid', 'pin', 'ping', 'pink', 'pioneer', 'pizza', 'pk', 'pl', 'place', 'play',
+ 'playstation', 'plumbing', 'plus', 'pm', 'pn', 'pnc', 'pohl', 'poker', 'politie', 'porn', 'post', 'pr', 'pramerica',
+ 'praxi', 'press', 'prime', 'pro', 'prod', 'productions', 'prof', 'progressive', 'promo', 'properties', 'property',
+ 'protection', 'pru', 'prudential', 'ps', 'pt', 'pub', 'pw', 'pwc', 'py', 'qa', 'qpon', 'quebec', 'quest', 'qvc',
+ 'racing', 'radio', 'raid', 're', 'read', 'realestate', 'realtor', 'realty', 'recipes', 'red', 'redstone', 'redumbrella',
+ 'rehab', 'reise', 'reisen', 'reit', 'reliance', 'ren', 'rent', 'rentals', 'repair', 'report', 'republican', 'rest',
+ 'restaurant', 'review', 'reviews', 'rexroth', 'rich', 'richardli', 'ricoh', 'rightathome', 'ril', 'rio', 'rip', 'rmit',
+ 'ro', 'rocher', 'rocks', 'rodeo', 'rogers', 'room', 'rs', 'rsvp', 'ru', 'rugby', 'ruhr', 'run', 'rw', 'rwe', 'ryukyu',
+ 'sa', 'saarland', 'safe', 'safety', 'sakura', 'sale', 'salon', 'samsclub', 'samsung', 'sandvik', 'sandvikcoromant',
+ 'sanofi', 'sap', 'sapo', 'sarl', 'sas', 'save', 'saxo', 'sb', 'sbi', 'sbs', 'sc', 'sca', 'scb', 'schaeffler', 'schmidt',
+ 'scholarships', 'school', 'schule', 'schwarz', 'science', 'scjohnson', 'scor', 'scot', 'sd', 'se', 'search', 'seat',
+ 'secure', 'security', 'seek', 'select', 'sener', 'services', 'ses', 'seven', 'sew', 'sex', 'sexy', 'sfr', 'sg', 'sh',
+ 'shangrila', 'sharp', 'shaw', 'shell', 'shia', 'shiksha', 'shoes', 'shop', 'shopping', 'shouji', 'show', 'showtime',
+ 'shriram', 'si', 'silk', 'sina', 'singles', 'site', 'sj', 'sk', 'ski', 'skin', 'sky', 'skype', 'sl', 'sling', 'sm',
+ 'smart', 'smile', 'sn', 'sncf', 'so', 'soccer', 'social', 'softbank', 'software', 'sohu', 'solar', 'solutions', 'song',
+ 'sony', 'soy', 'spa', 'space', 'spiegel', 'sport', 'spot', 'spreadbetting', 'sr', 'srl', 'srt', 'ss', 'st', 'stada',
+ 'staples', 'star', 'starhub', 'statebank', 'statefarm', 'statoil', 'stc', 'stcgroup', 'stockholm', 'storage', 'store',
+ 'stream', 'studio', 'study', 'style', 'su', 'sucks', 'supplies', 'supply', 'support', 'surf', 'surgery', 'suzuki', 'sv',
+ 'swatch', 'swiftcover', 'swiss', 'sx', 'sy', 'sydney', 'symantec', 'systems', 'sz', 'tab', 'taipei', 'talk', 'taobao',
+ 'target', 'tatamotors', 'tatar', 'tattoo', 'tax', 'taxi', 'tc', 'tci', 'td', 'tdk', 'team', 'tech', 'technology', 'tel',
+ 'telecity', 'telefonica', 'temasek', 'tennis', 'teva', 'tf', 'tg', 'th', 'thd', 'theater', 'theatre', 'tiaa', 'tickets',
+ 'tienda', 'tiffany', 'tips', 'tires', 'tirol', 'tj', 'tjmaxx', 'tjx', 'tk', 'tkmaxx', 'tl', 'tm', 'tmall', 'tn', 'to',
+ 'today', 'tokyo', 'tools', 'top', 'toray', 'toshiba', 'total', 'tours', 'town', 'toyota', 'toys', 'tp', 'tr', 'trade',
+ 'trading', 'training', 'travel', 'travelchannel', 'travelers', 'travelersinsurance', 'trust', 'trv', 'tt', 'tube', 'tui',
+ 'tunes', 'tushu', 'tv', 'tvs', 'tw', 'tz', 'ua', 'ubank', 'ubs', 'uconnect', 'ug', 'uk', 'um', 'unicom', 'university',
+ 'uno', 'uol', 'ups', 'us', 'uy', 'uz', 'va', 'vacations', 'vana', 'vanguard', 'vc', 've', 'vegas', 'ventures', 'verisign',
+ 'versicherung', 'vet', 'vg', 'vi', 'viajes', 'video', 'vig', 'viking', 'villas', 'vin', 'vip', 'virgin', 'visa', 'vision',
+ 'vista', 'vistaprint', 'viva', 'vivo', 'vlaanderen', 'vn', 'vodka', 'volkswagen', 'volvo', 'vote', 'voting', 'voto',
+ 'voyage', 'vu', 'vuelos', 'wales', 'walmart', 'walter', 'wang', 'wanggou', 'warman', 'watch', 'watches', 'weather',
+ 'weatherchannel', 'webcam', 'weber', 'website', 'wed', 'wedding', 'weibo', 'weir', 'wf', 'whoswho', 'wien', 'wiki',
+ 'williamhill', 'win', 'windows', 'wine', 'winners', 'wme', 'wolterskluwer', 'woodside', 'work', 'works', 'world', 'wow',
+ 'ws', 'wtc', 'wtf', 'xbox', 'xerox', 'xfinity', 'xihuan', 'xin', '测试', 'कॉम', 'परीक्षा', 'セール', '佛山', 'ಭಾರತ', '慈善',
+ '集团', '在线', '한국', 'ଭାରତ', '大众汽车', '点看', 'คอม', 'ভাৰত', 'ভারত', '八卦', 'ישראל\u200e', 'موقع\u200e', 'বাংলা', '公益',
+ '公司', '香格里拉', '网站', '移动', '我爱你', 'москва', 'испытание', 'қаз', 'католик', 'онлайн', 'сайт', '联通', 'срб', 'бг',
+ 'бел', 'קום\u200e', '时尚', '微博', '테스트', '淡马锡', 'ファッション', 'орг', 'नेट', 'ストア', 'アマゾン', '삼성', 'சிங்கப்பூர்', '商标',
+ '商店', '商城', 'дети', 'мкд', 'טעסט\u200e', 'ею', 'ポイント', '新闻', '工行', '家電', 'كوم\u200e', '中文网', '中信', '中国',
+ '中國', '娱乐', '谷歌', 'భారత్', 'ලංකා', '電訊盈科', '购物', '測試', 'クラウド', 'ભારત', '通販', 'भारतम्', 'भारत', 'भारोत', 'آزمایشی\u200e',
+ 'பரிட்சை', '网店', 'संगठन', '餐厅', '网络', 'ком', 'укр', '香港', '亚马逊', '诺基亚', '食品', 'δοκιμή', '飞利浦', 'إختبار\u200e',
+ '台湾', '台灣', '手表', '手机', 'мон', 'الجزائر\u200e', 'عمان\u200e', 'ارامكو\u200e', 'ایران\u200e', 'العليان\u200e',
+ 'اتصالات\u200e', 'امارات\u200e', 'بازار\u200e', 'موريتانيا\u200e', 'پاکستان\u200e', 'الاردن\u200e', 'موبايلي\u200e',
+ 'بارت\u200e', 'بھارت\u200e', 'المغرب\u200e', 'ابوظبي\u200e', 'البحرين\u200e', 'السعودية\u200e', 'ڀارت\u200e',
+ 'كاثوليك\u200e', 'سودان\u200e', 'همراه\u200e', 'عراق\u200e', 'مليسيا\u200e', '澳門', '닷컴', '政府', 'شبكة\u200e',
+ 'بيتك\u200e', 'عرب\u200e', 'გე', '机构', '组织机构', '健康', 'ไทย', 'سورية\u200e', '招聘', 'рус', 'рф', '珠宝',
+ 'تونس\u200e', '大拿', 'ລາວ', 'みんな', 'グーグル', 'ευ', 'ελ', '世界', '書籍', 'ഭാരതം', 'ਭਾਰਤ', '网址', '닷넷', 'コム',
+ '天主教', '游戏', 'vermögensberater', 'vermögensberatung', '企业', '信息', '嘉里大酒店', '嘉里', 'مصر\u200e',
+ 'قطر\u200e', '广东', 'இலங்கை', 'இந்தியா', 'հայ', '新加坡', 'فلسطين\u200e', 'テスト', '政务', 'xperia', 'xxx',
+ 'xyz', 'yachts', 'yahoo', 'yamaxun', 'yandex', 'ye', 'yodobashi', 'yoga', 'yokohama', 'you', 'youtube', 'yt',
+ 'yun', 'za', 'zappos', 'zara', 'zero', 'zip', 'zippo', 'zm', 'zone', 'zuerich', 'zw',
+];
diff --git a/src/register/RegistrationFields/EmailField/validator.js b/src/register/RegistrationFields/EmailField/validator.js
new file mode 100644
index 00000000..477c9a83
--- /dev/null
+++ b/src/register/RegistrationFields/EmailField/validator.js
@@ -0,0 +1,125 @@
+import { distance } from 'fastest-levenshtein';
+
+import {
+ COMMON_EMAIL_PROVIDERS,
+ DEFAULT_SERVICE_PROVIDER_DOMAINS,
+ DEFAULT_TOP_LEVEL_DOMAINS, VALID_EMAIL_REGEX,
+} from './constants';
+import messages from '../../messages';
+
+export const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i');
+
+const getLevenshteinSuggestion = (word, knownWords, similarityThreshold = 4) => {
+ if (!word) {
+ return null;
+ }
+
+ let minEditDistance = 100;
+ let mostSimilar = word;
+
+ for (let i = 0; i < knownWords.length; i++) {
+ const editDistance = distance(knownWords[i].toLowerCase(), word.toLowerCase());
+ if (editDistance < minEditDistance) {
+ minEditDistance = editDistance;
+ mostSimilar = knownWords[i];
+ }
+ }
+
+ return minEditDistance <= similarityThreshold && word !== mostSimilar ? mostSimilar : null;
+};
+
+export const getSuggestionForInvalidEmail = (domain, username) => {
+ if (!domain) {
+ return '';
+ }
+
+ const defaultDomains = ['yahoo', 'aol', 'hotmail', 'live', 'outlook', 'gmail'];
+ const suggestion = getLevenshteinSuggestion(domain, COMMON_EMAIL_PROVIDERS);
+
+ if (suggestion) {
+ return `${username}@${suggestion}`;
+ }
+
+ for (let i = 0; i < defaultDomains.length; i++) {
+ if (domain.includes(defaultDomains[i])) {
+ return `${username}@${defaultDomains[i]}.com`;
+ }
+ }
+
+ return '';
+};
+
+export const validateEmailAddress = (value, username, domainName) => {
+ let suggestion = null;
+ const validation = {
+ hasError: false,
+ suggestion: '',
+ type: '',
+ };
+
+ const hasMultipleSubdomains = value.match(/\./g).length > 1;
+ const [serviceLevelDomain, topLevelDomain] = domainName.split('.');
+ const tldSuggestion = !DEFAULT_TOP_LEVEL_DOMAINS.includes(topLevelDomain);
+ const serviceSuggestion = getLevenshteinSuggestion(serviceLevelDomain, DEFAULT_SERVICE_PROVIDER_DOMAINS, 2);
+
+ if (DEFAULT_SERVICE_PROVIDER_DOMAINS.includes(serviceSuggestion || serviceLevelDomain)) {
+ suggestion = `${username}@${serviceSuggestion || serviceLevelDomain}.com`;
+ }
+
+ if (!hasMultipleSubdomains && tldSuggestion) {
+ validation.suggestion = suggestion;
+ validation.type = 'error';
+ } else if (serviceSuggestion) {
+ validation.suggestion = suggestion;
+ validation.type = 'warning';
+ } else {
+ suggestion = getLevenshteinSuggestion(domainName, COMMON_EMAIL_PROVIDERS, 3);
+ if (suggestion) {
+ validation.suggestion = `${username}@${suggestion}`;
+ validation.type = 'warning';
+ }
+ }
+
+ if (!hasMultipleSubdomains && tldSuggestion) {
+ validation.hasError = true;
+ }
+
+ return validation;
+};
+
+const validateEmail = (value, confirmEmailValue, formatMessage) => {
+ let fieldError = '';
+ let confirmEmailError = '';
+ let emailSuggestion = {};
+
+ if (!value) {
+ fieldError = formatMessage(messages['empty.email.field.error']);
+ } else if (value.length <= 2) {
+ fieldError = formatMessage(messages['email.invalid.format.error']);
+ } else {
+ const [username, domainName] = value.split('@');
+ // Check if email address is invalid. If we have a suggestion for invalid email
+ // provide that along with the error message.
+ if (!emailRegex.test(value)) {
+ fieldError = formatMessage(messages['email.invalid.format.error']);
+ emailSuggestion = {
+ suggestion: getSuggestionForInvalidEmail(domainName, username),
+ type: 'error',
+ };
+ } else {
+ const response = validateEmailAddress(value, username, domainName);
+ if (response.hasError) {
+ fieldError = formatMessage(messages['email.invalid.format.error']);
+ delete response.hasError;
+ }
+ emailSuggestion = { ...response };
+
+ if (confirmEmailValue && value !== confirmEmailValue) {
+ confirmEmailError = formatMessage(messages['email.do.not.match']);
+ }
+ }
+ }
+ return { fieldError, confirmEmailError, suggestion: emailSuggestion };
+};
+
+export default validateEmail;
diff --git a/src/register/registrationFields/HonorCode.jsx b/src/register/RegistrationFields/HonorCode.jsx
similarity index 100%
rename from src/register/registrationFields/HonorCode.jsx
rename to src/register/RegistrationFields/HonorCode.jsx
diff --git a/src/register/RegistrationFields/NameField/NameField.jsx b/src/register/RegistrationFields/NameField/NameField.jsx
new file mode 100644
index 00000000..c64e70c6
--- /dev/null
+++ b/src/register/RegistrationFields/NameField/NameField.jsx
@@ -0,0 +1,59 @@
+import React, {useEffect} from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { useIntl } from '@edx/frontend-platform/i18n';
+import PropTypes from 'prop-types';
+
+import { NAME_FIELD_LABEL } from './constants';
+import validateName from './validator';
+import { FormGroup } from '../../../common-components';
+import { clearRegistertionBackendError, fetchRealtimeValidations } from '../../data/actions';
+
+const NameField = (props) => {
+ const { formatMessage } = useIntl();
+ const dispatch = useDispatch();
+ const validationApiRateLimited = useSelector(state => state.register.validationApiRateLimited);
+
+ const {
+ handleErrorChange,
+ shouldFetchUsernameSuggestions,
+ } = props;
+
+ const handleOnBlur = (e) => {
+ const { value } = e.target;
+ const fieldError = validateName(value, formatMessage);
+ if (fieldError) {
+ handleErrorChange(NAME_FIELD_LABEL, fieldError);
+ } else if (shouldFetchUsernameSuggestions && !validationApiRateLimited) {
+ dispatch(fetchRealtimeValidations({ name: value }));
+ }
+ };
+
+ const handleOnFocus = () => {
+ handleErrorChange(NAME_FIELD_LABEL, '');
+ dispatch(clearRegistertionBackendError(NAME_FIELD_LABEL));
+ };
+
+ return (
+
+ );
+};
+
+NameField.defaultProps = {
+ errorMessage: '',
+ shouldFetchUsernameSuggestions: false,
+};
+
+NameField.propTypes = {
+ errorMessage: PropTypes.string,
+ shouldFetchUsernameSuggestions: PropTypes.bool,
+ value: PropTypes.string.isRequired,
+ handleChange: PropTypes.func.isRequired,
+ handleErrorChange: PropTypes.func.isRequired,
+};
+
+export default NameField;
diff --git a/src/register/RegistrationFields/NameField/constants.js b/src/register/RegistrationFields/NameField/constants.js
new file mode 100644
index 00000000..46405f69
--- /dev/null
+++ b/src/register/RegistrationFields/NameField/constants.js
@@ -0,0 +1,5 @@
+export const INVALID_NAME_REGEX = /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi; // eslint-disable-line no-useless-escape
+
+export const urlRegex = new RegExp(INVALID_NAME_REGEX);
+
+export const NAME_FIELD_LABEL = 'name';
diff --git a/src/register/RegistrationFields/NameField/validator.js b/src/register/RegistrationFields/NameField/validator.js
new file mode 100644
index 00000000..08d2b16f
--- /dev/null
+++ b/src/register/RegistrationFields/NameField/validator.js
@@ -0,0 +1,14 @@
+import { urlRegex } from './constants';
+import messages from '../../messages';
+
+const validateName = (value, formatMessage) => {
+ let fieldError;
+ if (!value.trim()) {
+ fieldError = formatMessage(messages['empty.name.field.error']);
+ } else if (value && value.match(urlRegex)) {
+ fieldError = formatMessage(messages['name.validation.message']);
+ }
+ return fieldError;
+};
+
+export default validateName;
diff --git a/src/register/registrationFields/TermsOfService.jsx b/src/register/RegistrationFields/TermsOfService.jsx
similarity index 100%
rename from src/register/registrationFields/TermsOfService.jsx
rename to src/register/RegistrationFields/TermsOfService.jsx
diff --git a/src/register/RegistrationFields/UsernameField/UsernameField.jsx b/src/register/RegistrationFields/UsernameField/UsernameField.jsx
new file mode 100644
index 00000000..b5984b7a
--- /dev/null
+++ b/src/register/RegistrationFields/UsernameField/UsernameField.jsx
@@ -0,0 +1,150 @@
+import React, { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Button, Icon, IconButton } from '@edx/paragon';
+import { Close } from '@edx/paragon/icons';
+import PropTypes from 'prop-types';
+
+import { USERNAME_FIELD_LABEL } from './constants';
+import validateUsername from './validator';
+import { FormGroup } from '../../../common-components';
+import {
+ clearRegistertionBackendError,
+ clearUsernameSuggestions,
+ fetchRealtimeValidations,
+} from '../../data/actions';
+import messages from '../../messages';
+
+const UsernameField = (props) => {
+ const { formatMessage } = useIntl();
+ const dispatch = useDispatch();
+
+ const {
+ value,
+ errorMessage,
+ handleChange,
+ handleErrorChange,
+ } = props;
+
+ let className = '';
+ let suggestedUsernameDiv = null;
+ let iconButton = null;
+ const { usernameSuggestions, validationApiRateLimited } = useSelector(state => state.register);
+
+ /**
+ * We need to remove the placeholder from the field, adding a space will do that.
+ * This is needed because we are placing the username suggestions on top of the field.
+ */
+ useEffect(() => {
+ if (usernameSuggestions.length && !value) {
+ handleChange({ target: { name: USERNAME_FIELD_LABEL, value: ' ' } });
+ }
+ }, [handleChange, usernameSuggestions, value]);
+
+ const handleOnBlur = (event) => {
+ const { value: username } = event.target;
+ const fieldError = validateUsername(username, formatMessage);
+ if (fieldError) {
+ handleErrorChange(USERNAME_FIELD_LABEL, fieldError);
+ } else if (!validationApiRateLimited) {
+ dispatch(fetchRealtimeValidations({ username: value }));
+ }
+ };
+
+ const handleOnChange = (event) => {
+ let username = event.target.value;
+ if (username.length > 30) {
+ return;
+ }
+ if (event.target.value.startsWith(' ')) {
+ username = username.trim();
+ }
+ handleChange({ target: { name: USERNAME_FIELD_LABEL, value: username } });
+ };
+
+ const handleOnFocus = (event) => {
+ const username = event.target.value;
+ dispatch(clearUsernameSuggestions());
+ // If we added a space character to username field to display the suggestion
+ // remove it before user enters the input. This is to ensure user doesn't
+ // have a space prefixed to the username.
+ if (username === ' ') {
+ handleChange({ target: { name: USERNAME_FIELD_LABEL, value: '' } });
+ }
+ handleErrorChange(USERNAME_FIELD_LABEL, '');
+ dispatch(clearRegistertionBackendError(USERNAME_FIELD_LABEL));
+ };
+
+ const handleSuggestionClick = (event, suggestion = '') => {
+ event.preventDefault();
+ handleErrorChange(USERNAME_FIELD_LABEL, ''); // clear error
+ handleChange({ target: { name: USERNAME_FIELD_LABEL, value: suggestion } }); // to set suggestion as value
+ dispatch(clearUsernameSuggestions());
+ };
+
+ const handleUsernameSuggestionClose = () => {
+ handleChange({ target: { name: USERNAME_FIELD_LABEL, value: '' } }); // to remove space in field
+ dispatch(clearUsernameSuggestions());
+ };
+
+ const suggestedUsernames = () => (
+
+
{formatMessage(messages['registration.username.suggestion.label'])}
+
+ {usernameSuggestions.map((username, index) => (
+
+ ))}
+
+ {iconButton}
+
+ );
+
+ if (usernameSuggestions.length > 0 && errorMessage && value === ' ') {
+ className = 'username-suggestions__error';
+ iconButton =
handleUsernameSuggestionClose()} variant="black" size="sm" className="username-suggestions__close__button" />;
+ suggestedUsernameDiv = suggestedUsernames();
+ } else if (usernameSuggestions.length > 0 && value === ' ') {
+ className = 'username-suggestions d-flex align-items-center';
+ iconButton = handleUsernameSuggestionClose()} variant="black" size="sm" className="username-suggestions__close__button" />;
+ suggestedUsernameDiv = suggestedUsernames();
+ } else if (usernameSuggestions.length > 0 && errorMessage) {
+ suggestedUsernameDiv = suggestedUsernames();
+ }
+ return (
+
+ {suggestedUsernameDiv}
+
+ );
+};
+
+UsernameField.defaultProps = {
+ errorMessage: '',
+ autoComplete: null,
+};
+
+UsernameField.propTypes = {
+ handleChange: PropTypes.func.isRequired,
+ handleErrorChange: PropTypes.func.isRequired,
+ errorMessage: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ autoComplete: PropTypes.string,
+};
+
+export default UsernameField;
diff --git a/src/register/RegistrationFields/UsernameField/constants.js b/src/register/RegistrationFields/UsernameField/constants.js
new file mode 100644
index 00000000..39a629df
--- /dev/null
+++ b/src/register/RegistrationFields/UsernameField/constants.js
@@ -0,0 +1,3 @@
+export const USERNAME_FIELD_LABEL = 'username';
+
+export const VALID_USERNAME_REGEX = /^[a-zA-Z0-9_-]*$/i;
diff --git a/src/register/RegistrationFields/UsernameField/validator.js b/src/register/RegistrationFields/UsernameField/validator.js
new file mode 100644
index 00000000..af92c429
--- /dev/null
+++ b/src/register/RegistrationFields/UsernameField/validator.js
@@ -0,0 +1,16 @@
+import { VALID_USERNAME_REGEX } from './constants';
+import messages from '../../messages';
+
+export const usernameRegex = new RegExp(VALID_USERNAME_REGEX, 'i');
+
+const validateUsername = (value, formatMessage) => {
+ let fieldError = '';
+ if (!value || value.length <= 1 || value.length > 30) {
+ fieldError = formatMessage(messages['username.validation.message']);
+ } else if (!usernameRegex.test(value)) {
+ fieldError = formatMessage(messages['username.format.validation.message']);
+ }
+ return fieldError;
+};
+
+export default validateUsername;
diff --git a/src/register/RegistrationFields/index.js b/src/register/RegistrationFields/index.js
new file mode 100644
index 00000000..e2c77777
--- /dev/null
+++ b/src/register/RegistrationFields/index.js
@@ -0,0 +1,5 @@
+export { default as EmailField } from './EmailField/EmailField';
+export { default as UsernameField } from './UsernameField/UsernameField';
+export { default as CountryField } from './CountryField/CountryField';
+export { default as HonorCode } from './HonorCode';
+export { default as TermsOfService } from './TermsOfService';
diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx
index c7627c40..3703fb79 100644
--- a/src/register/RegistrationPage.jsx
+++ b/src/register/RegistrationPage.jsx
@@ -1,52 +1,41 @@
import React, {
useEffect, useMemo, useState,
} from 'react';
-import { connect } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
-import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
+import { getConfig } from '@edx/frontend-platform';
import { sendPageEvent } from '@edx/frontend-platform/analytics';
-import {
- getCountryList, getLocale, useIntl,
-} from '@edx/frontend-platform/i18n';
+import { useIntl } from '@edx/frontend-platform/i18n';
import { Form, Spinner, StatefulButton } from '@edx/paragon';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import Skeleton from 'react-loading-skeleton';
-import ConfigurableRegistrationForm from './ConfigurableRegistrationForm';
+import ConfigurableRegistrationForm from './components/ConfigurableRegistrationForm';
import {
backupRegistrationFormBegin,
clearRegistertionBackendError,
- clearUsernameSuggestions,
- fetchRealtimeValidations,
registerNewUser,
setUserPipelineDataLoaded,
} from './data/actions';
import {
- COUNTRY_CODE_KEY,
- COUNTRY_DISPLAY_KEY,
- FIELDS,
FORM_SUBMISSION_ERROR,
TPA_AUTHENTICATION_FAILURE,
} from './data/constants';
-import { registrationErrorSelector, validationsSelector } from './data/selectors';
-import {
- emailRegex, getSuggestionForInvalidEmail, urlRegex, validateCountryField, validateEmailAddress,
-} from './data/utils';
+import { getBackendValidations } from './data/selectors';
+import { isFormValid, prepareRegistrationPayload } from './data/utils';
import messages from './messages';
-import RegistrationFailure from './RegistrationFailure';
-import { EmailField, UsernameField } from './registrationFields';
-import ThirdPartyAuth from './ThirdPartyAuth';
+import RegistrationFailure from './components/RegistrationFailure';
+import { EmailField, UsernameField } from './RegistrationFields';
+import NameField from './RegistrationFields/NameField/NameField';
+import ThirdPartyAuth from './components/ThirdPartyAuth';
import {
- FormGroup, InstitutionLogistration, PasswordField, RedirectLogistration, ThirdPartyAuthAlert,
+ InstitutionLogistration, PasswordField, RedirectLogistration, ThirdPartyAuthAlert,
} from '../common-components';
-import { getThirdPartyAuthContext } from '../common-components/data/actions';
-import {
- fieldDescriptionSelector, optionalFieldsSelector, thirdPartyAuthContextSelector,
-} from '../common-components/data/selectors';
+import { getThirdPartyAuthContext as getRegistrationDataFromBackend } from '../common-components/data/actions';
import EnterpriseSSO from '../common-components/EnterpriseSSO';
import {
- COMPLETE_STATE, DEFAULT_STATE, LETTER_REGEX, NUMBER_REGEX, PENDING_STATE, REGISTER_PAGE,
+ COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE,
} from '../data/constants';
import {
getAllPossibleQueryParams, getTpaHint, getTpaProvider, setCookie,
@@ -54,33 +43,36 @@ import {
const RegistrationPage = (props) => {
const {
- backedUpFormData,
- backendCountryCode,
- backendValidations,
- fieldDescriptions,
- handleInstitutionLogin,
- institutionLogin,
- optionalFields,
+ registrationFormData: backedUpFormData,
registrationError,
- registrationErrorCode,
+ registrationError: {
+ errorCode: registrationErrorCode,
+ } = {},
registrationResult,
shouldBackupState,
+ userPipelineDataLoaded,
submitState,
+ validations,
+ } = useSelector(state => state.register);
+
+ const {
+ fieldDescriptions,
+ optionalFields,
thirdPartyAuthApiStatus,
thirdPartyAuthContext,
- usernameSuggestions,
- validationApiRateLimited,
- // Actions
- backupFormState,
- setUserPipelineDetailsLoaded,
- getRegistrationDataFromBackend,
- userPipelineDataLoaded,
- validateFromBackend,
- clearBackendError,
+ } = useSelector(state => state.commonComponents);
+
+ const {
+ handleInstitutionLogin,
+ institutionLogin,
} = props;
const { formatMessage } = useIntl();
- const countryList = useMemo(() => getCountryList(getLocale()), []);
+ const dispatch = useDispatch();
+
+ const backendValidations = useMemo(
+ () => getBackendValidations(registrationError, validations), [registrationError, validations],
+ );
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
const tpaHint = useMemo(() => getTpaHint(), []);
const flags = {
@@ -92,35 +84,15 @@ const RegistrationPage = (props) => {
const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields });
const [configurableFormFields, setConfigurableFormFields] = useState({ ...backedUpFormData.configurableFormFields });
const [errors, setErrors] = useState({ ...backedUpFormData.errors });
- const [emailSuggestion, setEmailSuggestion] = useState({ ...backedUpFormData.emailSuggestion });
const [autoSubmitRegisterForm, setAutoSubmitRegisterForm] = useState(false);
const [errorCode, setErrorCode] = useState({ type: '', count: 0 });
const [formStartTime, setFormStartTime] = useState(null);
- const [focusedField, setFocusedField] = useState(null);
const {
providers, currentProvider, secondaryProviders, finishAuthUrl,
} = thirdPartyAuthContext;
const platformName = getConfig().SITE_NAME;
- /**
- * If auto submitting register form, we will check tos and honor code fields if they exist for feature parity.
- */
- const checkTOSandHonorCodeFields = () => {
- if (Object.keys(fieldDescriptions).includes(FIELDS.HONOR_CODE)) {
- setConfigurableFormFields(prevState => ({
- ...prevState,
- [FIELDS.HONOR_CODE]: true,
- }));
- }
- if (Object.keys(fieldDescriptions).includes(FIELDS.TERMS_OF_SERVICE)) {
- setConfigurableFormFields(prevState => ({
- ...prevState,
- [FIELDS.TERMS_OF_SERVICE]: true,
- }));
- }
- };
-
/**
* Set the userPipelineDetails data in formFields for only first time
*/
@@ -130,7 +102,6 @@ const RegistrationPage = (props) => {
if (errorMessage) {
setErrorCode(prevState => ({ type: TPA_AUTHENTICATION_FAILURE, count: prevState.count + 1 }));
} else if (autoSubmitRegForm) {
- checkTOSandHonorCodeFields();
setAutoSubmitRegisterForm(true);
}
if (pipelineUserDetails && Object.keys(pipelineUserDetails).length !== 0) {
@@ -138,13 +109,12 @@ const RegistrationPage = (props) => {
setFormFields(prevState => ({
...prevState, name, username, email,
}));
- setUserPipelineDetailsLoaded(true);
+ dispatch(setUserPipelineDataLoaded(true));
}
}
}, [ // eslint-disable-line react-hooks/exhaustive-deps
thirdPartyAuthContext,
userPipelineDataLoaded,
- setUserPipelineDetailsLoaded,
]);
useEffect(() => {
@@ -154,24 +124,24 @@ const RegistrationPage = (props) => {
if (tpaHint) {
payload.tpa_hint = tpaHint;
}
- getRegistrationDataFromBackend(payload);
+ dispatch(getRegistrationDataFromBackend(payload));
setFormStartTime(Date.now());
}
- }, [formStartTime, getRegistrationDataFromBackend, queryParams, tpaHint]);
+ }, [dispatch, formStartTime, queryParams, tpaHint]);
/**
* Backup the registration form in redux when register page is toggled.
*/
useEffect(() => {
if (shouldBackupState) {
- backupFormState({
+ dispatch(backupRegistrationFormBegin({
+ ...backedUpFormData,
configurableFormFields: { ...configurableFormFields },
formFields: { ...formFields },
- emailSuggestion: { ...emailSuggestion },
errors: { ...errors },
- });
+ }));
}
- }, [shouldBackupState, configurableFormFields, formFields, errors, emailSuggestion, backupFormState]);
+ }, [shouldBackupState, configurableFormFields, formFields, errors, dispatch, backedUpFormData]);
useEffect(() => {
if (backendValidations) {
@@ -185,39 +155,6 @@ const RegistrationPage = (props) => {
}
}, [registrationErrorCode]);
- useEffect(() => {
- if (backendCountryCode && backendCountryCode !== configurableFormFields?.country?.countryCode) {
- let countryCode = '';
- let countryDisplayValue = '';
-
- const selectedCountry = countryList.find(
- (country) => (country[COUNTRY_CODE_KEY].toLowerCase() === backendCountryCode.toLowerCase()),
- );
- if (selectedCountry) {
- countryCode = selectedCountry[COUNTRY_CODE_KEY];
- countryDisplayValue = selectedCountry[COUNTRY_DISPLAY_KEY];
- }
- setConfigurableFormFields(prevState => (
- {
- ...prevState,
- country: {
- countryCode, displayValue: countryDisplayValue,
- },
- }
- ));
- }
- }, [backendCountryCode, countryList]); // eslint-disable-line react-hooks/exhaustive-deps
-
- /**
- * We need to remove the placeholder from the field, adding a space will do that.
- * This is needed because we are placing the username suggestions on top of the field.
- */
- useEffect(() => {
- if (usernameSuggestions.length && !formFields.username) {
- setFormFields(prevState => ({ ...prevState, username: ' ' }));
- }
- }, [usernameSuggestions, formFields]);
-
useEffect(() => {
if (registrationResult.success) {
// Optimizely registration conversion event
@@ -241,210 +178,22 @@ const RegistrationPage = (props) => {
}
}, [registrationResult]);
- const validateInput = (fieldName, value, payload, shouldValidateFromBackend, setError = true) => {
- let fieldError = '';
- let confirmEmailError = ''; // This is to handle the use case where the form contains "confirm email" field
- let countryFieldCode = '';
-
- switch (fieldName) {
- case 'name':
- if (!value.trim()) {
- fieldError = formatMessage(messages['empty.name.field.error']);
- } else if (value && value.match(urlRegex)) {
- fieldError = formatMessage(messages['name.validation.message']);
- } else if (value && !payload.username.trim() && shouldValidateFromBackend) {
- validateFromBackend(payload);
- }
- break;
- case 'email':
- if (!value) {
- fieldError = formatMessage(messages['empty.email.field.error']);
- } else if (value.length <= 2) {
- fieldError = formatMessage(messages['email.invalid.format.error']);
- } else {
- const [username, domainName] = value.split('@');
- // Check if email address is invalid. If we have a suggestion for invalid email
- // provide that along with the error message.
- if (!emailRegex.test(value)) {
- fieldError = formatMessage(messages['email.invalid.format.error']);
- setEmailSuggestion({
- suggestion: getSuggestionForInvalidEmail(domainName, username),
- type: 'error',
- });
- } else {
- const response = validateEmailAddress(value, username, domainName);
- if (response.hasError) {
- fieldError = formatMessage(messages['email.invalid.format.error']);
- delete response.hasError;
- } else if (shouldValidateFromBackend) {
- validateFromBackend(payload);
- }
- setEmailSuggestion({ ...response });
-
- if (configurableFormFields.confirm_email && value !== configurableFormFields.confirm_email) {
- confirmEmailError = formatMessage(messages['email.do.not.match']);
- }
- }
- }
- break;
- case 'username':
- if (!value || value.length <= 1 || value.length > 30) {
- fieldError = formatMessage(messages['username.validation.message']);
- } else if (!value.match(/^[a-zA-Z0-9_-]*$/i)) {
- fieldError = formatMessage(messages['username.format.validation.message']);
- } else if (shouldValidateFromBackend) {
- validateFromBackend(payload);
- }
- break;
- case 'password':
- if (!value || !LETTER_REGEX.test(value) || !NUMBER_REGEX.test(value) || value.length < 8) {
- fieldError = formatMessage(messages['password.validation.message']);
- } else if (shouldValidateFromBackend) {
- validateFromBackend(payload);
- }
- break;
- case 'country':
- if (flags.showConfigurableEdxFields || flags.showConfigurableRegistrationFields) {
- const {
- countryCode, displayValue, error,
- } = validateCountryField(value.displayValue.trim(), countryList, formatMessage(messages['empty.country.field.error']));
- fieldError = error;
- countryFieldCode = countryCode;
- setConfigurableFormFields(prevState => ({ ...prevState, country: { countryCode, displayValue } }));
- }
- break;
- default:
- if (flags.showConfigurableRegistrationFields) {
- if (!value && fieldDescriptions[fieldName]?.error_message) {
- fieldError = fieldDescriptions[fieldName].error_message;
- } else if (fieldName === 'confirm_email' && formFields.email && value !== formFields.email) {
- fieldError = formatMessage(messages['email.do.not.match']);
- }
- }
- break;
- }
- if (setError) {
- setErrors(prevErrors => ({
- ...prevErrors,
- confirm_email: flags.showConfigurableRegistrationFields ? confirmEmailError : '',
- [fieldName]: fieldError,
- }));
- }
- return { fieldError, countryFieldCode };
- };
-
- const isFormValid = (payload, focusedFieldError) => {
- const fieldErrors = { ...errors };
- let isValid = !focusedFieldError;
- Object.keys(payload).forEach(key => {
- if (!payload[key]) {
- fieldErrors[key] = formatMessage(messages[`empty.${key}.field.error`]);
- }
- if (fieldErrors[key]) {
- isValid = false;
- }
- });
-
- if (flags.showConfigurableEdxFields) {
- if (!configurableFormFields.country.displayValue) {
- fieldErrors.country = formatMessage(messages['empty.country.field.error']);
- }
- if (fieldErrors.country) {
- isValid = false;
- }
- }
-
- if (flags.showConfigurableRegistrationFields) {
- Object.keys(fieldDescriptions).forEach(key => {
- if (key === 'country' && !configurableFormFields.country.displayValue) {
- fieldErrors[key] = formatMessage(messages['empty.country.field.error']);
- } else if (!configurableFormFields[key]) {
- fieldErrors[key] = fieldDescriptions[key].error_message;
- }
- if (fieldErrors[key]) {
- isValid = false;
- }
- });
- }
-
- if (focusedField) {
- fieldErrors[focusedField] = focusedFieldError;
- }
- setErrors({ ...fieldErrors });
- return isValid;
- };
-
- const handleSuggestionClick = (event, fieldName, suggestion = '') => {
- event.preventDefault();
- setErrors(prevErrors => ({ ...prevErrors, [fieldName]: '' }));
- switch (fieldName) {
- case 'email':
- setFormFields(prevState => ({ ...prevState, email: emailSuggestion.suggestion }));
- setEmailSuggestion({ suggestion: '', type: '' });
- break;
- case 'username':
- setFormFields(prevState => ({ ...prevState, username: suggestion }));
- props.resetUsernameSuggestions();
- break;
- default:
- break;
- }
- };
-
- const handleEmailSuggestionClosed = () => setEmailSuggestion({ suggestion: '', type: '' });
-
- const handleUsernameSuggestionClosed = () => props.resetUsernameSuggestions();
-
const handleOnChange = (event) => {
const { name } = event.target;
- let value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
+ const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
if (registrationError[name]) {
- clearBackendError(name);
+ dispatch(clearRegistertionBackendError(name));
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
}
- if (name === 'username') {
- if (value.length > 30) {
- return;
- }
- if (value.startsWith(' ')) {
- value = value.trim();
- }
- }
-
setFormFields(prevState => ({ ...prevState, [name]: value }));
};
- const handleOnBlur = (event) => {
- const { name, value } = event.target;
-
- const payload = {
- name: formFields.name,
- email: formFields.email,
- username: formFields.username,
- password: formFields.password,
- form_field_key: name,
- };
-
- setFocusedField(null);
- validateInput(name, name === 'password' ? formFields.password : value, payload, !validationApiRateLimited);
- };
-
- const handleOnFocus = (event) => {
- const { name, value } = event.target;
- setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
- clearBackendError(name);
- // Since we are removing the form errors from the focused field, we will
- // need to rerun the validation for focused field on form submission.
- setFocusedField(name);
-
- if (name === 'username') {
- props.resetUsernameSuggestions();
- // If we added a space character to username field to display the suggestion
- // remove it before user enters the input. This is to ensure user doesn't
- // have a space prefixed to the username.
- if (value === ' ') {
- setFormFields(prevState => ({ ...prevState, [name]: '' }));
- }
+ const handleErrorChange = (fieldName, error) => {
+ if (fieldName) {
+ setErrors(prevErrors => ({
+ ...prevErrors,
+ [fieldName]: error,
+ }));
}
};
@@ -457,42 +206,33 @@ const RegistrationPage = (props) => {
payload.social_auth_provider = currentProvider;
}
- const { fieldError: focusedFieldError, countryFieldCode } = focusedField ? (
- validateInput(
- focusedField,
- (focusedField in fieldDescriptions || ['country', 'marketingEmailsOptIn'].includes(focusedField)) ? (
- configurableFormFields[focusedField]
- ) : formFields[focusedField],
- payload,
- false,
- false,
- )
- ) : '';
+ // Validating form data before submitting
+ const { isValid, fieldErrors } = isFormValid(
+ payload,
+ errors,
+ configurableFormFields,
+ fieldDescriptions,
+ formatMessage,
+ );
+ setErrors({ ...fieldErrors });
- if (!isFormValid(payload, focusedFieldError)) {
+ // returning if not valid
+ if (!isValid) {
setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 }));
return;
}
- Object.keys(configurableFormFields).forEach((fieldName) => {
- if (fieldName === 'country') {
- payload[fieldName] = focusedField === 'country' ? countryFieldCode : configurableFormFields[fieldName].countryCode;
- } else {
- payload[fieldName] = configurableFormFields[fieldName];
- }
- });
+ // Preparing payload for submission
+ payload = prepareRegistrationPayload(
+ payload,
+ configurableFormFields,
+ flags.showMarketingEmailOptInCheckbox,
+ totalRegistrationTime,
+ queryParams);
- // Don't send the marketing email opt-in value if the flag is turned off
- if (!flags.showMarketingEmailOptInCheckbox) {
- delete payload.marketingEmailsOptIn;
- }
-
- payload = snakeCaseObject(payload);
- payload.totalRegistrationTime = totalRegistrationTime;
-
- // add query params to the payload
- payload = { ...payload, ...queryParams };
- props.registerNewUser(payload);
+ // making register call
+ console.log('register payload', payload);
+ dispatch(registerNewUser(payload));
};
const handleSubmit = (e) => {
@@ -548,12 +288,12 @@ const RegistrationPage = (props) => {
context={{ provider: currentProvider, errorMessage: thirdPartyAuthContext.errorMessage }}
/>