Compare commits

..

3 Commits

Author SHA1 Message Date
Syed Sajjad Hussain Shah
e57994bd85 refactor: move fields specific code to their wrappers 2023-08-25 09:48:21 +05:00
Blue
aeec576d8c fix: apply frontend validations (#1041)
Description:
Applied frontend validations on all the fields
VAN-1614
2023-08-24 15:24:00 +05:00
Blue
90db7ba1d8 fix: embedded registration form (#1026)
Description:
Adding embedded registration form
VAN-1574
2023-08-21 12:08:06 +05:00
110 changed files with 3329 additions and 5200 deletions

2
.env
View File

@@ -25,7 +25,7 @@ SEARCH_CATALOG_URL=''
DISABLE_ENTERPRISE_LOGIN=''
ENABLE_DYNAMIC_REGISTRATION_FIELDS=''
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN=''
ENABLE_POST_REGISTRATION_RECOMMENDATIONS=''
ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS=''
MARKETING_EMAILS_OPT_IN=''
SHOW_CONFIGURABLE_EDX_FIELDS=''
# ***** Zendesk related keys *****

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

View File

@@ -1,5 +1,5 @@
export TRANSIFEX_RESOURCE = frontend-app-authn
transifex_langs = "ar,de,de_DE,es_419,fa_IR,fr,fr_CA,hi,it,it_IT,pt,pt_PT,ru,uk,zh_CN"
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA,it_IT,pt_PT,de_DE"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js

222
package-lock.json generated
View File

@@ -18,10 +18,7 @@
"@fortawesome/react-fontawesome": "0.2.0",
"@optimizely/react-sdk": "^2.9.1",
"@redux-devtools/extension": "3.2.5",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"algoliasearch": "^4.14.3",
"algoliasearch-helper": "^3.14.0",
"classnames": "2.3.2",
"core-js": "3.32.0",
"fastest-levenshtein": "1.0.16",
@@ -140,11 +137,6 @@
"@algolia/transporter": "4.19.1"
}
},
"node_modules/@algolia/events": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz",
"integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ=="
},
"node_modules/@algolia/logger-common": {
"version": "4.19.1",
"resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.19.1.tgz",
@@ -5392,158 +5384,6 @@
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@testing-library/dom": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz",
"integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.1.3",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/@testing-library/dom/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/@testing-library/dom/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@testing-library/react": {
"version": "12.1.5",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz",
"integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@testing-library/dom": "^8.0.0",
"@types/react-dom": "<18.0.0"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": "<18.0.0",
"react-dom": "<18.0.0"
}
},
"node_modules/@testing-library/react-hooks": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz",
"integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"react-error-boundary": "^3.1.0"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"@types/react": "^16.9.0 || ^17.0.0",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0",
"react-test-renderer": "^16.9.0 || ^17.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-test-renderer": {
"optional": true
}
}
},
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -5560,11 +5400,6 @@
"node": ">=10.13.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz",
"integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q=="
},
"node_modules/@types/babel__core": {
"version": "7.20.1",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz",
@@ -5830,23 +5665,15 @@
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw=="
},
"node_modules/@types/react": {
"version": "17.0.63",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.63.tgz",
"integrity": "sha512-T+aaG8RlIkgJ4VzWLJYbMW9QX7sIAV8CcuyV6FU6Hm7yu3Bee1YBZQRu2vYEm/dU8kre+/mzl2aGYh5MFgVLaQ==",
"version": "18.2.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.11.tgz",
"integrity": "sha512-+hsJr9hmwyDecSMQAmX7drgbDpyE+EgSF6t7+5QEBAn1tQK7kl1vWZ4iRf6SjQ8lk7dyEULxUmZOIpN0W5baZA==",
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "17.0.20",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.20.tgz",
"integrity": "sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA==",
"dependencies": {
"@types/react": "^17"
}
},
"node_modules/@types/react-redux": {
"version": "7.1.25",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.25.tgz",
@@ -6364,17 +6191,6 @@
"@algolia/transporter": "4.19.1"
}
},
"node_modules/algoliasearch-helper": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.14.0.tgz",
"integrity": "sha512-gXDXzsSS0YANn5dHr71CUXOo84cN4azhHKUbg71vAWnH+1JBiR4jf7to3t3JHXknXkbV0F7f055vUSBKrltHLQ==",
"dependencies": {
"@algolia/events": "^4.0.1"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 6"
}
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@@ -8877,11 +8693,6 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="
},
"node_modules/dom-converter": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -15706,14 +15517,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/mailto-link": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mailto-link/-/mailto-link-2.0.0.tgz",
@@ -18238,21 +18041,6 @@
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-error-boundary": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
"integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=10",
"npm": ">=6"
},
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-error-overlay": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
@@ -18554,7 +18342,7 @@
"version": "16.15.0",
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
"integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==",
"devOptional": true,
"dev": true,
"dependencies": {
"object-assign": "^4.1.1",
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
@@ -18609,7 +18397,7 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz",
"integrity": "sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==",
"devOptional": true,
"dev": true,
"dependencies": {
"object-assign": "^4.1.1",
"react-is": "^17.0.2",

View File

@@ -41,10 +41,7 @@
"@fortawesome/react-fontawesome": "0.2.0",
"@optimizely/react-sdk": "^2.9.1",
"@redux-devtools/extension": "3.2.5",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"algoliasearch": "^4.14.3",
"algoliasearch-helper": "^3.14.0",
"classnames": "2.3.2",
"core-js": "3.32.0",
"fastest-levenshtein": "1.0.16",

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { breakpoints } from '@edx/paragon';
import classNames from 'classnames';
import PropTypes from 'prop-types';
@@ -12,7 +13,10 @@ import {
import { AuthLargeLayout, AuthMediumLayout, AuthSmallLayout } from './components/welcome-page-layout';
import { DEFAULT_LAYOUT, IMAGE_LAYOUT } from './data/constants';
const BaseContainer = ({ children, showWelcomeBanner, username }) => {
const BaseContainer = ({ children, showWelcomeBanner }) => {
const authenticatedUser = showWelcomeBanner ? getAuthenticatedUser() : null;
const username = authenticatedUser ? authenticatedUser.username : null;
const [baseContainerVersion, setBaseContainerVersion] = useState(DEFAULT_LAYOUT);
useEffect(() => {
@@ -34,18 +38,18 @@ const BaseContainer = ({ children, showWelcomeBanner, username }) => {
return (
<div className="layout">
<MediaQuery maxWidth={breakpoints.extraSmall.maxWidth - 1}>
{showWelcomeBanner ? <AuthSmallLayout username={username} /> : <ImageExtraSmallLayout />}
{authenticatedUser ? <AuthSmallLayout username={username} /> : <ImageExtraSmallLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.small.minWidth} maxWidth={breakpoints.small.maxWidth - 1}>
{showWelcomeBanner ? <AuthSmallLayout username={username} /> : <ImageSmallLayout />}
{authenticatedUser ? <AuthSmallLayout username={username} /> : <ImageSmallLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
{showWelcomeBanner ? <AuthMediumLayout username={username} /> : <ImageMediumLayout />}
{authenticatedUser ? <AuthMediumLayout username={username} /> : <ImageMediumLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.extraLarge.minWidth}>
{showWelcomeBanner ? <AuthLargeLayout username={username} /> : <ImageLargeLayout />}
{authenticatedUser ? <AuthLargeLayout username={username} /> : <ImageLargeLayout />}
</MediaQuery>
<div className={classNames('content', { 'align-items-center mt-0': showWelcomeBanner })}>
<div className={classNames('content', { 'align-items-center mt-0': authenticatedUser })}>
{children}
</div>
</div>
@@ -57,15 +61,15 @@ const BaseContainer = ({ children, showWelcomeBanner, username }) => {
<div className="col-md-12 extra-large-screen-top-stripe" />
<div className="layout">
<MediaQuery maxWidth={breakpoints.small.maxWidth - 1}>
{showWelcomeBanner ? <AuthSmallLayout username={username} /> : <DefaultSmallLayout />}
{authenticatedUser ? <AuthSmallLayout username={username} /> : <DefaultSmallLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
{showWelcomeBanner ? <AuthMediumLayout username={username} /> : <DefaultMediumLayout />}
{authenticatedUser ? <AuthMediumLayout username={username} /> : <DefaultMediumLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.extraLarge.minWidth}>
{showWelcomeBanner ? <AuthLargeLayout username={username} /> : <DefaultLargeLayout />}
{authenticatedUser ? <AuthLargeLayout username={username} /> : <DefaultLargeLayout />}
</MediaQuery>
<div className={classNames('content', { 'align-items-center mt-0': showWelcomeBanner })}>
<div className={classNames('content', { 'align-items-center mt-0': authenticatedUser })}>
{children}
</div>
</div>
@@ -75,13 +79,11 @@ const BaseContainer = ({ children, showWelcomeBanner, username }) => {
BaseContainer.defaultProps = {
showWelcomeBanner: false,
username: null,
};
BaseContainer.propTypes = {
children: PropTypes.node.isRequired,
showWelcomeBanner: PropTypes.bool,
username: PropTypes.string,
};
export default BaseContainer;

View File

@@ -1,5 +1,4 @@
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -12,92 +11,31 @@ import PropTypes from 'prop-types';
import messages from './messages';
import { LETTER_REGEX, NUMBER_REGEX } from '../data/constants';
import { clearRegistrationBackendError, fetchRealtimeValidations } from '../register/data/actions';
import { validatePasswordField } from '../register/data/utils';
const PasswordField = (props) => {
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const validationApiRateLimited = useSelector(state => state.register.validationApiRateLimited);
const [isPasswordHidden, setHiddenTrue, setHiddenFalse] = useToggle(true);
const [showTooltip, setShowTooltip] = useState(false);
const handleBlur = (e) => {
const { name, value } = e.target;
if (name === props.name && e.relatedTarget?.name === 'passwordIcon') {
return; // Do not run validations on password icon click
}
let passwordValue = value;
if (name === 'passwordIcon') {
// To validate actual password value when onBlur is triggered by focusing out the password icon
passwordValue = props.value;
}
if (props.handleBlur) {
props.handleBlur({
target: {
name: props.name,
value: passwordValue,
},
});
}
if (props.handleBlur) { props.handleBlur(e); }
setShowTooltip(props.showRequirements && false);
if (props.handleErrorChange) { // If rendering from register page
const fieldError = validatePasswordField(passwordValue, formatMessage);
if (fieldError) {
props.handleErrorChange('password', fieldError);
} else if (!validationApiRateLimited) {
dispatch(fetchRealtimeValidations({ password: passwordValue }));
}
}
};
const handleFocus = (e) => {
if (e.target?.name === 'passwordIcon') {
return; // Do not clear error on password icon focus
}
if (props.handleFocus) {
props.handleFocus(e);
}
if (props.handleErrorChange) {
props.handleErrorChange('password', '');
dispatch(clearRegistrationBackendError('password'));
}
setTimeout(() => setShowTooltip(props.showRequirements && true), 150);
};
const HideButton = (
<IconButton
onFocus={handleFocus}
onBlur={handleBlur}
name="passwordIcon"
src={VisibilityOff}
iconAs={Icon}
onClick={setHiddenTrue}
size="sm"
variant="secondary"
alt={formatMessage(messages['hide.password'])}
/>
<IconButton onFocus={handleFocus} onBlur={handleBlur} name="password" src={VisibilityOff} iconAs={Icon} onClick={setHiddenTrue} size="sm" variant="secondary" alt={formatMessage(messages['hide.password'])} />
);
const ShowButton = (
<IconButton
onFocus={handleFocus}
onBlur={handleBlur}
name="passwordIcon"
src={Visibility}
iconAs={Icon}
onClick={setHiddenFalse}
size="sm"
variant="secondary"
alt={formatMessage(messages['show.password'])}
/>
<IconButton onFocus={handleFocus} onBlur={handleBlur} name="password" src={Visibility} iconAs={Icon} onClick={setHiddenFalse} size="sm" variant="secondary" alt={formatMessage(messages['show.password'])} />
);
const placement = window.innerWidth < 768 ? 'top' : 'left';
const tooltip = (
<Tooltip id={`password-requirement-${placement}`}>
@@ -151,7 +89,6 @@ PasswordField.defaultProps = {
handleBlur: null,
handleFocus: null,
handleChange: () => {},
handleErrorChange: null,
showRequirements: true,
autoComplete: null,
};
@@ -163,7 +100,6 @@ PasswordField.propTypes = {
handleBlur: PropTypes.func,
handleFocus: PropTypes.func,
handleChange: PropTypes.func,
handleErrorChange: PropTypes.func,
name: PropTypes.string.isRequired,
showRequirements: PropTypes.bool,
value: PropTypes.string.isRequired,

View File

@@ -9,7 +9,6 @@ import { setCookie } from '../data/utils';
const RedirectLogistration = (props) => {
const {
authenticatedUser,
finishAuthUrl,
redirectUrl,
redirectToProgressiveProfilingPage,
@@ -44,7 +43,6 @@ const RedirectLogistration = (props) => {
state={{
registrationResult,
optionalFields,
authenticatedUser,
}}
replace
/>
@@ -74,7 +72,6 @@ const RedirectLogistration = (props) => {
};
RedirectLogistration.defaultProps = {
authenticatedUser: {},
educationLevel: null,
finishAuthUrl: null,
success: false,
@@ -86,7 +83,6 @@ RedirectLogistration.defaultProps = {
};
RedirectLogistration.propTypes = {
authenticatedUser: PropTypes.shape({}),
educationLevel: PropTypes.string,
finishAuthUrl: PropTypes.string,
success: PropTypes.bool,

View File

@@ -2,16 +2,13 @@ import { THIRD_PARTY_AUTH_CONTEXT, THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG } fr
import { COMPLETE_STATE, FAILURE_STATE, PENDING_STATE } from '../../data/constants';
export const defaultState = {
fieldDescriptions: {
fields: {},
},
fieldDescriptions: {},
optionalFields: {
fields: {},
extended_profile: [],
},
thirdPartyAuthApiStatus: null,
thirdPartyAuthContext: {
autoSubmitRegForm: false,
currentProvider: null,
finishAuthUrl: null,
countryCode: null,

View File

@@ -58,7 +58,7 @@ describe('common components reducer', () => {
providers: [],
secondaryProviders: [],
pipelineUserDetails: null,
errorMessage: 'An error occurred',
errorMessage: 'An error occured',
},
};

View File

@@ -1,13 +1,9 @@
import React from 'react';
import { Provider } from 'react-redux';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { fetchRealtimeValidations } from '../../register/data/actions';
import FormGroup from '../FormGroup';
import PasswordField from '../PasswordField';
@@ -30,27 +26,10 @@ describe('FormGroup', () => {
});
describe('PasswordField', () => {
const mockStore = configureStore();
const IntlPasswordField = injectIntl(PasswordField);
let props = {};
let store = {};
const reduxWrapper = children => (
<IntlProvider locale="en">
<MemoryRouter>
<Provider store={store}>{children}</Provider>
</MemoryRouter>
</IntlProvider>
);
const initialState = {
register: {
validationApiRateLimited: false,
},
};
beforeEach(() => {
store = mockStore(initialState);
props = {
floatingLabel: 'Password',
name: 'password',
@@ -60,7 +39,7 @@ describe('PasswordField', () => {
});
it('should show/hide password on icon click', () => {
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
const passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
passwordField.find('button[aria-label="Show password"]').simulate('click');
expect(passwordField.find('input').prop('type')).toEqual('text');
@@ -70,7 +49,7 @@ describe('PasswordField', () => {
});
it('should show password requirement tooltip on focus', async () => {
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
const passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
jest.useFakeTimers();
await act(async () => {
passwordField.find('input').simulate('focus');
@@ -88,7 +67,7 @@ describe('PasswordField', () => {
};
jest.useFakeTimers();
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
const passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
await act(async () => {
passwordField.find('input').simulate('focus');
jest.runAllTimers();
@@ -101,7 +80,7 @@ describe('PasswordField', () => {
});
it('should update password requirement checks', async () => {
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
const passwordField = mount(<IntlProvider locale="en"><IntlPasswordField {...props} /></IntlProvider>);
jest.useFakeTimers();
await act(async () => {
passwordField.find('input').simulate('focus');
@@ -113,142 +92,4 @@ describe('PasswordField', () => {
expect(passwordField.find('#number-check span').prop('className')).toEqual('pgn__icon text-success mr-1');
expect(passwordField.find('#characters-check span').prop('className')).toEqual('pgn__icon text-success mr-1');
});
it('should not run validations when blur is fired on password icon click', () => {
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
passwordField.find('button[aria-label="Show password"]').simulate('blur', {
target: {
name: 'password',
value: 'invalid',
},
relatedTarget: {
name: 'passwordIcon',
},
});
expect(passwordField.find('div[feedback-for="password"]').exists()).toBeFalsy();
});
it('should call props handle blur if available', () => {
props = {
...props,
handleBlur: jest.fn(),
};
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
passwordField.find('input#password').simulate('blur', {
target: {
name: 'password',
value: '',
},
});
expect(props.handleBlur).toHaveBeenCalledTimes(1);
});
it('should run validations on blur event when rendered from register page', () => {
props = {
...props,
handleErrorChange: jest.fn(),
};
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
passwordField.find('input#password').simulate('blur', {
target: {
name: 'password',
value: '',
},
});
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'password',
'Password criteria has not been met',
);
});
it('should not clear error when focus is fired on password icon click when rendered from register page', () => {
props = {
...props,
handleErrorChange: jest.fn(),
};
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
passwordField.find('button[aria-label="Show password"]').simulate('focus', {
target: {
name: 'passwordIcon',
value: '',
},
});
expect(props.handleErrorChange).toHaveBeenCalledTimes(0);
});
it('should clear error when focus is fired on password icon click when rendered from register page', () => {
props = {
...props,
handleErrorChange: jest.fn(),
};
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
passwordField.find('button[aria-label="Show password"]').simulate('focus', {
target: {
name: 'password',
value: 'invalid',
},
});
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'password',
'',
);
});
it('should run backend validations when frontend validations pass on blur when rendered from register page', () => {
store.dispatch = jest.fn(store.dispatch);
props = {
...props,
handleErrorChange: jest.fn(),
};
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
passwordField.find('button[aria-label="Show password"]').simulate('blur', {
target: {
name: 'password',
value: 'password123',
},
});
expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations({ password: 'password123' }));
});
it('should use password value from prop when password icon is focused out (blur due to icon)', () => {
store.dispatch = jest.fn(store.dispatch);
props = {
...props,
value: 'testPassword',
handleErrorChange: jest.fn(),
handleBlur: jest.fn(),
};
const passwordField = mount(reduxWrapper(<IntlPasswordField {...props} />));
passwordField.find('button[aria-label="Show password"]').simulate('blur', {
target: {
name: 'passwordIcon',
value: undefined,
},
});
expect(props.handleBlur).toHaveBeenCalledTimes(1);
expect(props.handleBlur).toHaveBeenCalledWith({
target: {
name: 'password',
value: 'testPassword',
},
});
});
});

View File

@@ -6,7 +6,7 @@ const configuration = {
DISABLE_ENTERPRISE_LOGIN: process.env.DISABLE_ENTERPRISE_LOGIN || '',
ENABLE_DYNAMIC_REGISTRATION_FIELDS: process.env.ENABLE_DYNAMIC_REGISTRATION_FIELDS || false,
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: process.env.ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN || false,
ENABLE_POST_REGISTRATION_RECOMMENDATIONS: process.env.ENABLE_POST_REGISTRATION_RECOMMENDATIONS || false,
ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS: process.env.ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS || false,
MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '',
SHOW_CONFIGURABLE_EDX_FIELDS: process.env.SHOW_CONFIGURABLE_EDX_FIELDS || false,
// Links
@@ -26,12 +26,12 @@ const configuration = {
BANNER_IMAGE_EXTRA_SMALL: process.env.BANNER_IMAGE_EXTRA_SMALL || '',
// Recommendation constants
GENERAL_RECOMMENDATIONS: process.env.GENERAL_RECOMMENDATIONS || '[]',
POPULAR_PRODUCTS: process.env.POPULAR_PRODUCTS || '[]',
TRENDING_PRODUCTS: process.env.TRENDING_PRODUCTS || '[]',
// Miscellaneous
INFO_EMAIL: process.env.INFO_EMAIL || '',
ZENDESK_KEY: process.env.ZENDESK_KEY,
ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL,
ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '',
ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '',
};
export default configuration;

View File

@@ -1,20 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import algoliasearch from 'algoliasearch';
// initialize Algolia workers
const initializeSearchClient = () => algoliasearch(
getConfig().ALGOLIA_APP_ID,
getConfig().ALGOLIA_SEARCH_API_KEY,
);
const getLocationRestrictionFilter = (userCountry) => {
if (userCountry) {
return `NOT blocked_in:"${userCountry}" AND (allowed_in:"null" OR allowed_in:"${userCountry}")`;
}
return '';
};
export {
initializeSearchClient,
getLocationRestrictionFilter,
};

View File

@@ -26,12 +26,15 @@ export const FAILURE_STATE = 'failure';
export const FORBIDDEN_STATE = 'forbidden';
export const EMBEDDED = 'embedded';
export const LETTER_REGEX = /[a-zA-Z]/;
export const NUMBER_REGEX = /\d/;
// Regex
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 LETTER_REGEX = /[a-zA-Z]/;
export const USERNAME_REGEX = /^[a-zA-Z0-9_-]*$/i;
export const NUMBER_REGEX = /\d/;
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
// Query string parameters that can be passed to LMS to manage
// things like auto-enrollment upon login and registration.

View File

@@ -1,3 +0,0 @@
const isOneTrustFunctionalCookieEnabled = () => !!window?.OnetrustActiveGroups?.includes('C0003');
export default isOneTrustFunctionalCookieEnabled;

View File

@@ -1,16 +0,0 @@
import { getLocationRestrictionFilter } from '../algolia';
describe('algoliaUtilsTests', () => {
it('test getLocationRestrictionFilter returns filter if country is passed', () => {
const countryCode = 'PK';
const filter = getLocationRestrictionFilter(countryCode);
const expectedFilter = `NOT blocked_in:"${countryCode}" AND (allowed_in:"null" OR allowed_in:"${countryCode}")`;
expect(filter).toEqual(expectedFilter);
});
it('test getLocationRestrictionFilter returns empty string if country is not passed', () => {
const countryCode = '';
const filter = getLocationRestrictionFilter(countryCode);
const expectedFilter = '';
expect(filter).toEqual(expectedFilter);
});
});

View File

@@ -1,5 +1,5 @@
import { updatePathWithQueryParams } from './dataUtils';
import { LOGIN_PAGE } from '../constants';
import { updatePathWithQueryParams } from '../utils/dataUtils';
describe('updatePathWithQueryParams', () => {
it('should append query params into the path', () => {

View File

@@ -1,4 +1,4 @@
import AsyncActionType from '../utils/reduxUtils';
import AsyncActionType from './reduxUtils';
describe('AsyncActionType', () => {
it('should return well formatted action strings', () => {

View File

@@ -2,37 +2,35 @@ import { messages as paragonMessages } from '@edx/paragon';
import arMessages from './messages/ar.json';
import deMessages from './messages/de.json';
import deDEMessages from './messages/de_DE.json';
import dedeCAMessages from './messages/de_DE.json';
import es419Messages from './messages/es_419.json';
import faIRMessages from './messages/fa_IR.json';
import frMessages from './messages/fr.json';
import frCAMessages from './messages/fr_CA.json';
import hiMessages from './messages/hi.json';
import itMessages from './messages/it.json';
import itITMessages from './messages/it_IT.json';
import ititCAMessages from './messages/it_IT.json';
import ptMessages from './messages/pt.json';
import ptPTMessages from './messages/pt_PT.json';
import ptptCAMessages from './messages/pt_PT.json';
import ruMessages from './messages/ru.json';
import ukMessages from './messages/uk.json';
import zhCNMessages from './messages/zh_CN.json';
import zhcnMessages from './messages/zh_CN.json';
// no need to import en messages-- they are in the defaultMessage field
const appMessages = {
ar: arMessages,
de: deMessages,
'de-de': deDEMessages,
'es-419': es419Messages,
'fa-ir': faIRMessages,
fr: frMessages,
'fr-ca': frCAMessages,
hi: hiMessages,
it: itMessages,
'it-it': itITMessages,
'zh-cn': zhcnMessages,
pt: ptMessages,
'pt-pt': ptPTMessages,
it: itMessages,
de: deMessages,
hi: hiMessages,
'fr-ca': frCAMessages,
ru: ruMessages,
uk: ukMessages,
'zh-cn': zhCNMessages,
'de-de': dedeCAMessages,
'it-it': ititCAMessages,
'pt-pt': ptptCAMessages,
};
export default [

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "التوصيات | {siteName}",
"recommendation.page.heading": "لدينا بعض التوصيات لكي تبدأ.",
"recommendation.skip.button": "التخطي مؤقتا",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "التسجيل | {siteName}",
"registration.fullname.label": "الاسم الكامل",
"registration.email.label": "البريد الإلكتروني",
@@ -135,7 +126,6 @@
"help.text.email": "لتفعيل الحساب و التحديثات الهامة",
"create.account.for.free.button": "إنشاء حساب مجانا",
"registration.other.options.heading": "أو سجل باستخدام:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "بيانات المؤسسة / الجامعة",
"register.institution.login.page.title": "التسجيل باستخدام بيانات المؤسسة / الجامعة",
"empty.name.field.error": "أدخل اسمك الكامل",

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "Recommendations | {siteName}",
"recommendation.page.heading": "We have a few recommendations to get you started.",
"recommendation.skip.button": "Skip for now",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "Register | {siteName}",
"registration.fullname.label": "Full name",
"registration.email.label": "Email",
@@ -135,7 +126,6 @@
"help.text.email": "For account activation and important updates",
"create.account.for.free.button": "Create an account for free",
"registration.other.options.heading": "Or register with:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Institution/campus credentials",
"register.institution.login.page.title": "Register with institution/campus credentials",
"empty.name.field.error": "Enter your full name",

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "Empfehlungen | {siteName}",
"recommendation.page.heading": "Wir haben ein paar Empfehlungen für den Einstieg.",
"recommendation.skip.button": "Überspringen",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "Registrieren | {siteName}",
"registration.fullname.label": "Vollständiger Name",
"registration.email.label": "E-Mail-Adresse",
@@ -135,7 +126,6 @@
"help.text.email": "Für die Kontoaktivierung und wichtige Updates",
"create.account.for.free.button": "Erstellen Sie kostenlos ein Benutzerkonto",
"registration.other.options.heading": "Oder registrieren Sie sich bei:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Zeugnisse der Institution/des Campus",
"register.institution.login.page.title": "Registrieren Sie sich mit Institutions-/Campus-Anmeldeinformationen",
"empty.name.field.error": "Geben Sie Ihren vollständigen Namen ein",

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "Recomendaciones | {siteName}",
"recommendation.page.heading": "Tenemos algunas recomendaciones para empezar.",
"recommendation.skip.button": "Saltar por ahora ",
"recommendation.option.trending": "Tendencias",
"recommendation.option.popular": "Más popular",
"recommendation.product-card.pill-text.course": "Curso",
"recommendation.product-card.pill-text.professional-certificate": "Certificado profesional",
"recommendation.product-card.pill-text.emeritus": "Ofrecido en Emeritus",
"recommendation.product-card.pill-text.shorelight": "Ofrecido a través de Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Suscripción",
"recommendation.product-card.launch-icon.sr-text": "Abrir un enlace en una pestaña nueva",
"register.page.title": "Register | {siteName}",
"registration.fullname.label": "Nombre completo",
"registration.email.label": "Correo electrónico",
@@ -135,7 +126,6 @@
"help.text.email": "Para la activación de la cuenta y las actualizaciones importantes",
"create.account.for.free.button": "Crea una cuenta gratis",
"registration.other.options.heading": "O regístrese con:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Credenciales de la institución/campus",
"register.institution.login.page.title": "Registro con credenciales de la institución/campus",
"empty.name.field.error": "Introduce tu nombre completo",

View File

@@ -1,180 +0,0 @@
{
"start.learning": "آغاز یادگیری",
"with.site.name": "با {siteName}",
"your.career.turning.point": "نقطه عطف حرفه ای شما",
"is.here": "اینجاست.",
"welcome.to.platform": "خوش آمدید به {siteName}, {username}!",
"complete.your.profile.1": "کامل",
"complete.your.profile.2": "پرونده کاربری شما",
"institution.login.page.sub.heading": "موسسه خود را از فهرست زیر برگزینید",
"logistration.sign.in": "ورود",
"logistration.register": "ثبت‌نام",
"enterprisetpa.title.heading": "آیا می‌خواهید با استفاده از اطلاعات کاربری {providerName} خود وارد سامانه شوید؟",
"enterprisetpa.login.button.text": "راه‌های دیگری برای ورود به سامانه یا ثبت‌نام به من نشان بده",
"enterprisetpa.login.button.text.public.account.creation.disabled": "راه های دیگری را برای ورود به سیستم به من نشان دهید",
"sso.sign.in.with": "با {providerName} وارد شوید",
"sso.create.account.using": "با استفاده از {providerName} حساب کاربری بسازید",
"show.password": "نمایش گذرواژه",
"hide.password": "پنهان‌سازی گذرواژه",
"one.letter": "1 نامه",
"one.number": "1 رقم",
"eight.characters": "8 نویسه",
"password.sr.only.helping.text": "گذرواژه باید حداقل 8 نویسه، حداقل یک حرف و حداقل یک عدد داشته باشد",
"tpa.alert.heading": "تقریبا تمام شد!",
"login.third.party.auth.account.not.linked": "شما با موفقیت به {currentProvider} وارد شدید، اما حساب کاربری {currentProvider} شما به حساب کاربری {platformName} پیوند ندارد. برای پیوند حساب‌های کاربری خود، اکنون با استفاده از گذرواژه {platformName} خود وارد شوید.",
"register.third.party.auth.account.not.linked": "شما با موفقیت به {currentProvider} وارد شدید! پیش از آغاز یادگیری با {platformName} فقط به کمی اطلاعات بیشتر نیاز داریم.",
"registration.using.tpa.form.heading": "ساخت حساب کاربری خود را به اتمام برسانید",
"zendesk.supportTitle": "پشتیبانی edX",
"zendesk.selectTicketForm": "لطفا نوع درخواست خود را انتخاب کنید:",
"error.notfound.message": "صفحه مورد نظر شما در دسترس نیست یا خطایی در نشانی اینترنتی وجود دارد. لطفاً نشانی اینترنتی را بررسی کرده و دوباره تلاش کنید.",
"forgot.password.confirmation.message": "ما رایانامه‌ای به‌همراه دستورالعمل بازنشانی گذرواژه به {email} ارسال کردیم. اگر پس از 1 دقیقه پیام بازنشانی را دریافت نکردید، بررسی کنید که نشانی رایانامه را صحیح وارد کرده‌اید یا پوشه هرزنامه خود را بررسی کنید. اگر به دریافت راهنمایی بیشتری، با {supportLink} تماس بگیرید.",
"forgot.password.page.title": "فراموش گذرواژه | {siteName}",
"forgot.password.page.heading": "بازتنظیم گذرواژه",
"forgot.password.page.instructions": "لطفا نشانی رایانامه خود را در قسمت زیر وارد کنید و ما رایانامه‌ای حاوی دستورالعمل نحوه بازتنظیم مجدد گذرواژه برای شما ارسال خواهیم کرد.",
"forgot.password.page.invalid.email.message": "نشانی رایانامه معتبری را وارد کنید",
"forgot.password.page.email.field.label": "رایانامه",
"forgot.password.page.submit.button": "ارسال",
"forgot.password.error.alert.title.": "ما نتوانستیم با شما تماس بگیریم.",
"forgot.password.error.message.title": "خطایی رخ داد.",
"forgot.password.request.in.progress.message": "درخواست قبلی شما در حال انجام است، لطفا چند لحظه دیگر دوباره تلاش کنید.",
"forgot.password.empty.email.field.error": "نشانی رایانامه خود را وارد کنید",
"forgot.password.email.help.text": "نشانی رایانامه‌ای که برای ثبت‌نام در {platformName} استفاده کردید",
"confirmation.message.title": "صندوق رایانامه خود را ببینید",
"confirmation.support.link": "با پشتیبانی فنی تماس بگیرید",
"need.help.sign.in.text": "برای ورود به سامانه نیاز به کمک دارید؟",
"additional.help.text": "برای دریافت راهنمایی بیشتر، با پشتیبانی {platformName} در این آدرس تماس بگیرید",
"sign.in.text": "ورود",
"extend.field.errors": "{emailError} زیر.",
"invalid.token.heading": "پیوند بازتنظیم گذرواژه معتبر نیست",
"invalid.token.error.message": "این پیوند برای بازتنظیم گذرواژه معتبر نیست. ممکن است قبلاً استفاده شده باشد. برای دریافت پیوند جدید نشانی رایانامه خود را در زیر وارد کنید.",
"token.validation.rate.limit.error.heading": "تعداد زیاد درخواست",
"token.validation.rate.limit.error": "به دلیل درخواست‌های زیاد، خطایی روی داده است. لطفا بعد از مدتی دوباره امتحان کنید.",
"token.validation.internal.sever.error.heading": "اعتبارسنجی رمز انجام نشد",
"token.validation.internal.sever.error": "خطایی رخ داده است. صفحه را دوباره بارگیری کنید یا اتصال اینترنت خود را بررسی کنید.",
"internal.server.error": "خطایی رخ داده است. صفحه را دوباره بارگیری کنید یا اتصال اینترنت خود را بررسی کنید.",
"account.activation.error.message": "اشتباهی رخ داد، لطفاً برای حل این مساله ، به این قسمت{supportLink} مراجعه کنید.",
"login.inactive.user.error": "برای ورود به سامانه، باید حساب کاربری خود را فعال کنید.{lineBreak} {lineBreak}هم اکنون پیوند فعالسازی را به {email} فرستادیم. اگر رایانامه‌ای دریافت نکردید، پوشه‌های هرزنامه یا {supportLink} را بررسی کنید.",
"allowed.domain.login.error": "به عنوان کاربر {allowedDomain}، باید با {allowedDomain} {tpaLink} خود وارد شوید.",
"login.incorrect.credentials.error.attempts.text.1": "نام کاربری، نشانی رایانامه یا گذرواژه‌ای که وارد کرده‌اید نادرست است. پیش از اینکه حساب کاربری شما موقتا قفل شود، {remainingAttempts} تلاش دیگر برای ورود به سامانه دارید.",
"login.incorrect.credentials.error.attempts.text.2": "اگر گذرواژه خود را فراموش کرده‌اید، {resetLink}",
"account.locked.out.message.2": "برای حفظ امنیت، می‌توانید پیش از تلاش مجدد، {resetLink} را انجام دهید.",
"login.incorrect.credentials.error.with.reset.link": "نام کاربری، نشانی رایانامه یا گذرواژه‌ای که وارد کردید درست نیست. لطفاً دوباره امتحان کنید یا {resetLink}.",
"login.page.title": "ورود به سامانه | {siteName}",
"login.user.identity.label": "نام کاربری یا نشانی رایانامه",
"login.password.label": "گذرواژه",
"sign.in.button": "ورود به سامانه",
"forgot.password": "فراموشی گذرواژه",
"institution.login.button": "اعتبارنامه‌های موسسه/پردیس",
"institution.login.page.title": "با اعتبار موسسه/پردیس وارد شوید",
"login.other.options.heading": "یا وارد شوید با:",
"non.compliant.password.title": "ما اخیراً الزامات گذرواژه خود را تغییر دادیم",
"non.compliant.password.message": "گذرواژه فعلی شما الزامات امنیتی جدید را برآورده نمی‌کند. ما فقط یک پیام بازتنظیم گذرواژه به نشانی رایانامه مرتبط با این حساب کاربری ارسال کردیم. از اینکه به ما کمک می‌کنید تا داده‌های شما را ایمن نگه دارید متشکریم.",
"account.locked.out.message.1": "حساب کاربری شما، به دلیل حفاظت، به‌طور موقت قفل شده است. 30 دقیقه دیگر دوباره امتحان کنید.",
"enterprise.login.btn.text": "اعتبار دانشکده یا شرکت",
"username.or.email.format.validation.less.chars.message": "نام کاربری یا نشانی رایانامه حداقل باید 3 نویسه داشته باشد",
"email.validation.message": "نام کاربری یا رایانامه خود را وارد کنید",
"password.validation.message": "معیارهای گذرواژه رعایت نشده است",
"account.activation.success.message.title": "موفق شدید! شما حساب کاربری خود را فعال کردید.",
"account.activation.success.message": "اکنون رایانامه‌های مربوط به روزآمدسازی‌ها و هشدارها درباره دوره‌های آموزشی را که در آن ثبت‌نام کرده‌اید از ما دریافت خواهید کرد. برای ادامه وارد شوید.",
"account.activation.info.message": "حساب کاربری مورد نظر شما قبلاً فعال شده است.",
"account.activation.error.message.title": "امکان فعالسازی حساب کاربری شما نبود",
"account.activation.support.link": "تماس با پشتیبانی ",
"account.confirmation.success.message.title": "موفق شدید! نشانی رایانامه خود را تایید کردید.",
"account.confirmation.success.message": "برای ادامه وارد سامانه شوید.",
"account.confirmation.info.message": "این نشانی رایانامه قبلا تایید شده‌است.",
"account.confirmation.error.message.title": "امکان تایید نشانی رایانامه شما وجود ندارد",
"tpa.account.link": "حساب {provider}",
"internal.server.error.message": "خطایی رخ داده است. صفحه را دوباره بارگیری کنید یا اتصال اینترنت خود را بررسی کنید.",
"login.rate.limit.reached.message": "شما برای ورود به حساب کاربری چند بار تلاش ناموفق داشتید. لطفا بعدا تلاش نمایید.",
"login.failure.header.title": "قادر نیستیم شما را به سامانه وارد کنیم.",
"contact.support.link": "با پشتیبانی {platformName} تماس بگیرید",
"login.incorrect.credentials.error": "نام کاربری، نشانی رایانامه یا گذرواژه‌ای که وارد کردید نادرست است. لطفا دوباره تلاش کنید.",
"login.form.invalid.error.message": "لطفا قسمت‌های زیر را پر کنید.",
"login.incorrect.credentials.error.reset.link.text": "گذرواژه را بازتنظیم کنید",
"login.incorrect.credentials.error.before.account.blocked.text": "برای بازتنظیم اینجا بزنید",
"password.security.nudge.title": "امنیت گذرواژه",
"password.security.block.title": "تغییر گذرواژه ضروری است",
"password.security.nudge.body": "سامانه ما تشخیص داده است که گذرواژه شما آسیب‌پذیر است. توصیه ما این است که آن را تغییر دهید تا حساب کاربری شما ایمن بماند.",
"password.security.block.body": "سامانه ما تشخیص داده است که گذرواژه شما ضعیف و آسیب‌پذیر است. گذرواژه خود را تغییر دهید تا حساب کاربری شما ایمن بماند.",
"password.security.close.button": "بستن",
"password.security.redirect.to.reset.password.button": "بازتنظیم گذرواژه",
"login.tpa.authentication.failure": "متأسفیم، شما مجاز به دسترسی به {platform_name} از طریق این کانال نیستید. لطفاً برای دسترسی به {platform_name} با سرپرست یا مدیر آموزشی خود تماس بگیرید.{lineBreak}{lineBreak}جزئیات خطا:{lineBreak}{errorMessage}",
"progressive.profiling.page.title": "خوش آمدید | {siteName}",
"progressive.profiling.page.heading": "چند سوال برای شما به ما کمک خواهد کرد تا باهوش‌تر شویم.",
"optional.fields.information.link": "درباره نحوه استفاده ما از این اطلاعات بیشتر بدانید.",
"optional.fields.submit.button": "ارسال",
"optional.fields.skip.button": "فعلا بگذرید",
"optional.fields.next.button": "بعدی",
"continue.to.platform": "ادامه در {platformName}",
"modal.title": "از اینکه اطلاع دادید تشکر می‌کنیم.",
"modal.description": "در صورت تغییر تصمیم‌تان، شما هر زمانی این امکان را دارید که پرونده کاربری خود را در قسمت تنظیمات تکمیل کنید. ",
"welcome.page.error.heading": "امکان روزآمدسازی پرونده کاربری شما نیست",
"welcome.page.error.message": "خطایی رخ داد. می‌توانید پرونده کاربری خود را هر زمان در قسمت تنظیمات تکمیل کنید.",
"recommendation.page.title": "توصیه ها | {siteName}",
"recommendation.page.heading": "ما چند توصیه برای شروع کار داریم.",
"recommendation.skip.button": "فعلا بگذرید",
"recommendation.option.trending": "پرطرفدار",
"recommendation.option.popular": "محبوبترین",
"recommendation.product-card.pill-text.course": "دوره آموزشی",
"recommendation.product-card.pill-text.professional-certificate": "گواهی حرفه‌ای",
"recommendation.product-card.pill-text.emeritus": "ارائه شده در Emeritus",
"recommendation.product-card.pill-text.shorelight": "از طریق Shorelight ارائه می شود",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "اشتراک",
"recommendation.product-card.launch-icon.sr-text": "پیوندی را در یک برگه جدید باز می کند",
"register.page.title": "ثبت‌نام | {siteName}",
"registration.fullname.label": "نام و نام خانوادگی",
"registration.email.label": "رایانامه",
"registration.username.label": "نام‌کاربری عمومی",
"registration.password.label": "گذرواژه",
"registration.country.label": "کشور/منطقه",
"registration.opt.in.label": "با ارسال پیام‌های بازرگانی از سوی {siteName} موافقم.",
"help.text.name": "این نام در هر گواهی که بدست آورید استفاده خواهد شد.",
"help.text.username.1": "نامی که شما را در دوره‌های آموزشی با آن شناخته خواهید شد.",
"help.text.username.2": "این مورد بعدا قابل تغییر نیست.",
"help.text.email": "برای فعال‌سازی حساب کاربری و روزآمدسازی‌های مهم",
"create.account.for.free.button": "یک حساب کاربری رایگان بسازید",
"registration.other.options.heading": "یا ثبت‌نام کنید با:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "اعتبارنامه‌های موسسه/پردیس",
"register.institution.login.page.title": "با اعتبارنامه موسسه/پردیس ثبت‌نام کنید",
"empty.name.field.error": "نام و نام خانوادگی خود را وارد کنید",
"empty.email.field.error": "نشانی رایانامه خود را وارد کنید",
"empty.username.field.error": "نام کاربری باید بین 2 تا 30 نویسه داشته باشد",
"empty.password.field.error": "معیارهای گذرواژه رعایت نشده است",
"empty.country.field.error": "کشور یا منطقه محل سکونت خود را برگزینید",
"email.do.not.match": "نشانی‌های رایانامه همخوانی ندارند.",
"email.invalid.format.error": "نشانی رایانامه معتبر وارد کنید",
"username.validation.message": "نام کاربری باید بین 2 تا 30 نویسه داشته باشد",
"name.validation.message": "نامی معتبر وارد کنید",
"username.format.validation.message": "نام کاربری فقط می‌تواند شامل حروف (A-Z، a-z)، اعداد (0-9)، خط زیر (_) و خط فاصله (-) باشد. نام کاربری نمی‌تواند حاوی فاصله باشد",
"registration.request.failure.header": "موفق به ایجاد حساب کاربری شما نشدیم.",
"registration.empty.form.submission.error": "لطفاً پاسخ‌های خود را بررسی کرده و دوباره امتحان کنید.",
"registration.request.server.error": "خطایی رخ داده است. صفحه را دوباره بارگیری کنید یا اتصال اینترنت خود را بررسی کنید.",
"registration.rate.limit.error": "تعداد دفعات تلاش ناموفق برای ثبت‌نام بسیار بوده است. بعدا دوباره تلاش کنید.",
"registration.tpa.session.expired": "مهلت ثبت‌نام {provider} به پایان رسیده‌است.",
"registration.tpa.authentication.failure": "متأسفیم، شما مجاز به دسترسی به {platform_name} از طریق این کانال نیستید. لطفاً برای دسترسی به {platform_name} با سرپرست یا مدیر آموزشی خود تماس بگیرید.{lineBreak}{lineBreak}جزئیات خطا:{lineBreak}{errorMessage}",
"terms.of.service.and.honor.code": "شرایط استفاده از خدمات و اصول اخلاقی",
"privacy.policy": "قواعد حفظ حریم خصوصی",
"honor.code": "اصول اخلاقی",
"terms.of.service": "شرایط استفاده از خدمات",
"registration.username.suggestion.label": "پیشنهادشده:",
"did.you.mean.alert.text": "منظور شما این بود",
"register.page.terms.of.service.and.honor.code": "با ایجاد یک حساب کاربری، با {tosAndHonorCode} موافقت می‌کنید و تصدیق می‌کنید که {platformName} و هر عضو داده‌های شخصی شما را مطابق با {privacyPolicy} پردازش می‌کنند.",
"register.page.honor.code": "من با {platformName} {tosAndHonorCode} موافقم",
"register.page.terms.of.service": "من با {platformName} {termsOfService} موافقم",
"sign.in": "ورود",
"reset.password.page.title": "بازتنظیم گذرواژه | {siteName}",
"reset.password": "بازتنظیم گذرواژه",
"reset.password.page.instructions": "گذرواژه جدید را مجددا وارد کنید ",
"new.password.label": "گذرواژه جدید",
"confirm.password.label": "تایید گذرواژه",
"passwords.do.not.match": "گذرواژه‌ها مطابقت ندارند",
"confirm.your.password": "تایید گذرواژه ",
"reset.password.failure.heading": "امکان بازتنظیم گذرواژه شما نیست.",
"reset.password.form.submission.error": "لطفاً پاسخ‌های خود را بررسی کرده و دوباره امتحان کنید.",
"reset.server.rate.limit.error": "تعداد درخواست‌ها خیلی زیاد است.",
"reset.password.success.heading": "بازتنظیم گذرواژه  تکمیل شد.",
"reset.password.success": "گذرواژه شما بازتنظیم شد. وارد حساب کاربری خود شوید",
"rate.limit.error": "به دلیل درخواست‌های زیاد، خطایی روی داده است. لطفا بعد از مدتی دوباره امتحان کنید."
}

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "Recommendations | {siteName}",
"recommendation.page.heading": "We have a few recommendations to get you started.",
"recommendation.skip.button": "Skip for now",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "S'inscrire | {siteName}",
"registration.fullname.label": "Nom complet",
"registration.email.label": "Email",
@@ -135,7 +126,6 @@
"help.text.email": "Pour l'activation du compte et les mises à jour importantes",
"create.account.for.free.button": "Créer un compte gratuitement",
"registration.other.options.heading": "Ou inscrivez-vous avec :",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Identifiants de l'établissement/du campus",
"register.institution.login.page.title": "Inscription avec les crédentiels d'institution ou de campus",
"empty.name.field.error": "Saisissez votre nom complet",

View File

@@ -1,5 +1,5 @@
{
"start.learning": "Commencez à apprendre",
"start.learning": "Démarrer l'apprentissage",
"with.site.name": "avec {siteName}",
"your.career.turning.point": "Votre tournant de carrière",
"is.here": "est là.",
@@ -9,7 +9,7 @@
"institution.login.page.sub.heading": "Sélectionner votre institution dans la liste ci-dessous",
"logistration.sign.in": "Connexion",
"logistration.register": "Inscription",
"enterprisetpa.title.heading": "Souhaitez-vous vous connecter à l'aide de vos informations d'identification {providerName}?",
"enterprisetpa.title.heading": "Souhaitez-vous vous connecter à l'aide de vos identifiants {providerName}?",
"enterprisetpa.login.button.text": "Affichez moi d'autres façons de se connecter ou de s'inscrire",
"enterprisetpa.login.button.text.public.account.creation.disabled": "Montrez-moi d'autres façons de me connecter",
"sso.sign.in.with": "Connectez-vous avec {providerName}",
@@ -24,7 +24,7 @@
"login.third.party.auth.account.not.linked": "Vous vous êtes connecté avec succès à {currentProvider}, mais votre compte {currentProvider} n'a pas de compte relié à {platformName}. Pour lier vos comptes, connectez-vous en utilisant votre mot de passe {platformName}.",
"register.third.party.auth.account.not.linked": "Vous vous êtes connecté avec succès à {currentProvider}! Nous avons juste besoin d'un peu plus d'informations avant que vous commenciez à apprendre avec {platformName}.",
"registration.using.tpa.form.heading": "Terminer la création de votre compte",
"zendesk.supportTitle": "Prise en charge d'edX",
"zendesk.supportTitle": "Prise en charge d&#39;edX",
"zendesk.selectTicketForm": "Veuillez choisir votre type de demande :",
"error.notfound.message": "La page que vous recherchez n'est pas disponible ou il y a une erreur dans l'URL. Veuillez vérifier l'URL et réessayer.",
"forgot.password.confirmation.message": "Nous avons envoyé un courriel à {email} avec des instructions pour réinitialiser votre mot de passe.\n Si vous ne recevez pas de message de réinitialisation de mot de passe après 1 minute, vérifiez que vous avez saisi\nl'adresse courriel correctement, ou vérifiez votre dossier de pourriels. Si vous avez besoin d'aide supplémentaire, {supportLink}.",
@@ -42,7 +42,7 @@
"confirmation.message.title": "Vérifiez votre courriel",
"confirmation.support.link": "contacter le support technique",
"need.help.sign.in.text": "Besoin d'aide pour vous connecter?",
"additional.help.text": "Pour obtenir une aide supplémentaire, contactez l'assistance {platformName} à l'adresse ",
"additional.help.text": "Pour obtenir une aide supplémentaire, contactez l'assistance {platformName} à l'adresse",
"sign.in.text": "Connexion",
"extend.field.errors": "{emailError} ci-dessous.",
"invalid.token.heading": "Lien de réinitialisation du mot de passe non valide",
@@ -64,13 +64,13 @@
"login.password.label": "Mot de passe",
"sign.in.button": "Connexion",
"forgot.password": "Mot de passe oublié",
"institution.login.button": "Informations d'identification de l'établissement/du campus",
"institution.login.page.title": "Connectez vous avec les informations d'identification d'institution ou de campus",
"institution.login.button": "Identifiants de l'établissement/du campus",
"institution.login.page.title": "Connectez vous avec les crédentiels d'institution ou de campus",
"login.other.options.heading": "Ou se connecter avec :",
"non.compliant.password.title": "Nous avons récemment modifié nos exigences en matière de mot de passe",
"non.compliant.password.message": "Votre mot de passe actuel ne répond pas aux nouvelles exigences de sécurité. Nous venons d'envoyer un message de réinitialisation de mot de passe à l'adresse courriel associée à ce compte. Merci de nous aider à protéger vos données.",
"account.locked.out.message.1": "Pour protéger votre compte, il a été temporairement verrouillé. Réessayez dans 30 minutes.",
"enterprise.login.btn.text": "Informations d'identification de la compagnie ou de l'école",
"enterprise.login.btn.text": "Identifiants de la compagnie ou de l'école",
"username.or.email.format.validation.less.chars.message": "Le nom d'utilisateur ou l'adresse courriel doit comporter au moins 3 caractères.",
"email.validation.message": "Entrez votre nom d'utilisateur ou votre adresse courriel",
"password.validation.message": "Les critères de mot de passe n'ont pas été remplis",
@@ -113,15 +113,6 @@
"recommendation.page.title": "Recommandations | {siteName}",
"recommendation.page.heading": "Nous avons quelques recommandations pour vous aider à démarrer.",
"recommendation.skip.button": "Ignorer pour l'instant",
"recommendation.option.trending": "Tendance",
"recommendation.option.popular": "Le plus populaire",
"recommendation.product-card.pill-text.course": "Cours",
"recommendation.product-card.pill-text.professional-certificate": "Attestation professionnelle",
"recommendation.product-card.pill-text.emeritus": "Offert à titre émérite",
"recommendation.product-card.pill-text.shorelight": "Offert par Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Abonnement",
"recommendation.product-card.launch-icon.sr-text": "Ouvre un lien dans un nouvel onglet",
"register.page.title": "S'inscrire | {siteName}",
"registration.fullname.label": "Nom complet",
"registration.email.label": "Courriel",
@@ -135,9 +126,8 @@
"help.text.email": "Pour l'activation du compte et les mises à jour importantes",
"create.account.for.free.button": "Créer un compte gratuitement",
"registration.other.options.heading": "Ou inscrivez-vous avec :",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Informations d'identification de l'établissement/du campus",
"register.institution.login.page.title": "Inscription avec les informations d'identification d'institution ou de campus",
"register.institution.login.button": "Identifiants de l'établissement/du campus",
"register.institution.login.page.title": "Inscription avec les crédentiels d'institution ou de campus",
"empty.name.field.error": "Saisissez votre nom complet",
"empty.email.field.error": "Saisissez votre courriel",
"empty.username.field.error": "Le nom d'utilisateur doit comporter entre 2 et 30 caractères",
@@ -147,7 +137,7 @@
"email.invalid.format.error": "Entrez une adresse de courriel valide",
"username.validation.message": "Le nom d'utilisateur doit comporter entre 2 et 30 caractères",
"name.validation.message": "Entrez un nom valide",
"username.format.validation.message": "Les noms d'utilisateur ne peuvent contenir que des lettres (A-Z, a-z), des chiffres (0-9), des traits de soulignement (_) et des traits d'union (-). Les noms d'utilisateur ne peuvent pas contenir d'espaces",
"username.format.validation.message": "Les noms d'utilisateur ne peuvent contenir que des lettres (AZ, az), des chiffres (0-9), des traits de soulignement (_) et des traits d'union (-). Les noms d'utilisateur ne peuvent pas contenir d'espaces",
"registration.request.failure.header": "Nous n'avons pas pu créer votre compte.",
"registration.empty.form.submission.error": "Veuillez vérifier vos réponses et réessayer.",
"registration.request.server.error": "Une erreur est survenue. Essayer de rafraîchir la page, ou vérifier votre connexion Internet.",

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "Recommendations | {siteName}",
"recommendation.page.heading": "We have a few recommendations to get you started.",
"recommendation.skip.button": "Skip for now",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "Register | {siteName}",
"registration.fullname.label": "Full name",
"registration.email.label": "Email",
@@ -135,7 +126,6 @@
"help.text.email": "For account activation and important updates",
"create.account.for.free.button": "Create an account for free",
"registration.other.options.heading": "Or register with:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Institution/campus credentials",
"register.institution.login.page.title": "Register with institution/campus credentials",
"empty.name.field.error": "Enter your full name",

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "Recommendations | {siteName}",
"recommendation.page.heading": "We have a few recommendations to get you started.",
"recommendation.skip.button": "Skip for now",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "Register | {siteName}",
"registration.fullname.label": "Full name",
"registration.email.label": "Email",
@@ -135,7 +126,6 @@
"help.text.email": "For account activation and important updates",
"create.account.for.free.button": "Create an account for free",
"registration.other.options.heading": "Or register with:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Institution/campus credentials",
"register.institution.login.page.title": "Register with institution/campus credentials",
"empty.name.field.error": "Enter your full name",

View File

@@ -99,7 +99,7 @@
"password.security.close.button": "Chiudi",
"password.security.redirect.to.reset.password.button": "Ripristina la tua password",
"login.tpa.authentication.failure": "We are sorry, you are not authorized to access {platform_name} via this channel. Please contact your learning administrator or manager in order to access {platform_name}.{lineBreak}{lineBreak}Error Details:{lineBreak}{errorMessage}",
"progressive.profiling.page.title": "Benvenuto | {siteName}",
"progressive.profiling.page.title": "Welcome | {siteName}",
"progressive.profiling.page.heading": "Alcune domande per te ci aiuteranno a diventare più intelligenti.",
"optional.fields.information.link": "Ulteriori informazioni su come utilizziamo queste informazioni.",
"optional.fields.submit.button": "Invia",
@@ -113,15 +113,6 @@
"recommendation.page.title": "Recommendations | {siteName}",
"recommendation.page.heading": "We have a few recommendations to get you started.",
"recommendation.skip.button": "Skip for now",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "Registrazione | {siteName}",
"registration.fullname.label": "Nome e Cognome",
"registration.email.label": "Email",
@@ -135,7 +126,6 @@
"help.text.email": "Per l&#39;attivazione dell&#39;account e aggiornamenti importanti",
"create.account.for.free.button": "Crea un account gratis",
"registration.other.options.heading": "Oppure registrati con:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Credenziali dell&#39;istituto/campus",
"register.institution.login.page.title": "Registrati con le credenziali dell'istituzione/campus",
"empty.name.field.error": "Inserisci il tuo nome e cognome",

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "Recommendations | {siteName}",
"recommendation.page.heading": "We have a few recommendations to get you started.",
"recommendation.skip.button": "Skip for now",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "Register | {siteName}",
"registration.fullname.label": "Full name",
"registration.email.label": "Email",
@@ -135,7 +126,6 @@
"help.text.email": "For account activation and important updates",
"create.account.for.free.button": "Create an account for free",
"registration.other.options.heading": "Or register with:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Institution/campus credentials",
"register.institution.login.page.title": "Register with institution/campus credentials",
"empty.name.field.error": "Enter your full name",

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "Recomendações | {siteName}",
"recommendation.page.heading": "Temos algumas recomendações para o ajudar a começar.",
"recommendation.skip.button": "Saltar por enquanto",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "Registar | {siteName}",
"registration.fullname.label": "Nome completo",
"registration.email.label": "E-mail",
@@ -135,7 +126,6 @@
"help.text.email": "Para ativação de contas e atualizações importantes",
"create.account.for.free.button": "Criar uma conta gratuitamente",
"registration.other.options.heading": "Ou registe-se com:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Credenciais da instituição/campus",
"register.institution.login.page.title": "Registo com credenciais da instituição/campus",
"empty.name.field.error": "Insira o seu nome completo",

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "Recommendations | {siteName}",
"recommendation.page.heading": "We have a few recommendations to get you started.",
"recommendation.skip.button": "Skip for now",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "Register | {siteName}",
"registration.fullname.label": "Full name",
"registration.email.label": "Email",
@@ -135,7 +126,6 @@
"help.text.email": "For account activation and important updates",
"create.account.for.free.button": "Create an account for free",
"registration.other.options.heading": "Or register with:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Institution/campus credentials",
"register.institution.login.page.title": "Register with institution/campus credentials",
"empty.name.field.error": "Enter your full name",

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "Recommendations | {siteName}",
"recommendation.page.heading": "We have a few recommendations to get you started.",
"recommendation.skip.button": "Skip for now",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "Register | {siteName}",
"registration.fullname.label": "Full name",
"registration.email.label": "Email",
@@ -135,7 +126,6 @@
"help.text.email": "For account activation and important updates",
"create.account.for.free.button": "Create an account for free",
"registration.other.options.heading": "Or register with:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "Institution/campus credentials",
"register.institution.login.page.title": "Register with institution/campus credentials",
"empty.name.field.error": "Enter your full name",

View File

@@ -113,15 +113,6 @@
"recommendation.page.title": "建议 | {siteName}",
"recommendation.page.heading": "我们有一些建议可以帮助您入门。",
"recommendation.skip.button": "暂时跳过",
"recommendation.option.trending": "Trending",
"recommendation.option.popular": "Most Popular",
"recommendation.product-card.pill-text.course": "Course",
"recommendation.product-card.pill-text.professional-certificate": "Professional Certificate",
"recommendation.product-card.pill-text.emeritus": "Offered on Emeritus",
"recommendation.product-card.pill-text.shorelight": "Offered through Shorelight",
"recommendation.product-card.footer-text.number-of-courses": "{length} {label}",
"recommendation.product-card.footer-text.subscription": "Subscription",
"recommendation.product-card.launch-icon.sr-text": "Opens a link in a new tab",
"register.page.title": "注册 | {siteName}",
"registration.fullname.label": "全名",
"registration.email.label": "邮箱",
@@ -135,7 +126,6 @@
"help.text.email": "用于帐户激活和重要更新",
"create.account.for.free.button": "免费创建一个帐户",
"registration.other.options.heading": "或注册:",
"create.account.cta.button": "{label}",
"register.institution.login.button": "机构/院系验证",
"register.institution.login.page.title": "使用机构/院系账户注册",
"empty.name.field.error": "输入您的全名",

View File

@@ -225,7 +225,7 @@ describe('LoginFailureMessage', () => {
loginError: {
errorCode: TPA_AUTHENTICATION_FAILURE,
context: {
errorMessage: 'An error occurred',
errorMessage: 'An error occured',
},
},
};
@@ -239,7 +239,7 @@ describe('LoginFailureMessage', () => {
const expectedMessageSubstring = 'We are sorry, you are not authorized to access';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toContain(expectedMessageSubstring);
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toContain('An error occurred');
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toContain('An error occured');
});
it('should show modal that nudges users to change password', () => {

View File

@@ -55,9 +55,6 @@ describe('LoginPage', () => {
secondaryProviders: [],
},
},
register: {
validationApiRateLimited: false,
},
};
const secondaryProviders = {
@@ -448,13 +445,13 @@ describe('LoginPage', () => {
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
currentProvider: null,
errorMessage: 'An error occurred',
errorMessage: 'An error occured',
},
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('#login-failure-alert').find('p').text()).toContain('An error occurred');
expect(loginPage.find('#login-failure-alert').find('p').text()).toContain('An error occured');
});
it('should match invalid login form error message', () => {

View File

@@ -12,7 +12,7 @@ import {
} from '@edx/paragon';
import { ChevronLeft } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import { Navigate, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import BaseContainer from '../base-container';
import { clearThirdPartyAuthContextErrorMessage } from '../common-components/data/actions';
@@ -36,7 +36,6 @@ const Logistration = (props) => {
} = tpaProviders;
const { formatMessage } = useIntl();
const [institutionLogin, setInstitutionLogin] = useState(false);
const [key, setKey] = useState('');
const navigate = useNavigate();
const disablePublicAccountCreation = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false;
@@ -70,7 +69,7 @@ const Logistration = (props) => {
if (tabKey === LOGIN_PAGE) {
props.backupRegistrationForm();
}
setKey(tabKey);
navigate(updatePathWithQueryParams(tabKey));
};
const tabTitle = (
@@ -122,9 +121,6 @@ const Logistration = (props) => {
<Tab title={formatMessage(messages['logistration.sign.in'])} eventKey={LOGIN_PAGE} />
</Tabs>
))}
{ key && (
<Navigate to={updatePathWithQueryParams(key)} replace />
)}
<div id="main-content" className="main-content">
{selectedPage === LOGIN_PAGE
? <LoginPage institutionLogin={institutionLogin} handleInstitutionLogin={handleInstitutionLogin} />

View File

@@ -43,40 +43,7 @@ describe('Logistration', () => {
</IntlProvider>
);
const initialState = {
register: {
registrationFormData: {
configurableFormFields: {
marketingEmailsOptIn: true,
},
formFields: {
name: '', email: '', username: '', password: '',
},
emailSuggestion: {
suggestion: '', type: '',
},
errors: {
name: '', email: '', username: '', password: '',
},
},
registrationResult: { success: false, redirectUrl: '' },
registrationError: {},
usernameSuggestions: [],
validationApiRateLimited: false,
},
commonComponents: {
thirdPartyAuthContext: {
providers: [],
secondaryProviders: [],
},
},
login: {
loginResult: { success: false, redirectUrl: '' },
},
};
beforeEach(() => {
store = mockStore(initialState);
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({
userId: 3,
@@ -98,13 +65,36 @@ describe('Logistration', () => {
mergeConfig({
ALLOW_PUBLIC_ACCOUNT_CREATION: true,
});
store = mockStore({
register: {
registrationResult: { success: false, redirectUrl: '' },
registrationError: {},
},
commonComponents: {
thirdPartyAuthContext: {
providers: [],
secondaryProviders: [],
},
},
});
const logistration = mount(reduxWrapper(<IntlLogistration />));
expect(logistration.find('#main-content').find('RegistrationPage').exists()).toBeTruthy();
});
it('should render login page', () => {
store = mockStore({
login: {
loginResult: { success: false, redirectUrl: '' },
},
commonComponents: {
thirdPartyAuthContext: {
providers: [],
secondaryProviders: [],
},
},
});
const props = { selectedPage: LOGIN_PAGE };
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
@@ -118,7 +108,9 @@ describe('Logistration', () => {
});
store = mockStore({
...initialState,
login: {
loginResult: { success: false, redirectUrl: '' },
},
commonComponents: {
thirdPartyAuthContext: {
currentProvider: null,
@@ -148,7 +140,9 @@ describe('Logistration', () => {
});
store = mockStore({
...initialState,
login: {
loginResult: { success: false, redirectUrl: '' },
},
commonComponents: {
thirdPartyAuthContext: {
currentProvider: null,
@@ -179,7 +173,9 @@ describe('Logistration', () => {
});
store = mockStore({
...initialState,
login: {
loginResult: { success: false, redirectUrl: '' },
},
commonComponents: {
thirdPartyAuthContext: {
currentProvider: null,
@@ -209,7 +205,10 @@ describe('Logistration', () => {
});
store = mockStore({
...initialState,
register: {
registrationResult: { success: false, redirectUrl: '' },
registrationError: {},
},
commonComponents: {
thirdPartyAuthContext: {
currentProvider: null,
@@ -234,6 +233,22 @@ describe('Logistration', () => {
});
it('should fire action to backup registration form on tab click', () => {
store = mockStore({
login: {
loginResult: { success: false, redirectUrl: '' },
},
register: {
registrationResult: { success: false, redirectUrl: '' },
registrationError: {},
},
commonComponents: {
thirdPartyAuthContext: {
providers: [],
secondaryProviders: [],
},
},
});
store.dispatch = jest.fn(store.dispatch);
const logistration = mount(reduxWrapper(<IntlLogistration />));
logistration.find('a[data-rb-event-key="/login"]').simulate('click');
@@ -241,6 +256,22 @@ describe('Logistration', () => {
});
it('should clear tpa context errorMessage tab click', () => {
store = mockStore({
login: {
loginResult: { success: false, redirectUrl: '' },
},
register: {
registrationResult: { success: false, redirectUrl: '' },
registrationError: {},
},
commonComponents: {
thirdPartyAuthContext: {
providers: [],
secondaryProviders: [],
},
},
});
store.dispatch = jest.fn(store.dispatch);
const logistration = mount(reduxWrapper(<IntlLogistration />));
logistration.find('a[data-rb-event-key="/login"]').simulate('click');

View File

@@ -6,7 +6,9 @@ import { identifyAuthenticatedUser, sendPageEvent, sendTrackEvent } from '@edx/f
import {
AxiosJwtAuthService,
configure as configureAuth,
ensureAuthenticatedUser,
getAuthenticatedUser,
hydrateAuthenticatedUser,
} from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getLoggingService } from '@edx/frontend-platform/logging';
@@ -35,9 +37,12 @@ import {
FAILURE_STATE,
PENDING_STATE,
} from '../data/constants';
import isOneTrustFunctionalCookieEnabled from '../data/oneTrust';
import { getAllPossibleQueryParams, isHostAvailableInQueryParams } from '../data/utils';
import { FormFieldRenderer } from '../field-renderer';
import {
activateRecommendationsExperiment, RECOMMENDATIONS_EXP_VARIATION,
} from '../recommendations/optimizelyExperiment';
import { trackRecommendationsGroup, trackRecommendationsViewed } from '../recommendations/track';
const ProgressiveProfiling = (props) => {
const { formatMessage } = useIntl();
@@ -52,25 +57,27 @@ const ProgressiveProfiling = (props) => {
const registrationEmbedded = isHostAvailableInQueryParams();
const queryParams = getAllPossibleQueryParams();
const authenticatedUser = getAuthenticatedUser() || location.state?.authenticatedUser;
const functionalCookiesConsent = isOneTrustFunctionalCookieEnabled();
const enablePostRegistrationRecommendations = (
getConfig().ENABLE_POST_REGISTRATION_RECOMMENDATIONS && functionalCookiesConsent
);
const authenticatedUser = getAuthenticatedUser();
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
const enablePopularAndTrendingRecommendations = getConfig().ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS;
const [registrationResult, setRegistrationResult] = useState({ redirectUrl: '' });
const [formFieldData, setFormFieldData] = useState({ fields: {}, extendedProfile: [] });
const [canViewWelcomePage, setCanViewWelcomePage] = useState(false);
const [values, setValues] = useState({});
const [showModal, setShowModal] = useState(false);
const [showRecommendationsPage, setShowRecommendationsPage] = useState(false);
useEffect(() => {
if (registrationEmbedded) {
getFieldDataFromBackend({ is_welcome_page: true, next: queryParams?.next });
} else {
configureAuth(AxiosJwtAuthService, { loggingService: getLoggingService(), config: getConfig() });
}
}, [registrationEmbedded, getFieldDataFromBackend, queryParams?.next]);
configureAuth(AxiosJwtAuthService, { loggingService: getLoggingService(), config: getConfig() });
ensureAuthenticatedUser(DASHBOARD_URL)
.then(() => {
hydrateAuthenticatedUser().then(() => {
setCanViewWelcomePage(true);
});
})
.catch(() => {});
}, [DASHBOARD_URL]);
useEffect(() => {
const registrationResponse = location.state?.registrationResult;
@@ -83,6 +90,12 @@ const ProgressiveProfiling = (props) => {
}
}, [location.state]);
useEffect(() => {
if (registrationEmbedded) {
getFieldDataFromBackend({ is_welcome_page: true, next: queryParams?.next });
}
}, [registrationEmbedded, getFieldDataFromBackend, queryParams?.next]);
useEffect(() => {
if (registrationEmbedded && Object.keys(welcomePageContext).includes('fields')) {
setFormFieldData({
@@ -95,46 +108,42 @@ const ProgressiveProfiling = (props) => {
}, [registrationEmbedded, welcomePageContext]);
useEffect(() => {
if (authenticatedUser?.userId) {
if (canViewWelcomePage && authenticatedUser?.userId) {
identifyAuthenticatedUser(authenticatedUser.userId);
sendPageEvent('login_and_registration', 'welcome');
}
}, [authenticatedUser]);
}, [authenticatedUser, canViewWelcomePage]);
useEffect(() => {
if (!enablePostRegistrationRecommendations) {
sendTrackEvent(
'edx.bi.user.recommendations.not.enabled',
{ functionalCookiesConsent, page: 'authn_recommendations' },
);
return;
}
if (registrationResult.redirectUrl && authenticatedUser?.userId) {
const redirectQueryParams = getAllPossibleQueryParams(registrationResult.redirectUrl);
if (!('enrollment_action' in redirectQueryParams || queryParams?.next)) {
setShowRecommendationsPage(true);
if (enablePopularAndTrendingRecommendations && !('enrollment_action' in redirectQueryParams) && !queryParams?.next) {
const userIdStr = authenticatedUser.userId.toString();
const variation = activateRecommendationsExperiment(userIdStr);
const showRecommendations = variation === RECOMMENDATIONS_EXP_VARIATION;
trackRecommendationsGroup(variation, authenticatedUser.userId);
setShowRecommendationsPage(showRecommendations);
if (!showRecommendations) {
trackRecommendationsViewed([], '', true, authenticatedUser.userId);
}
}
}
}, [
authenticatedUser,
enablePostRegistrationRecommendations,
functionalCookiesConsent,
registrationResult.redirectUrl,
queryParams?.next,
]);
}, [authenticatedUser, enablePopularAndTrendingRecommendations, registrationResult.redirectUrl, queryParams?.next]);
if (
!authenticatedUser
|| !(location.state?.registrationResult || registrationEmbedded)
!(location.state?.registrationResult || registrationEmbedded)
|| welcomePageContextApiStatus === FAILURE_STATE
|| (welcomePageContextApiStatus === COMPLETE_STATE && !Object.keys(welcomePageContext).includes('fields'))
) {
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
global.location.assign(DASHBOARD_URL);
return null;
}
if (!canViewWelcomePage) {
return null;
}
const handleSubmit = (e) => {
e.preventDefault();
window.history.replaceState(location.state, null, '');
@@ -194,7 +203,7 @@ const ProgressiveProfiling = (props) => {
});
return (
<BaseContainer showWelcomeBanner username={authenticatedUser?.username}>
<BaseContainer showWelcomeBanner>
<Helmet>
<title>{formatMessage(messages['progressive.profiling.page.title'],
{ siteName: getConfig().SITE_NAME })}
@@ -274,10 +283,6 @@ const ProgressiveProfiling = (props) => {
};
ProgressiveProfiling.propTypes = {
authenticatedUser: PropTypes.shape({
username: PropTypes.string,
userId: PropTypes.number,
}),
showError: PropTypes.bool,
shouldRedirect: PropTypes.bool,
submitState: PropTypes.string,
@@ -293,7 +298,6 @@ ProgressiveProfiling.propTypes = {
};
ProgressiveProfiling.defaultProps = {
authenticatedUser: {},
shouldRedirect: false,
showError: false,
submitState: DEFAULT_STATE,

View File

@@ -6,6 +6,7 @@ import { identifyAuthenticatedUser, sendTrackEvent } from '@edx/frontend-platfor
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { configure, injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { MemoryRouter, mockNavigate, useLocation } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -16,6 +17,7 @@ import {
FAILURE_STATE,
RECOMMENDATIONS,
} from '../../data/constants';
import { activateRecommendationsExperiment } from '../../recommendations/optimizelyExperiment';
import { saveUserProfile } from '../data/actions';
import ProgressiveProfiling from '../ProgressiveProfiling';
@@ -29,11 +31,18 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
}));
jest.mock('@edx/frontend-platform/auth', () => ({
configure: jest.fn(),
ensureAuthenticatedUser: jest.fn().mockImplementation(() => Promise.resolve(true)),
hydrateAuthenticatedUser: jest.fn().mockImplementation(() => Promise.resolve(true)),
getAuthenticatedUser: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
getLoggingService: jest.fn(),
}));
jest.mock('../../recommendations/optimizelyExperiment.js', () => ({
activateRecommendationsExperiment: jest.fn(),
trackRecommendationViewedOptimizely: jest.fn(),
RECOMMENDATIONS_EXP_VARIATION: 'welcome_page_recommendations_enabled',
}));
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
@@ -52,9 +61,9 @@ jest.mock('react-router-dom', () => {
});
describe('ProgressiveProfilingTests', () => {
let store = {};
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
mergeConfig({
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: 'http://localhost:1999/welcome',
});
const registrationResult = { redirectUrl: getConfig().LMS_BASE_URL + DEFAULT_REDIRECT_URL, success: true };
const fields = {
company: { name: 'company', type: 'text', label: 'Company' },
@@ -67,6 +76,8 @@ describe('ProgressiveProfilingTests', () => {
};
const extendedProfile = ['company'];
const optionalFields = { fields, extended_profile: extendedProfile };
let store = {};
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
const initialState = {
welcomePage: {},
commonComponents: {
@@ -86,6 +97,17 @@ describe('ProgressiveProfilingTests', () => {
</IntlProvider>
);
const getProgressiveProfilingPage = async () => {
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
await act(async () => {
await Promise.resolve(progressiveProfilingPage);
await new Promise(resolve => { setImmediate(resolve); });
progressiveProfilingPage.update();
});
return progressiveProfilingPage;
};
beforeEach(() => {
store = mockStore(initialState);
configure({
@@ -102,59 +124,50 @@ describe('ProgressiveProfilingTests', () => {
optionalFields,
},
});
getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123' });
});
// ******** test form links and modal ********
it('should not display button "Learn more about how we use this information."', () => {
it('should not display button "Learn more about how we use this information."', async () => {
mergeConfig({
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: '',
});
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
const progressiveProfilingPage = await getProgressiveProfilingPage();
expect(progressiveProfilingPage.find('a.pgn__hyperlink').exists()).toBeFalsy();
});
it('should display button "Learn more about how we use this information."', () => {
it('should display button "Learn more about how we use this information."', async () => {
mergeConfig({
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: 'http://localhost:1999/support',
});
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
const progressiveProfilingPage = await getProgressiveProfilingPage();
expect(progressiveProfilingPage.find('a.pgn__hyperlink').text()).toEqual('Learn more about how we use this information.');
});
it('should open modal on pressing skip for now button', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING) };
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
progressiveProfilingPage.find('button.btn-link').simulate('click');
expect(progressiveProfilingPage.find('.pgn__modal-content-container').exists()).toBeTruthy();
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host: '' });
});
// ******** test event functionality ********
it('should make identify call to segment on progressive profiling page', () => {
mount(reduxWrapper(<IntlProgressiveProfilingPage />));
it('should make identify call to segment on progressive profiling page', async () => {
getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123' });
await getProgressiveProfilingPage();
expect(identifyAuthenticatedUser).toHaveBeenCalledWith(3);
expect(identifyAuthenticatedUser).toHaveBeenCalled();
});
it('should send analytic event for support link click', () => {
mergeConfig({
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: 'http://localhost:1999/support',
});
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
it('should submit user profile details on form submission', async () => {
getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123' });
const formPayload = {
gender: 'm',
extended_profile: [{ field_name: 'company', field_value: 'test company' }],
};
store.dispatch = jest.fn(store.dispatch);
const progressiveProfilingPage = await getProgressiveProfilingPage();
progressiveProfilingPage.find('select#gender').simulate('change', { target: { value: 'm', name: 'gender' } });
progressiveProfilingPage.find('input#company').simulate('change', { target: { value: 'test company', name: 'company' } });
progressiveProfilingPage.find('.pp-page__support-link a[target="_blank"]').simulate('click');
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.support.link.clicked');
progressiveProfilingPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(saveUserProfile('abc123', formPayload));
});
it('should set empty host property value for non-embedded experience', () => {
it('should set host property value empty for non-embedded experience', async () => {
getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123' });
const expectedEventProperties = {
isGenderSelected: false,
isYearOfBirthSelected: false,
@@ -163,30 +176,30 @@ describe('ProgressiveProfilingTests', () => {
};
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING) };
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
const progressiveProfilingPage = await getProgressiveProfilingPage();
progressiveProfilingPage.find('button.btn-brand').simulate('click');
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.submit.clicked', expectedEventProperties);
});
// ******** test form submission ********
it('should open modal on pressing skip for now button', async () => {
getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123' });
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING) };
const progressiveProfilingPage = await getProgressiveProfilingPage();
it('should submit user profile details on form submission', () => {
const formPayload = {
gender: 'm',
extended_profile: [{ field_name: 'company', field_value: 'test company' }],
};
store.dispatch = jest.fn(store.dispatch);
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
progressiveProfilingPage.find('select#gender').simulate('change', { target: { value: 'm', name: 'gender' } });
progressiveProfilingPage.find('input#company').simulate('change', { target: { value: 'test company', name: 'company' } });
progressiveProfilingPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(saveUserProfile('abc123', formPayload));
progressiveProfilingPage.find('button.btn-link').simulate('click');
expect(progressiveProfilingPage.find('.pgn__modal-content-container').exists()).toBeTruthy();
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host: '' });
});
it('should show error message when patch request fails', () => {
it('should send analytic event for support link click', async () => {
const progressiveProfilingPage = await getProgressiveProfilingPage();
progressiveProfilingPage.find('.pp-page__support-link a[target="_blank"]').simulate('click');
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.support.link.clicked');
});
it('should show error message when patch request fails', async () => {
store = mockStore({
...initialState,
welcomePage: {
@@ -195,31 +208,16 @@ describe('ProgressiveProfilingTests', () => {
},
});
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
const progressiveProfilingPage = await getProgressiveProfilingPage();
expect(progressiveProfilingPage.find('#pp-page-errors').exists()).toBeTruthy();
});
// ******** miscellaneous tests ********
it('should redirect to login page if unauthenticated user tries to access welcome page', () => {
getAuthenticatedUser.mockReturnValue(null);
delete window.location;
window.location = {
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
href: getConfig().BASE_URL,
};
mount(reduxWrapper(<IntlProgressiveProfilingPage />));
expect(window.location.href).toEqual(DASHBOARD_URL);
});
describe('Recommendations test', () => {
window.OnetrustActiveGroups = 'C0003';
mergeConfig({
ENABLE_POST_REGISTRATION_RECOMMENDATIONS: true,
ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS: true,
});
it('should redirect to recommendations page if recommendations are enabled', () => {
it('should redirect to recommendations page if recommendations are enabled', async () => {
store = mockStore({
...initialState,
welcomePage: {
@@ -227,12 +225,43 @@ describe('ProgressiveProfilingTests', () => {
success: true,
},
});
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
activateRecommendationsExperiment.mockImplementation(() => 'welcome_page_recommendations_enabled');
getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123' });
const progressiveProfilingPage = await getProgressiveProfilingPage();
expect(progressiveProfilingPage.find('button.btn-brand').text()).toEqual('Next');
expect(mockNavigate).toHaveBeenCalledWith(RECOMMENDATIONS);
});
it('should fire segments recommendations viewed and variation group events', async () => {
const viewedEventProperties = {
page: 'authn_recommendations',
products: [],
recommendation_type: '',
is_control: true,
user_id: 3,
};
const groupEventProperties = {
page: 'authn_recommendations',
variation: 'control',
user_id: 3,
};
activateRecommendationsExperiment.mockImplementation(() => 'control');
store = mockStore({
...initialState,
welcomePage: {
...initialState.welcomePage,
success: true,
},
});
getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123' });
await getProgressiveProfilingPage();
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.recommendations.group', groupEventProperties);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.recommendations.viewed', viewedEventProperties);
});
it('should not redirect to recommendations page if user is on its way to enroll in a course', async () => {
const redirectUrl = `${getConfig().LMS_BASE_URL}${DEFAULT_REDIRECT_URL}?enrollment_action=1`;
useLocation.mockReturnValue({
@@ -253,7 +282,8 @@ describe('ProgressiveProfilingTests', () => {
},
});
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123' });
const progressiveProfilingPage = await getProgressiveProfilingPage();
expect(progressiveProfilingPage.find('button.btn-brand').text()).toEqual('Submit');
expect(window.location.href).toEqual(redirectUrl);
@@ -280,19 +310,19 @@ describe('ProgressiveProfilingTests', () => {
});
});
it('should set host property value embedded host for on ramp experience for skip link event', () => {
it('should set host property value embedded host for on ramp experience for skip link event', async () => {
delete window.location;
window.location = {
href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING),
search: `?host=${host}&variant=${EMBEDDED}`,
};
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
const progressiveProfilingPage = await getProgressiveProfilingPage();
progressiveProfilingPage.find('button.btn-link').simulate('click');
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host });
});
it('should set host property value to host where iframe is embedded for on ramp experience', () => {
it('should set host property value to host where iframe is embedded for on ramp experience', async () => {
const expectedEventProperties = {
isGenderSelected: false,
isYearOfBirthSelected: false,
@@ -304,12 +334,12 @@ describe('ProgressiveProfilingTests', () => {
href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING),
search: `?host=${host}`,
};
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
const progressiveProfilingPage = await getProgressiveProfilingPage();
progressiveProfilingPage.find('button.btn-brand').simulate('click');
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.submit.clicked', expectedEventProperties);
});
it('should render fields returned by backend API', () => {
it('should render fields returned by backend API', async () => {
delete window.location;
window.location = {
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
@@ -317,11 +347,11 @@ describe('ProgressiveProfilingTests', () => {
search: `?variant=${EMBEDDED}&host=${host}`,
};
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
const progressiveProfilingPage = await getProgressiveProfilingPage();
expect(progressiveProfilingPage.find('#gender').exists()).toBeTruthy();
});
it('should redirect to dashboard if API call to get form field fails', () => {
it('should redirect to dashboard if API call to get form field fails', async () => {
delete window.location;
window.location = {
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
@@ -336,11 +366,11 @@ describe('ProgressiveProfilingTests', () => {
},
});
mount(reduxWrapper(<IntlProgressiveProfilingPage />));
await getProgressiveProfilingPage();
expect(window.location.href).toBe(DASHBOARD_URL);
});
it('should redirect to provided redirect url', () => {
it('should redirect to provided redirect url', async () => {
const redirectUrl = 'https://redirect-test.com';
delete window.location;
window.location = {
@@ -364,7 +394,7 @@ describe('ProgressiveProfilingTests', () => {
},
});
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
const progressiveProfilingPage = await getProgressiveProfilingPage();
progressiveProfilingPage.find('button.btn-brand').simulate('click');
expect(window.location.href).toBe(redirectUrl);
});

View File

@@ -15,9 +15,9 @@ const BaseCard = ({
productTypeCopy,
footer,
handleOnClick,
isLoading,
isLoading = false,
}) => (
<div className="recommendation-card" key={`container-${uuid}`}>
<div className="mr-4 recommendation-card" key={`container-${uuid}`}>
<Hyperlink
target="_blank"
className="card-box"

View File

@@ -15,7 +15,6 @@ const ProductCard = ({
product,
userId,
position,
isLoading,
}) => {
const { formatMessage } = useIntl();
@@ -67,6 +66,7 @@ const ProductCard = ({
trackRecommendationClick(
product,
position + 1,
false,
userId,
);
};
@@ -82,7 +82,6 @@ const ProductCard = ({
productTypeCopy={productTypeCopy}
productType={productType}
variant={variant}
isLoading={isLoading}
footer={(
<Footer
quickFacts={product.degree?.quickFacts}
@@ -106,10 +105,8 @@ ProductCard.propTypes = {
]).isRequired,
userId: PropTypes.number.isRequired,
position: PropTypes.number.isRequired,
isLoading: PropTypes.bool,
};
ProductCard.defaultProps = {
isLoading: false,
};
export default ProductCard;

View File

@@ -5,19 +5,19 @@ import PropTypes from 'prop-types';
import ProductCard from './ProductCard';
const RecommendationsList = (props) => {
const { recommendations, userId, isLoading } = props;
const { recommendations, userId } = props;
return (
<div className="d-flex flex-wrap mb-3 recommendations-container__card-list">
<div className="d-flex recommendations-container__card-list">
{
recommendations.map((recommendation, idx) => (
<ProductCard
key={recommendation.uuid}
product={recommendation}
position={idx}
userId={userId}
isLoading={isLoading}
/>
<span key={recommendation.uuid}>
<ProductCard
product={recommendation}
position={idx}
userId={userId}
/>
</span>
))
}
</div>
@@ -29,13 +29,11 @@ RecommendationsList.propTypes = {
uuid: PropTypes.string,
})),
userId: PropTypes.number,
isLoading: PropTypes.bool,
};
RecommendationsList.defaultProps = {
recommendations: [],
userId: null,
isLoading: false,
};
export default RecommendationsList;

View File

@@ -1,50 +1,36 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
breakpoints,
Container,
Hyperlink,
Image, Skeleton,
StatefulButton,
useMediaQuery,
Container, Hyperlink, Image, StatefulButton, Tab, Tabs,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useLocation } from 'react-router-dom';
import { EDUCATION_LEVEL_MAPPING, PERSONALIZED } from './data/constants';
import useAlgoliaRecommendations from './data/hooks/useAlgoliaRecommendations';
import { POPULAR, TRENDING } from './data/constants';
import useProducts from './data/hooks/useProducts';
import messages from './messages';
import RecommendationsLargeLayout from './RecommendationsPageLayouts/LargeLayout';
import RecommendationsSmallLayout from './RecommendationsPageLayouts/SmallLayout';
import { LINK_TIMEOUT, trackRecommendationsViewed, trackSkipButtonClicked } from './track';
import RecommendationsList from './RecommendationsList';
import { trackRecommendationsViewed } from './track';
import { DEFAULT_REDIRECT_URL } from '../data/constants';
const RecommendationsPage = () => {
const RecommendationsPage = ({ countryCode }) => {
const { formatMessage } = useIntl();
const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth - 1 });
const location = useLocation();
const registrationResponse = location.state?.registrationResult;
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
const educationLevel = EDUCATION_LEVEL_MAPPING[location.state?.educationLevel];
const userId = location.state?.userId;
const userCountry = useSelector((state) => state.register.backendCountryCode);
const {
recommendations: algoliaRecommendations,
isLoading,
} = useAlgoliaRecommendations(userCountry, educationLevel);
const { popularProducts, trendingProducts, isLoading } = useProducts(countryCode);
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
useEffect(() => {
if (!isLoading && algoliaRecommendations.length > 0) {
trackRecommendationsViewed(algoliaRecommendations, PERSONALIZED, userId);
}
}, [isLoading, algoliaRecommendations, userId]);
trackRecommendationsViewed(popularProducts, POPULAR, false, userId);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSkipRecommendationPage = () => {
const handleRedirection = () => {
window.history.replaceState(location.state, null, '');
if (registrationResponse) {
window.location.href = registrationResponse.redirectUrl;
@@ -55,8 +41,7 @@ const RecommendationsPage = () => {
const handleSkip = (e) => {
e.preventDefault();
trackSkipButtonClicked(userId);
setTimeout(() => { handleSkipRecommendationPage(); }, LINK_TIMEOUT);
handleRedirection();
};
if (!registrationResponse) {
@@ -64,10 +49,15 @@ const RecommendationsPage = () => {
return null;
}
if (!isLoading && !algoliaRecommendations.length) {
handleSkipRecommendationPage();
if (!isLoading && (!popularProducts.length || !trendingProducts.length)) {
handleRedirection();
}
const handleOnSelect = (tabKey) => {
const recommendations = tabKey === POPULAR ? popularProducts : trendingProducts;
trackRecommendationsViewed(recommendations, tabKey, false, userId);
};
return (
<>
<Helmet>
@@ -75,55 +65,64 @@ const RecommendationsPage = () => {
{ siteName: getConfig().SITE_NAME })}
</title>
</Helmet>
<div className="d-flex flex-column bg-light-200 min-vh-100">
<div className="d-flex flex-column vh-100 bg-light-200">
<div className="mb-2">
<div className="col-md-12 small-screen-top-stripe medium-screen-top-stripe extra-large-screen-top-stripe" />
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
</Hyperlink>
</div>
<div className="d-flex flex-column align-items-center justify-content-center flex-grow-1">
<Container
id="course-recommendations"
size="lg"
className="pr-4 pl-4 mt-4.5 mb-4.5 mb-md-5"
>
{isExtraSmall ? (
<RecommendationsSmallLayout
userId={userId}
isLoading={isLoading}
personalizedRecommendations={algoliaRecommendations}
/>
) : (
<RecommendationsLargeLayout
userId={userId}
isLoading={isLoading}
personalizedRecommendations={algoliaRecommendations}
/>
)}
<div className="mt-3 mt-sm-4.5 text-center">
{isLoading && (
<Skeleton height={40} width={140} />
)}
{!isLoading && algoliaRecommendations.length && (
<StatefulButton
className="font-weight-500"
type="submit"
variant="outline-brand"
labels={{
default: formatMessage(messages['recommendation.skip.button']),
}}
onClick={handleSkip}
<div className="d-flex flex-column align-items-center justify-content-center flex-grow-1 p-1">
<Container id="course-recommendations" size="lg" className="recommendations-container">
<h2 className="text-sm-center mb-4 text-left recommendations-container__heading">
{formatMessage(messages['recommendation.page.heading'])}
</h2>
<Tabs
variant="tabs"
defaultActiveKey={POPULAR}
id="recommendations-selection"
onSelect={handleOnSelect}
>
<Tab tabClassName="mb-3" eventKey={POPULAR} title={formatMessage(messages['recommendation.option.popular'])}>
<RecommendationsList
recommendations={popularProducts}
userId={userId}
/>
)}
</div>
</Tab>
<Tab tabClassName="mb-3" eventKey={TRENDING} title={formatMessage(messages['recommendation.option.trending'])}>
<RecommendationsList
recommendations={trendingProducts}
userId={userId}
/>
</Tab>
</Tabs>
</Container>
<div className="text-center">
<StatefulButton
className="font-weight-500"
type="submit"
variant="brand"
labels={{
default: formatMessage(messages['recommendation.skip.button']),
}}
onClick={handleSkip}
/>
</div>
</div>
</div>
</>
);
};
RecommendationsPage.propTypes = {};
RecommendationsPage.propTypes = {
countryCode: PropTypes.string.isRequired,
};
export default RecommendationsPage;
const mapStateToProps = state => ({
countryCode: state.register.backendCountryCode,
});
export default connect(
mapStateToProps,
null,
)(RecommendationsPage);

View File

@@ -1,61 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Skeleton } from '@edx/paragon';
import PropTypes from 'prop-types';
import loadingCoursesPlaceholders from '../data/loadingCoursesPlaceholders';
import messages from '../messages';
import RecommendationsList from '../RecommendationsList';
const RecommendationsLargeLayout = (props) => {
const {
userId,
isLoading,
personalizedRecommendations,
} = props;
const { formatMessage } = useIntl();
if (isLoading) {
return (
<>
<Skeleton height={32} width={300} className="mb-5" />
<RecommendationsList
recommendations={loadingCoursesPlaceholders}
userId={userId}
isLoading
/>
</>
);
}
if (personalizedRecommendations.length) {
return (
<span id="recommendations-large-layout">
<h1 className="h2 text-sm-center mb-5 mb-sm-4.5 text-left recommendations-container__heading">
{formatMessage(messages['recommendation.page.heading'])}
</h1>
<RecommendationsList
recommendations={personalizedRecommendations}
userId={userId}
/>
</span>
);
}
return null;
};
RecommendationsLargeLayout.propTypes = {
userId: PropTypes.number.isRequired,
isLoading: PropTypes.bool,
personalizedRecommendations: PropTypes.arrayOf(PropTypes.shape({})),
};
RecommendationsLargeLayout.defaultProps = {
isLoading: true,
personalizedRecommendations: [],
};
export default RecommendationsLargeLayout;

View File

@@ -1,61 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Skeleton } from '@edx/paragon';
import PropTypes from 'prop-types';
import loadingCoursesPlaceholders from '../data/loadingCoursesPlaceholders';
import messages from '../messages';
import RecommendationsList from '../RecommendationsList';
const RecommendationsSmallLayout = (props) => {
const {
userId,
isLoading,
personalizedRecommendations,
} = props;
const { formatMessage } = useIntl();
if (isLoading) {
return (
<>
<Skeleton height={36} className="mb-3" />
<RecommendationsList
recommendations={loadingCoursesPlaceholders}
userId={userId}
isLoading
/>
</>
);
}
if (personalizedRecommendations.length) {
return (
<span id="recommendations-small-layout">
<h1 className="h3 text-sm-center mb-4.5 text-left recommendations-container__heading">
{formatMessage(messages['recommendation.page.heading'])}
</h1>
<RecommendationsList
recommendations={personalizedRecommendations}
userId={userId}
/>
</span>
);
}
return null;
};
RecommendationsSmallLayout.propTypes = {
userId: PropTypes.number.isRequired,
isLoading: PropTypes.bool,
personalizedRecommendations: PropTypes.arrayOf(PropTypes.shape({})),
};
RecommendationsSmallLayout.defaultProps = {
isLoading: true,
personalizedRecommendations: [],
};
export default RecommendationsSmallLayout;

View File

@@ -1,51 +0,0 @@
import React from 'react';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import SmallLayout from './SmallLayout';
import mockedRecommendedProducts from '../data/tests/mockedData';
const IntlRecommendationsSmallLayoutPage = injectIntl(SmallLayout);
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock('@edx/paragon', () => ({
...jest.requireActual('@edx/paragon'),
useMediaQuery: jest.fn(),
}));
describe('RecommendationsPageTests', () => {
let props = {};
const reduxWrapper = children => (
<IntlProvider locale="en">
{children}
</IntlProvider>
);
beforeEach(() => {
props = {
userId: 123,
personalizedRecommendations: mockedRecommendedProducts,
isLoading: false,
};
});
it('should render recommendations when recommendations are not loading', () => {
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsSmallLayoutPage {...props} />));
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeFalsy();
});
it('should render loading state when recommendations are loading', () => {
props = {
...props,
isLoading: true,
};
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsSmallLayoutPage {...props} />));
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeTruthy();
});
});

View File

@@ -1,33 +0,0 @@
import { PERSONALIZED } from './constants';
const { camelCaseObject } = require('@edx/frontend-platform');
const processCourseSearchResult = (searchResultCourse) => {
const camelCasedResult = camelCaseObject(searchResultCourse);
return {
activeCourseRun: {
key: camelCasedResult.activeRunKey,
type: camelCasedResult.activeRunType,
marketingUrl: camelCasedResult.marketingUrl,
},
activeRunKey: camelCasedResult.activeRunKey,
allowedIn: camelCasedResult.allowedIn,
blockedIn: camelCasedResult.blockedIn,
cardType: 'course',
courseType: 'course',
image: {
src: camelCasedResult.cardImageUrl,
},
owners: camelCasedResult.owners,
title: camelCasedResult.title,
uuid: camelCasedResult.uuid,
objectID: `course-${camelCasedResult.uuid}`,
productSource: {
name: camelCasedResult.productSource,
},
recommendationType: PERSONALIZED,
};
};
export default processCourseSearchResult;

View File

@@ -10,9 +10,5 @@ export const EDUCATION_LEVEL_MAPPING = {
jhs: 'Introductory',
};
export const PERSONALIZED = 'personalized';
export const LEVEL_FACET = 'level';
export const PRODUCT_FACET = 'product';
export const PRODUCT_TYPE_COURSE = 'course';
export const MAX_RECOMMENDATIONS = 4;
export const POPULAR = 'popular';
export const TRENDING = 'trending';

View File

@@ -1,77 +0,0 @@
import { useEffect, useState } from 'react';
import algoliasearchHelper from 'algoliasearch-helper';
import {
getLocationRestrictionFilter,
initializeSearchClient,
} from '../../../data/algolia';
import isOneTrustFunctionalCookieEnabled from '../../../data/oneTrust';
import processCourseSearchResult from '../algoliaResultsParser';
import {
LEVEL_FACET, MAX_RECOMMENDATIONS, PRODUCT_FACET, PRODUCT_TYPE_COURSE,
} from '../constants';
const INDEX_NAME = process.env.ALGOLIA_AUTHN_RECOMMENDATIONS_INDEX;
/**
* This hooks returns Algolia recommendations only if functional cookies are enabled. * @param userCountry
* @param userCountry
* @param educationLevel
* @returns {{isLoading: boolean, recommendations: *[]}}
*/
const useAlgoliaRecommendations = (userCountry, educationLevel) => {
const functionalCookiesEnabled = isOneTrustFunctionalCookieEnabled();
const [recommendations, setRecommendations] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!functionalCookiesEnabled) {
setIsLoading(false);
return;
}
const getSearchFiltersQueryString = () => getLocationRestrictionFilter(userCountry);
const algoliaSearchParams = {
facets: [LEVEL_FACET, PRODUCT_FACET],
filters: getSearchFiltersQueryString(),
aroundLatLngViaIP: true,
};
const searchClient = initializeSearchClient();
const searchHelper = algoliasearchHelper(
searchClient,
INDEX_NAME,
algoliaSearchParams,
);
searchHelper.addFacetRefinement(PRODUCT_FACET, PRODUCT_TYPE_COURSE);
if (educationLevel) {
searchHelper.addFacetRefinement(LEVEL_FACET, educationLevel);
}
const searchIndex = () => {
searchHelper.search();
};
searchIndex();
searchHelper.on('result', ({ results }) => {
const parsedCourses = results.hits.slice(0, MAX_RECOMMENDATIONS).map(
(course) => processCourseSearchResult(course),
);
setRecommendations(parsedCourses);
setIsLoading(false);
});
searchHelper.on('error', () => setIsLoading(false));
}, [educationLevel, functionalCookiesEnabled, userCountry]);
return {
recommendations,
isLoading,
};
};
export default useAlgoliaRecommendations;

View File

@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { filterLocationRestriction } from '../utils';
export default function useProducts(countryCode) {
const [isLoading, setLoading] = useState(true);
const [popularProducts, setPopularProducts] = useState([]);
const [trendingProducts, setTrendingProducts] = useState([]);
useEffect(() => {
const popular = filterLocationRestriction(JSON.parse(getConfig().POPULAR_PRODUCTS), countryCode);
const trending = filterLocationRestriction(JSON.parse(getConfig().TRENDING_PRODUCTS), countryCode);
setPopularProducts(popular);
setTrendingProducts(trending);
setLoading(false);
}, [countryCode]);
return { popularProducts, trendingProducts, isLoading };
}

View File

@@ -1,36 +0,0 @@
const placeholderCourse = {
activeCourseRun: {
key: 'course',
marketingUrl: '/',
type: 'Verified and Audit',
},
cardType: 'course',
image: {
src: './',
},
inProspectus: true,
objectID: 'skeleton',
owners: [{
key: 'skeleton',
logoImageUrl: './',
name: 'skeleton',
}],
position: 0,
prospectusPath: './',
queryID: 'skeleton',
recentEnrollmentCount: 0,
title: 'skeleton',
topics: [{
topic: 'skeleton',
}],
uuid: 'skeleton',
};
const loadingCoursesPlaceHolders = [
{ ...placeholderCourse, uuid: '294ea4rtn2117', courseType: 'course' },
{ ...placeholderCourse, uuid: '294fga4681117', courseType: 'course' },
{ ...placeholderCourse, uuid: '294ea4278e117', courseType: 'course' },
{ ...placeholderCourse, uuid: '294eax2rtg117', courseType: 'course' },
];
export default loadingCoursesPlaceHolders;

View File

@@ -0,0 +1,22 @@
import { camelCaseObject } from '@edx/frontend-platform';
import algoliasearch from 'algoliasearch/lite';
const INDEX_NAME = process.env.ALGOLIA_AUTHN_RECOMMENDATIONS_INDEX;
const getPersonalizedRecommendations = async (educationLevel) => {
const facetFilters = ['product:Course', 'availability:Available now'];
if (educationLevel) {
facetFilters.push(`level:${educationLevel}`);
}
const client = algoliasearch(process.env.ALGOLIA_APP_ID, process.env.ALGOLIA_SEARCH_KEY);
const index = client.initIndex(INDEX_NAME);
const { hits } = await index.search('', {
aroundLatLngViaIP: true,
facetFilters,
});
return camelCaseObject(hits);
};
export default getPersonalizedRecommendations;

View File

@@ -1,56 +0,0 @@
import { renderHook } from '@testing-library/react-hooks';
import algoliasearchHelper from 'algoliasearch-helper';
import mockedRecommendedProducts from './mockedData';
import CreateAlgoliaSearchHelperMock from './test_utils/test_utils';
import isOneTrustFunctionalCookieEnabled from '../../../data/oneTrust';
import useAlgoliaRecommendations from '../hooks/useAlgoliaRecommendations';
jest.mock('algoliasearch-helper');
jest.mock('../../../data/oneTrust');
jest.mock('../../../data/algolia', () => ({
initializeSearchClient: jest.fn(),
getLocationRestrictionFilter: jest.fn((countryCode) => `NOT BLOCKED IN ${countryCode}`),
}));
jest.mock('../algoliaResultsParser', () => jest.fn((course) => course));
describe('useAlgoliaRecommendations Tests', () => {
const MockSearchHelperWithData = new CreateAlgoliaSearchHelperMock(mockedRecommendedProducts);
const MockSearchHelperWithoutData = new CreateAlgoliaSearchHelperMock();
it('should fetch recommendations only if functional cookies are set', async () => {
isOneTrustFunctionalCookieEnabled.mockImplementation(() => true);
algoliasearchHelper.mockImplementation(() => MockSearchHelperWithData);
const { result } = renderHook(
() => useAlgoliaRecommendations('PK', 'Introductory'),
);
expect(result.current.recommendations).toEqual(mockedRecommendedProducts);
expect(result.current.isLoading).toBe(false);
});
it('should not fetch recommendations if functional cookies are not set', async () => {
isOneTrustFunctionalCookieEnabled.mockImplementation(() => false);
algoliasearchHelper.mockImplementation(() => MockSearchHelperWithData);
const { result } = renderHook(
() => useAlgoliaRecommendations('PK', 'Introductory'),
);
expect(result.current.recommendations).toEqual([]);
expect(result.current.isLoading).toBe(false);
});
it('should return empty list if no recommendations returned from Algolia', async () => {
isOneTrustFunctionalCookieEnabled.mockImplementation(() => true);
algoliasearchHelper.mockImplementation(() => MockSearchHelperWithoutData);
const { result } = renderHook(
() => useAlgoliaRecommendations('PK', 'Introductory'),
);
expect(result.current.recommendations).toEqual([]);
expect(result.current.isLoading).toBe(false);
});
});

View File

@@ -1,96 +0,0 @@
const mockedRecommendedProducts = [
{
activeCourseRun: {
key: 'course-v1:TEST_COURSE_RUN',
type: 'test_course_run_type',
marketingUrl: 'test_marketingUrl',
},
activeRunKey: 'course-v1:TEST_COURSE_RUN',
recommendationType: 'personalized',
allowedIn: [],
blockedIn: [],
cardType: 'course',
courseType: 'course',
image: {
src: 'test_src',
},
owners: [],
title: 'test_title',
uuid: 'test_uuid',
objectID: 'course-test_uuid',
productSource: {
name: 'test_source',
},
},
{
activeCourseRun: {
key: 'course-v1:TEST_COURSE_RUN',
type: 'test_course_run_type',
marketingUrl: 'test_marketingUrl',
},
activeRunKey: 'course-v1:TEST_COURSE_RUN',
recommendationType: 'personalized',
allowedIn: [],
blockedIn: [],
cardType: 'course',
courseType: 'course',
image: {
src: 'test_src',
},
owners: [],
title: 'test_title',
uuid: 'test_uuid2',
objectID: 'course-test_uuid',
productSource: {
name: 'test_source',
},
},
{
activeCourseRun: {
key: 'course-v1:TEST_COURSE_RUN',
type: 'test_course_run_type',
marketingUrl: 'test_marketingUrl',
},
activeRunKey: 'course-v1:TEST_COURSE_RUN',
recommendationType: 'personalized',
allowedIn: [],
blockedIn: [],
cardType: 'course',
courseType: 'course',
image: {
src: 'test_src',
},
owners: [],
title: 'test_title',
uuid: 'test_uuid3',
objectID: 'course-test_uuid',
productSource: {
name: 'test_source',
},
},
{
activeCourseRun: {
key: 'course-v1:TEST_COURSE_RUN',
type: 'test_course_run_type',
marketingUrl: 'test_marketingUrl',
},
activeRunKey: 'course-v1:TEST_COURSE_RUN',
recommendationType: 'personalized',
allowedIn: [],
blockedIn: [],
cardType: 'course',
courseType: 'course',
image: {
src: 'test_src',
},
owners: [],
title: 'test_title',
uuid: 'test_uuid4',
objectID: 'course-test_uuid',
productSource: {
name: 'test_source',
},
},
];
export default mockedRecommendedProducts;

View File

@@ -1,26 +0,0 @@
import mockedRecommendedProducts from './mockedData';
import processCourseSearchResult from '../algoliaResultsParser';
describe('AlgoliaResultsParserTests', () => {
const dataToBeProcessed = {
activeRunKey: 'course-v1:TEST_COURSE_RUN',
activeRunType: 'test_course_run_type',
marketingUrl: 'test_marketingUrl',
minEffort: 1,
maxEffort: 2,
weeksToComplete: 3,
allowedIn: [],
blockedIn: [],
cardImageUrl: 'test_src',
owners: [],
title: 'test_title',
uuid: 'test_uuid',
recentEnrollmentCount: 1,
productSource: 'test_source',
};
it('should parse results returned by Algolia', async () => {
const parsedData = processCourseSearchResult(dataToBeProcessed);
expect(parsedData).toEqual(mockedRecommendedProducts[0]);
});
});

View File

@@ -1,63 +0,0 @@
// Algolia Search Helper mock data
class CreateAlgoliaSearchHelperMock {
hits = [];
eventCb = () => {};
derived = [];
maxLimit = 0;
refineProp = null;
refineVal = null;
constructor(mockData = [], limit = 0) {
this.hits = mockData;
this.maxLimit = limit;
}
onMock(eventName, callback) {
if (eventName === 'result') {
const hitData = this.hits.slice(0, this.maxLimit > 0 ? this.maxLimit : this.hits.length);
callback({
results: {
hits: hitData,
},
});
} else if (eventName === 'error') {
callback({});
}
}
setQueryMock = () => {};
searchMock = () => {
this.eventCb();
};
clearRefinementsMock = () => this;
refinementMock(refineBy, value) {
// addDisjunctiveFacetRefinement // addFacetRefinement
this.refineProp = refineBy;
this.refineVal = value;
return this;
}
on = this.onMock;
setQuery = this.setQueryMock;
search = this.searchMock;
derive = this.deriveMock;
clearRefinements = this.clearRefinementsMock;
addDisjunctiveFacetRefinement = this.refinementMock;
addFacetRefinement = this.refinementMock;
}
export default CreateAlgoliaSearchHelperMock;

View File

@@ -1,9 +1,9 @@
import mockedProductData from '../../tests/mockedData';
import { convertCourseRunKeyToCourseKey, filterLocationRestriction, useProductType } from '../utils';
import { convertCourseRunKeytoCourseKey, filterLocationRestriction, useProductType } from '../utils';
describe('UtilsTests', () => {
it('should return the courseKey after parsing the activeCourseRun key', async () => {
const courseKey = convertCourseRunKeyToCourseKey('course-v1:Demox+Test101+2023');
const courseKey = convertCourseRunKeytoCourseKey('course-v1:Demox+Test101+2023');
expect(courseKey).toEqual('Demox+Test101');
});
it('should filter courses on the basis of country code', async () => {

View File

@@ -1,4 +1,4 @@
export const convertCourseRunKeyToCourseKey = (courseRunKey) => {
export const convertCourseRunKeytoCourseKey = (courseRunKey) => {
if (!courseRunKey) {
return '';
}
@@ -62,4 +62,4 @@ export const filterLocationRestriction = (products, countryCode) => products.fil
},
);
export default convertCourseRunKeyToCourseKey;
export default convertCourseRunKeytoCourseKey;

View File

@@ -18,7 +18,7 @@ const messages = defineMessages({
},
'recommendation.option.trending': {
id: 'recommendation.option.trending',
defaultMessage: 'Trending Now',
defaultMessage: 'Trending',
description: 'Title for trending products',
},
'recommendation.option.popular': {
@@ -26,11 +26,6 @@ const messages = defineMessages({
defaultMessage: 'Most Popular',
description: 'Title for popular products',
},
'recommendation.option.recommended.for.you': {
id: 'recommendation.option.recommended.for.you',
defaultMessage: 'Recommended For You',
description: 'Title for personalized products',
},
});
export const cardBadgesMessages = defineMessages({

View File

@@ -0,0 +1,42 @@
import optimizelyInstance from '../data/optimizely';
const RECOMMENDATIONS_EXP_KEY = 'popular_and_trending_recommendations_exp';
const RECOMMENDATIONS_EXP_VARIATION = 'popular_and_trending_recommendations';
export const eventNames = {
recommendedCourseClicked: 'welcome_page_recommendation_card_click',
recommendationsViewed: 'welcome_page_recommendations_viewed',
};
/**
* Activate the post registration recommendations optimizely experiment
* and return the true if the user is in variation else false.
* @param {String} userId user id of authenticated user.
* @return {string} variation the user belong in
*/
const activateRecommendationsExperiment = (userId) => optimizelyInstance?.activate(RECOMMENDATIONS_EXP_KEY, userId);
/**
* Fire an optimizely track event for post registration recommended course card clicked.
* @param {String} userId user id of authenticated user.
* @param {Object} userAttributes Dictionary of user attributes (optional).
*/
const trackRecommendationCardClickOptimizely = (userId, userAttributes = {}) => {
optimizelyInstance?.track(eventNames.recommendedCourseClicked, userId, userAttributes);
};
/**
* Fire an optimizely track event for post registration recommendation viewed.
* @param {String} userId user id of authenticated user.
* @param {Object} userAttributes Dictionary of user attributes (optional).
*/
const trackRecommendationViewedOptimizely = (userId, userAttributes = {}) => {
optimizelyInstance?.track(eventNames.recommendationsViewed, userId, userAttributes);
};
export {
RECOMMENDATIONS_EXP_VARIATION,
activateRecommendationsExperiment,
trackRecommendationCardClickOptimizely,
trackRecommendationViewedOptimizely,
};

View File

@@ -1,20 +1,14 @@
import React from 'react';
import { Provider } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { useMediaQuery } from '@edx/paragon';
import { mount } from 'enzyme';
import { useLocation } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { DEFAULT_REDIRECT_URL } from '../../data/constants';
import { PERSONALIZED } from '../data/constants';
import useAlgoliaRecommendations from '../data/hooks/useAlgoliaRecommendations';
import mockedRecommendedProducts from '../data/tests/mockedData';
import RecommendationsPage from '../RecommendationsPage';
import { eventNames, getProductMapping } from '../track';
const IntlRecommendationsPage = injectIntl(RecommendationsPage);
const mockStore = configureStore();
@@ -22,20 +16,22 @@ const mockStore = configureStore();
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
jest.mock('../data/service', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock('@edx/paragon', () => ({
...jest.requireActual('@edx/paragon'),
useMediaQuery: jest.fn(),
}));
jest.mock('../data/hooks/useAlgoliaRecommendations', () => jest.fn());
describe('RecommendationsPageTests', () => {
mergeConfig({
GENERAL_RECOMMENDATIONS: '[]',
POPULAR_PRODUCTS: '[]',
TRENDING_PRODUCTS: '[]',
});
let store = {};
const dashboardUrl = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
@@ -69,11 +65,6 @@ describe('RecommendationsPageTests', () => {
useLocation.mockReturnValue({
state: {},
});
useAlgoliaRecommendations.mockReturnValue({
recommendations: mockedRecommendedProducts,
isLoading: false,
});
});
it('should redirect to dashboard if user is not coming from registration workflow', () => {
@@ -81,85 +72,23 @@ describe('RecommendationsPageTests', () => {
expect(window.location.href).toEqual(dashboardUrl);
});
it('should redirect user if no personalized recommendations are available', () => {
useAlgoliaRecommendations.mockReturnValue({
recommendations: [],
isLoading: false,
});
it('should redirect if either popular or trending recommendations are not configured', () => {
mockUseLocation();
mount(reduxWrapper(<IntlRecommendationsPage />));
expect(window.location.href).toEqual(dashboardUrl);
expect(window.location.href).toEqual(redirectUrl);
});
it('should redirect user if they click "Skip for now" button', () => {
mockUseLocation();
jest.useFakeTimers();
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
recommendationsPage.find('.pgn__stateful-btn-state-default').first().simulate('click');
jest.advanceTimersByTime(300);
expect(window.location.href).toEqual(redirectUrl);
});
it('should display recommendations small layout for small screen', () => {
it('displays popular products as default recommendations', () => {
mockUseLocation();
useMediaQuery.mockReturnValue(true);
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
expect(recommendationsPage.find('#recommendations-small-layout').exists()).toBeTruthy();
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeFalsy();
});
it('should display recommendations large layout for large screen', () => {
mockUseLocation();
useMediaQuery.mockReturnValue(false);
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
expect(recommendationsPage.find('.pgn_collapsible').exists()).toBeFalsy();
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeFalsy();
});
it('should display skeletons if recommendations are loading for large screen', () => {
mockUseLocation();
useMediaQuery.mockReturnValue(false);
useAlgoliaRecommendations.mockReturnValueOnce({
recommendations: [],
isLoading: true,
});
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeTruthy();
});
it('should display skeletons if recommendations are loading for small screen', () => {
mockUseLocation();
useMediaQuery.mockReturnValue(true);
useAlgoliaRecommendations.mockReturnValueOnce({
recommendations: [],
isLoading: true,
});
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeTruthy();
});
it('should fire recommendations viewed event', () => {
mockUseLocation();
useAlgoliaRecommendations.mockReturnValue({
recommendations: mockedRecommendedProducts,
isLoading: false,
});
useMediaQuery.mockReturnValue(false);
mount(reduxWrapper(<IntlRecommendationsPage />));
expect(sendTrackEvent).toBeCalled();
expect(sendTrackEvent).toHaveBeenCalledWith(
eventNames.recommendationsViewed,
{
page: 'authn_recommendations',
recommendation_type: PERSONALIZED,
products: getProductMapping(mockedRecommendedProducts),
user_id: 111,
},
);
expect(recommendationsPage.find('.nav-link .active a').text()).toEqual('Most Popular');
});
});

View File

@@ -1,131 +0,0 @@
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
eventNames, trackRecommendationClick, trackRecommendationsViewed, trackSkipButtonClicked,
} from '../track';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
describe('SegmentEventTrackingTest', () => {
global.open = jest.fn();
const userId = 1;
it('test click event is fired properly and correct link opens for program product type', async () => {
jest.useFakeTimers();
const program = {
cardType: 'program',
title: 'test program',
uuid: 'test_uuid',
productSource: {
name: 'test_source',
},
recommendationType: 'static',
url: 'test_url',
};
const position = 0;
trackRecommendationClick(program, position, userId);
jest.advanceTimersByTime(300);
expect(sendTrackEvent).toBeCalled();
expect(sendTrackEvent).toHaveBeenCalledWith(
eventNames.recommendedProductClicked,
{
page: 'authn_recommendations',
position,
product_key: `${program.title} [${program.uuid}]`,
product_line: program.cardType,
product_source: program.productSource.name,
recommendation_type: program.recommendationType,
user_id: userId,
},
);
expect(global.open).toBeCalled();
expect(global.open).toHaveBeenCalledWith(program.url, '_blank');
});
it('test click event is fired properly and correct link opens for course product type', async () => {
jest.useFakeTimers();
const course = {
cardType: 'course',
activeRunKey: 'test_key',
productSource: {
name: 'test_source',
},
recommendationType: 'static',
activeCourseRun: {
marketingUrl: 'test_url',
},
};
const position = 0;
trackRecommendationClick(course, position, userId);
jest.advanceTimersByTime(300);
expect(sendTrackEvent).toBeCalled();
expect(sendTrackEvent).toHaveBeenCalledWith(
eventNames.recommendedProductClicked,
{
page: 'authn_recommendations',
position,
product_key: course.activeRunKey,
product_line: course.cardType,
product_source: course.productSource.name,
recommendation_type: course.recommendationType,
user_id: userId,
},
);
expect(global.open).toBeCalled();
expect(global.open).toHaveBeenCalledWith(course.activeCourseRun.marketingUrl, '_blank');
});
it('test viewed events are fired properly', () => {
const productList = [
{
title: 'Test Program',
uuid: '1234-5678-9101-1213',
cardType: 'program',
productSource: {
name: 'org name',
},
},
];
const recommendationsType = 'static';
const expectedProductList = [
{
product_key: 'Test Program [1234-5678-9101-1213]',
product_line: 'program',
product_source: 'org name',
},
];
trackRecommendationsViewed(productList, recommendationsType, userId);
expect(sendTrackEvent).toBeCalled();
expect(sendTrackEvent).toHaveBeenCalledWith(
eventNames.recommendationsViewed,
{
page: 'authn_recommendations',
products: expectedProductList,
recommendation_type: recommendationsType,
user_id: userId,
},
);
});
it('test skip button event is fired with correct properties', () => {
trackSkipButtonClicked(userId);
expect(sendTrackEvent).toBeCalled();
expect(sendTrackEvent).toHaveBeenCalledWith(
eventNames.skipButtonClicked,
{
page: 'authn_recommendations',
user_id: userId,
},
);
});
});

View File

@@ -4,21 +4,25 @@ export const LINK_TIMEOUT = 300;
export const eventNames = {
recommendedProductClicked: 'edx.bi.user.recommended.product.clicked',
recommendationsGroup: 'edx.bi.user.recommendations.group',
recommendationsViewed: 'edx.bi.user.recommendations.viewed',
skipButtonClicked: 'edx.bi.user.recommendations.skip.btn.clicked',
};
const generateProductKey = (product) => (
product.cardType === 'program' ? `${product.title} [${product.uuid}]` : product.activeRunKey
);
export const createLinkTracker = (tracker, href, openInNewTab = false) => (e) => {
e.preventDefault();
tracker();
if (openInNewTab) {
return setTimeout(() => { global.open(href, '_blank'); }, LINK_TIMEOUT);
}
return setTimeout(() => { global.location.href = href; }, LINK_TIMEOUT);
};
export const getProductMapping = (recommendedProducts) => recommendedProducts.map((product) => ({
product_key: generateProductKey(product),
product_line: product.cardType,
product_source: product.productSource.name,
}));
const generateProductKey = (product) => {
const productKey = product.cardType === 'program' ? `${product.title} [${product.uuid}]` : product.activeRunKey;
return productKey;
};
export const trackRecommendationClick = (product, position, userId) => {
export const trackRecommendationClick = (product, position, isControl, userId) => {
sendTrackEvent(eventNames.recommendedProductClicked, {
page: 'authn_recommendations',
position,
@@ -26,31 +30,33 @@ export const trackRecommendationClick = (product, position, userId) => {
product_key: generateProductKey(product),
product_line: product.cardType,
product_source: product.productSource.name,
is_control: isControl,
user_id: userId,
});
const productUrl = product.url || product?.activeCourseRun?.marketingUrl;
return setTimeout(() => { global.open(productUrl, '_blank'); }, LINK_TIMEOUT);
return setTimeout(() => { global.open(product.url, '_blank'); }, LINK_TIMEOUT);
};
export const trackRecommendationsViewed = (recommendedProducts, type, userId) => {
const viewedProductsList = getProductMapping(recommendedProducts);
if (viewedProductsList && viewedProductsList.length) {
sendTrackEvent(
eventNames.recommendationsViewed, {
page: 'authn_recommendations',
recommendation_type: type,
products: viewedProductsList,
user_id: userId,
},
);
}
};
export const trackSkipButtonClicked = (userId) => {
export const trackRecommendationsViewed = (recommendedProducts, type, isControl, userId) => {
const viewedProductsList = recommendedProducts.map((product) => ({
product_key: generateProductKey(product),
product_line: product.cardType,
product_source: product.productSource.name,
}));
sendTrackEvent(
eventNames.skipButtonClicked, {
eventNames.recommendationsViewed, {
page: 'authn_recommendations',
recommendation_type: type,
products: viewedProductsList,
is_control: isControl,
user_id: userId,
},
);
};
export const trackRecommendationsGroup = (variation, userId) => {
sendTrackEvent(
eventNames.recommendationsGroup, {
variation,
page: 'authn_recommendations',
user_id: userId,
},
@@ -59,6 +65,6 @@ export const trackSkipButtonClicked = (userId) => {
export default {
trackRecommendationClick,
trackRecommendationsGroup,
trackRecommendationsViewed,
trackSkipButtonClicked,
};

View File

@@ -1,13 +1,16 @@
import React, { useEffect, useMemo } from 'react';
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { getCountryList, getLocale, useIntl } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { FormFieldRenderer } from '../../field-renderer';
import { FIELDS } from '../data/constants';
import messages from '../messages';
import { CountryField, HonorCode, TermsOfService } from '../RegistrationFields';
import { COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY, FIELDS } from './data/constants';
import { validateCountryField } from './data/utils';
import messages from './messages';
import { HonorCode, TermsOfService } from './registrationFields';
import CountryField from './registrationFields/CountryField';
import { FormFieldRenderer } from '../field-renderer';
/**
* Fields on registration page that are not the default required fields (name, email, username, password).
@@ -24,16 +27,17 @@ import { CountryField, HonorCode, TermsOfService } from '../RegistrationFields';
const ConfigurableRegistrationForm = (props) => {
const { formatMessage } = useIntl();
const {
countryList,
email,
fieldDescriptions,
fieldErrors,
formFields,
setFieldErrors,
setFocusedField,
setFormFields,
autoSubmitRegistrationForm,
registrationEmbedded,
} = props;
const countryList = useMemo(() => getCountryList(getLocale()), []);
const backendCountryCode = useSelector(state => state.register.backendCountryCode);
let showTermsOfServiceAndHonorCode = false;
let showCountryField = false;
@@ -52,34 +56,28 @@ const ConfigurableRegistrationForm = (props) => {
}
});
/**
* If auto submitting register form, we will check tos and honor code fields if they exist for feature parity.
*/
useEffect(() => {
if (autoSubmitRegistrationForm) {
if (Object.keys(fieldDescriptions).includes(FIELDS.HONOR_CODE)) {
setFormFields(prevState => ({
...prevState,
[FIELDS.HONOR_CODE]: true,
}));
}
if (Object.keys(fieldDescriptions).includes(FIELDS.TERMS_OF_SERVICE)) {
setFormFields(prevState => ({
...prevState,
[FIELDS.TERMS_OF_SERVICE]: true,
}));
}
}
}, [autoSubmitRegistrationForm]); // eslint-disable-line react-hooks/exhaustive-deps
if (backendCountryCode && backendCountryCode !== formFields?.country?.countryCode) {
let countryCode = '';
let countryDisplayValue = '';
const handleErrorChange = (fieldName, error) => {
if (fieldName) {
setFieldErrors(prevErrors => ({
...prevErrors,
[fieldName]: error,
}));
const selectedCountry = countryList.find(
(country) => (country[COUNTRY_CODE_KEY].toLowerCase() === backendCountryCode.toLowerCase()),
);
if (selectedCountry) {
countryCode = selectedCountry[COUNTRY_CODE_KEY];
countryDisplayValue = selectedCountry[COUNTRY_DISPLAY_KEY];
}
setFormFields(prevState => (
{
...prevState,
country: {
countryCode, displayValue: countryDisplayValue,
},
}
));
}
};
}, [backendCountryCode, countryList]); // eslint-disable-line react-hooks/exhaustive-deps
const handleOnChange = (event, countryValue = null) => {
const { name } = event.target;
@@ -98,17 +96,31 @@ const ConfigurableRegistrationForm = (props) => {
const handleOnBlur = (event) => {
const { name, value } = event.target;
let error = '';
if ((!value || !value.trim()) && fieldDescriptions[name]?.error_message) {
if (name === 'country') {
const countryValidation = validateCountryField(
value.trim(), countryList, formatMessage(messages['empty.country.field.error']),
);
const { countryCode, displayValue } = countryValidation;
error = countryValidation.error;
setFormFields(prevState => ({ ...prevState, country: { countryCode, displayValue } }));
} else if (!value || !value.trim()) {
error = fieldDescriptions[name].error_message;
} else if (name === 'confirm_email' && value !== email) {
error = formatMessage(messages['email.do.not.match']);
}
if (registrationEmbedded) {
return;
}
setFocusedField(null);
setFieldErrors(prevErrors => ({ ...prevErrors, [name]: error }));
};
const handleOnFocus = (event) => {
const { name } = event.target;
setFieldErrors(prevErrors => ({ ...prevErrors, [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 (flags.showConfigurableRegistrationFields) {
@@ -171,7 +183,6 @@ const ConfigurableRegistrationForm = (props) => {
selectedCountry={formFields.country}
errorMessage={fieldErrors.country || ''}
onChangeHandler={handleOnChange}
handleErrorChange={handleErrorChange}
onBlurHandler={handleOnBlur}
onFocusHandler={handleOnFocus}
/>
@@ -217,6 +228,7 @@ const ConfigurableRegistrationForm = (props) => {
};
ConfigurableRegistrationForm.propTypes = {
countryList: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
email: PropTypes.string.isRequired,
fieldDescriptions: PropTypes.shape({}),
fieldErrors: PropTypes.shape({
@@ -231,13 +243,15 @@ ConfigurableRegistrationForm.propTypes = {
marketingEmailsOptIn: PropTypes.bool,
}).isRequired,
setFieldErrors: PropTypes.func.isRequired,
setFocusedField: PropTypes.func,
setFormFields: PropTypes.func.isRequired,
autoSubmitRegistrationForm: PropTypes.bool,
registrationEmbedded: PropTypes.bool,
};
ConfigurableRegistrationForm.defaultProps = {
fieldDescriptions: {},
autoSubmitRegistrationForm: false,
registrationEmbedded: false,
setFocusedField: () => {},
};
export default ConfigurableRegistrationForm;

View File

@@ -0,0 +1,555 @@
import React, {
useEffect, useMemo, useState,
} from 'react';
import { connect } from 'react-redux';
import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
import { sendPageEvent } from '@edx/frontend-platform/analytics';
import {
getCountryList, getLocale, useIntl,
} from '@edx/frontend-platform/i18n';
import { Form, StatefulButton } from '@edx/paragon';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import ConfigurableRegistrationForm from './ConfigurableRegistrationForm';
import {
clearRegistertionBackendError,
clearUsernameSuggestions,
fetchRealtimeValidations,
registerNewUser,
} 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 {
getAllPossibleQueryParams, setCookie,
} from '../data/utils';
const EmbeddableRegistrationPage = (props) => {
const {
backendCountryCode,
backendValidations,
fieldDescriptions,
registrationError,
registrationErrorCode,
registrationResult,
submitState,
usernameSuggestions,
validationApiRateLimited,
// Actions
getRegistrationDataFromBackend,
validateFromBackend,
clearBackendError,
} = props;
const { formatMessage } = useIntl();
const countryList = useMemo(() => getCountryList(getLocale()), []);
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
const { cta, host } = queryParams;
const flags = {
showConfigurableEdxFields: getConfig().SHOW_CONFIGURABLE_EDX_FIELDS,
showConfigurableRegistrationFields: getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS,
showMarketingEmailOptInCheckbox: getConfig().MARKETING_EMAILS_OPT_IN,
};
const [formFields, setFormFields] = useState({
email: '',
name: '',
password: '',
username: '',
});
const [configurableFormFields, setConfigurableFormFields] = useState({
marketingEmailsOptIn: true,
});
const [errors, setErrors] = useState({
email: '',
name: '',
password: '',
username: '',
});
const [emailSuggestion, setEmailSuggestion] = useState({ suggestion: '', type: '' });
const [errorCode, setErrorCode] = useState({ type: '', count: 0 });
const [formStartTime, setFormStartTime] = useState(null);
const [, setFocusedField] = useState(null);
const buttonLabel = cta ? formatMessage(messages['create.account.cta.button'], { label: cta }) : formatMessage(messages['create.account.for.free.button']);
useEffect(() => {
if (!formStartTime) {
sendPageEvent('login_and_registration', 'register');
const payload = { ...queryParams, is_register_page: true };
getRegistrationDataFromBackend(payload);
setFormStartTime(Date.now());
}
}, [formStartTime, getRegistrationDataFromBackend, queryParams]);
useEffect(() => {
if (backendValidations) {
setErrors(prevErrors => ({ ...prevErrors, ...backendValidations }));
}
}, [backendValidations]);
useEffect(() => {
if (registrationErrorCode) {
setErrorCode(prevState => ({ type: registrationErrorCode, count: prevState.count + 1 }));
}
}, [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
window.optimizely = window.optimizely || [];
window.optimizely.push({
type: 'event',
eventName: 'authn-registration-conversion',
});
// We probably don't need this cookie because this fires the same event as
// above for optimizely using GTM.
setCookie(getConfig().REGISTER_CONVERSION_COOKIE_NAME, true);
// This is used by the "User Retention Rate Event" on GTM
setCookie('authn-returning-user');
// Fire GTM event used for integration with impact.com
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'ImpactRegistrationEvent',
});
window.parent.postMessage({
action: REDIRECT,
redirectUrl: encodeURIComponent(getConfig().POST_REGISTRATION_REDIRECT_URL),
}, host);
}
}, [registrationResult, host]);
const validateInput = (fieldName, value, payload, shouldValidateFromBackend, shouldSetErrors = true) => {
let fieldError = '';
switch (fieldName) {
case 'name':
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.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;
}
setEmailSuggestion({ ...response });
}
}
break;
case 'username':
if (!value.match(USERNAME_REGEX)) {
fieldError = formatMessage(messages['username.format.validation.message']);
}
break;
case 'password':
if (!value || !LETTER_REGEX.test(value) || !NUMBER_REGEX.test(value) || value.length < 8) {
fieldError = formatMessage(messages['password.validation.message']);
}
break;
case 'country':
if (flags.showConfigurableEdxFields || flags.showConfigurableRegistrationFields) {
const {
countryCode, displayValue, error,
} = validateCountryField(value.trim(), countryList, formatMessage(messages['empty.country.field.error']));
fieldError = error;
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 (shouldSetErrors && fieldError) {
setErrors(prevErrors => ({
...prevErrors,
[fieldName]: fieldError,
}));
}
return fieldError;
};
const isFormValid = (payload) => {
const fieldErrors = { ...errors };
let isValid = true;
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;
}
});
}
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;
if (registrationError[name]) {
clearBackendError(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;
if (name === 'name') {
validateInput(
name,
value,
{ name: formFields.name, username: formFields.username, form_field_key: name },
!validationApiRateLimited,
false,
);
}
if (name === 'email') {
validateInput(name, value, null, !validationApiRateLimited, false);
}
};
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 handleSubmit = (e) => {
e.preventDefault();
const totalRegistrationTime = (Date.now() - formStartTime) / 1000;
let payload = { ...formFields };
if (!isFormValid(payload)) {
setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 }));
return;
}
Object.keys(configurableFormFields).forEach((fieldName) => {
if (fieldName === 'country') {
payload[fieldName] = configurableFormFields[fieldName].countryCode;
} else {
payload[fieldName] = configurableFormFields[fieldName];
}
});
// Don't send the marketing email opt-in value if the flag is turned off
if (!flags.showMarketingEmailOptInCheckbox) {
delete payload.marketingEmailsOptIn;
}
let isValid = true;
Object.entries(payload).forEach(([key, value]) => {
if (validateInput(key, value, payload, false, true) !== '') {
isValid = false;
}
});
if (!isValid) {
setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 }));
return;
}
payload = snakeCaseObject(payload);
payload.totalRegistrationTime = totalRegistrationTime;
// add query params to the payload
payload = { ...payload, ...queryParams };
props.registerNewUser(payload);
};
return (
<>
<Helmet>
<title>{formatMessage(messages['register.page.title'], { siteName: getConfig().SITE_NAME })}</title>
</Helmet>
<div
className="mw-xs mt-3 w-100 m-auto pt-4 main-content"
>
<RegistrationFailure
errorCode={errorCode.type}
failureCount={errorCode.count}
/>
<Form id="registration-form" name="registration-form">
<FormGroup
name="name"
value={formFields.name}
handleChange={handleOnChange}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
errorMessage={errors.name}
helpText={[formatMessage(messages['help.text.name'])]}
floatingLabel={formatMessage(messages['registration.fullname.label'])}
/>
<EmailField
name="email"
value={formFields.email}
handleChange={handleOnChange}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
handleSuggestionClick={(e) => handleSuggestionClick(e, 'email')}
handleOnClose={handleEmailSuggestionClosed}
emailSuggestion={emailSuggestion}
errorMessage={errors.email}
helpText={[formatMessage(messages['help.text.email'])]}
floatingLabel={formatMessage(messages['registration.email.label'])}
/>
<UsernameField
name="username"
spellCheck="false"
value={formFields.username}
handleBlur={handleOnBlur}
handleChange={handleOnChange}
handleFocus={handleOnFocus}
handleSuggestionClick={handleSuggestionClick}
handleUsernameSuggestionClose={handleUsernameSuggestionClosed}
usernameSuggestions={usernameSuggestions}
errorMessage={errors.username}
helpText={[formatMessage(messages['help.text.username.1']), formatMessage(messages['help.text.username.2'])]}
floatingLabel={formatMessage(messages['registration.username.label'])}
/>
<PasswordField
name="password"
value={formFields.password}
handleChange={handleOnChange}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
errorMessage={errors.password}
floatingLabel={formatMessage(messages['registration.password.label'])}
/>
<ConfigurableRegistrationForm
countryList={countryList}
email={formFields.email}
fieldErrors={errors}
registrationEmbedded
formFields={configurableFormFields}
setFieldErrors={setErrors}
setFormFields={setConfigurableFormFields}
setFocusedField={setFocusedField}
fieldDescriptions={fieldDescriptions}
/>
<StatefulButton
id="register-user"
name="register-user"
type="submit"
variant="brand"
className="register-button mt-4 mb-4"
state={submitState}
labels={{
default: buttonLabel,
pending: '',
}}
onClick={handleSubmit}
onMouseDown={(e) => e.preventDefault()}
/>
</Form>
</div>
</>
);
};
const mapStateToProps = state => {
const registerPageState = state.register;
return {
backendCountryCode: registerPageState.backendCountryCode,
backendValidations: validationsSelector(state),
fieldDescriptions: fieldDescriptionSelector(state),
registrationError: registerPageState.registrationError,
registrationErrorCode: registrationErrorSelector(state),
registrationResult: registerPageState.registrationResult,
submitState: registerPageState.submitState,
validationApiRateLimited: registerPageState.validationApiRateLimited,
usernameSuggestions: registerPageState.usernameSuggestions,
};
};
EmbeddableRegistrationPage.propTypes = {
backendCountryCode: PropTypes.string,
backendValidations: PropTypes.shape({
name: PropTypes.string,
email: PropTypes.string,
username: PropTypes.string,
password: PropTypes.string,
}),
fieldDescriptions: PropTypes.shape({}),
registrationError: PropTypes.shape({}),
registrationErrorCode: PropTypes.string,
registrationResult: PropTypes.shape({
redirectUrl: PropTypes.string,
success: PropTypes.bool,
}),
submitState: PropTypes.string,
usernameSuggestions: PropTypes.arrayOf(PropTypes.string),
validationApiRateLimited: PropTypes.bool,
// Actions
clearBackendError: PropTypes.func.isRequired,
getRegistrationDataFromBackend: PropTypes.func.isRequired,
registerNewUser: PropTypes.func.isRequired,
resetUsernameSuggestions: PropTypes.func.isRequired,
validateFromBackend: PropTypes.func.isRequired,
};
EmbeddableRegistrationPage.defaultProps = {
backendCountryCode: '',
backendValidations: null,
fieldDescriptions: {},
registrationError: {},
registrationErrorCode: '',
registrationResult: null,
submitState: DEFAULT_STATE,
usernameSuggestions: [],
validationApiRateLimited: false,
};
export default connect(
mapStateToProps,
{
clearBackendError: clearRegistertionBackendError,
getRegistrationDataFromBackend: getThirdPartyAuthContext,
resetUsernameSuggestions: clearUsernameSuggestions,
validateFromBackend: fetchRealtimeValidations,
registerNewUser,
},
)(EmbeddableRegistrationPage);

View File

@@ -6,14 +6,14 @@ import { Alert } from '@edx/paragon';
import { Error } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import { windowScrollTo } from '../../data/utils';
import {
FORBIDDEN_REQUEST,
INTERNAL_SERVER_ERROR,
TPA_AUTHENTICATION_FAILURE,
TPA_SESSION_EXPIRED,
} from '../data/constants';
import messages from '../messages';
} from './data/constants';
import messages from './messages';
import { windowScrollTo } from '../data/utils';
const RegistrationFailureMessage = (props) => {
const { formatMessage } = useIntl();

View File

@@ -1,143 +0,0 @@
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 validateCountryField, { COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY } from './validator';
import { clearRegistrationBackendError } from '../../data/actions';
import messages from '../../messages';
/**
* Country field wrapper. It accepts following handlers
* - handleChange for setting value change and
* - handleErrorChange for setting error
*
* It is responsible for
* - Auto populating country field if backendCountryCode is available in redux
* - Performing country field validations
* - clearing error on focus
* - setting value on change and selection
*/
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' } },
{ 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' } }, { countryCode, displayValue });
handleErrorChange('country', error);
};
const handleSelected = (value) => {
handleOnBlur({ target: { name: 'country', value } });
};
const handleOnFocus = (event) => {
handleErrorChange('country', '');
dispatch(clearRegistrationBackendError('country'));
onFocusHandler(event);
};
const handleOnChange = (value) => {
onChangeHandler({ target: { name: 'country' } }, { countryCode: '', displayValue: value });
};
const getCountryList = () => countryList.map((country) => (
<FormAutosuggestOption key={country[COUNTRY_CODE_KEY]}>
{country[COUNTRY_DISPLAY_KEY]}
</FormAutosuggestOption>
));
return (
<div className="mb-4">
<FormAutosuggest
floatingLabel={formatMessage(messages['registration.country.label'])}
aria-label="form autosuggest"
name="country"
value={selectedCountry.displayValue || ''}
onSelected={(value) => handleSelected(value)}
onFocus={(e) => handleOnFocus(e)}
onBlur={(e) => handleOnBlur(e)}
onChange={(value) => handleOnChange(value)}
>
{getCountryList()}
</FormAutosuggest>
{props.errorMessage !== '' && (
<FormControlFeedback
key="error"
className="form-text-size"
hasIcon={false}
feedback-for="country"
type="invalid"
>
{props.errorMessage}
</FormControlFeedback>
)}
</div>
);
};
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;

View File

@@ -1,180 +0,0 @@
import React from 'react';
import { Provider } from 'react-redux';
import { mergeConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY } from './validator';
import { CountryField } from '../index';
const IntlCountryField = injectIntl(CountryField);
const mockStore = configureStore();
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
// eslint-disable-next-line react/prop-types
const Navigate = ({ to }) => {
mockNavigation(to);
return <div />;
};
return {
...jest.requireActual('react-router-dom'),
Navigate,
mockNavigate: mockNavigation,
};
});
describe('CountryField', () => {
let props = {};
let store = {};
const reduxWrapper = children => (
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
);
const routerWrapper = children => (
<Router>
{children}
</Router>
);
const initialState = {
register: {},
};
beforeEach(() => {
store = mockStore(initialState);
props = {
countryList: [{
[COUNTRY_CODE_KEY]: 'PK',
[COUNTRY_DISPLAY_KEY]: 'Pakistan',
}],
selectedCountry: {
countryCode: '',
displayValue: '',
},
errorMessage: '',
onChangeHandler: jest.fn(),
handleErrorChange: jest.fn(),
onFocusHandler: jest.fn(),
};
window.location = { search: '' };
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Test Country Field', () => {
mergeConfig({
SHOW_CONFIGURABLE_EDX_FIELDS: true,
});
const emptyFieldValidation = {
country: 'Select your country or region of residence',
};
it('should run country field validation when onBlur is fired', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('blur', { target: { value: '', name: 'country' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'country',
emptyFieldValidation.country,
);
});
it('should not run country field validation when onBlur is fired by drop-down arrow icon click', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('blur', {
target: { value: '', name: 'country' },
relatedTarget: { type: 'button', className: 'btn-icon pgn__form-autosuggest__icon-button' },
});
expect(props.handleErrorChange).toHaveBeenCalledTimes(0);
});
it('should update errors for frontend validations', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('blur', { target: { value: '', name: 'country' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'country',
emptyFieldValidation.country,
);
});
it('should clear error on focus', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('focus', { target: { value: '', name: 'country' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'country',
'',
);
});
it('should update state from country code present in redux store', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
backendCountryCode: 'PK',
},
});
mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
expect(props.onChangeHandler).toHaveBeenCalledTimes(1);
expect(props.onChangeHandler).toHaveBeenCalledWith(
{ target: { name: 'country' } },
{ countryCode: 'PK', displayValue: 'Pakistan' },
);
});
it('should set option on dropdown menu item click', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('.pgn__form-autosuggest__icon-button').first().simulate('click');
countryField.find('.dropdown-item').first().simulate('click');
expect(props.onChangeHandler).toHaveBeenCalledTimes(1);
expect(props.onChangeHandler).toHaveBeenCalledWith(
{ target: { name: 'country' } },
{ countryCode: 'PK', displayValue: 'Pakistan' },
);
});
it('should set value on change', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate(
'change', { target: { value: 'pak', name: 'country' } },
);
expect(props.onChangeHandler).toHaveBeenCalledTimes(1);
expect(props.onChangeHandler).toHaveBeenCalledWith(
{ target: { name: 'country' } },
{ countryCode: '', displayValue: 'pak' },
);
});
it('should display error on invalid country input', () => {
props = {
...props,
errorMessage: 'country error message',
};
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
expect(countryField.find('div[feedback-for="country"]').text()).toEqual('country error message');
});
});
});

View File

@@ -1,29 +0,0 @@
export const COUNTRY_CODE_KEY = 'code';
export const COUNTRY_DISPLAY_KEY = 'name';
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;

View File

@@ -1,141 +0,0 @@
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 validateEmail from './validator';
import { FormGroup } from '../../../common-components';
import {
backupRegistrationFormBegin,
clearRegistrationBackendError,
fetchRealtimeValidations,
} from '../../data/actions';
import messages from '../../messages';
/**
* Email field wrapper. It accepts following handlers
* - handleChange for setting value change and
* - handleErrorChange for setting error
*
* It is responsible for
* - Generating email warning and error suggestions
* - Setting and clearing email suggestions
* - Performing email field validations
* - clearing error on focus
* - setting value on change
*/
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);
if (confirmEmailError) {
handleErrorChange('confirm_email', confirmEmailError);
}
dispatch(backupRegistrationFormBegin({
...backedUpFormData,
emailSuggestion: { ...suggestion },
}));
setEmailSuggestion(suggestion);
if (fieldError) {
handleErrorChange('email', fieldError);
} else if (!validationApiRateLimited) {
dispatch(fetchRealtimeValidations({ email: value }));
}
};
const handleOnFocus = () => {
handleErrorChange('email', '');
dispatch(clearRegistrationBackendError('email'));
};
const handleSuggestionClick = (event) => {
event.preventDefault();
handleErrorChange('email', '');
handleChange({ target: { name: 'email', value: emailSuggestion.suggestion } });
setEmailSuggestion({ suggestion: '', type: '' });
};
const handleSuggestionClosed = () => setEmailSuggestion({ suggestion: '', type: '' });
const renderEmailFeedback = () => {
if (emailSuggestion.type === 'error') {
return (
<Alert variant="danger" className="email-suggestion-alert-error mt-1" icon={Error}>
<span className="email-suggestion__text">
{formatMessage(messages['did.you.mean.alert.text'])}{' '}
<Alert.Link
href="#"
name="email"
onClick={handleSuggestionClick}
>
{emailSuggestion.suggestion}
</Alert.Link>?
<Icon src={Close} className="email-suggestion__close" onClick={handleSuggestionClosed} tabIndex="0" />
</span>
</Alert>
);
}
return (
<span id="email-warning" className="small">
{formatMessage(messages['did.you.mean.alert.text'])}:{' '}
<Alert.Link
href="#"
name="email"
className="email-suggestion-alert-warning"
onClick={handleSuggestionClick}
>
{emailSuggestion.suggestion}
</Alert.Link>?
</span>
);
};
return (
<FormGroup
borderClass={emailSuggestion.type === 'warning' ? 'yellow-border' : ''}
maxLength={254} // Limit per RFCs is 254
{...props}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
>
{emailSuggestion.suggestion ? renderEmailFeedback() : null}
</FormGroup>
);
};
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;

View File

@@ -1,207 +0,0 @@
import React from 'react';
import { Provider } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { clearRegistrationBackendError, fetchRealtimeValidations } from '../../data/actions';
import { EmailField } from '../index';
const IntlEmailField = injectIntl(EmailField);
const mockStore = configureStore();
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
// eslint-disable-next-line react/prop-types
const Navigate = ({ to }) => {
mockNavigation(to);
return <div />;
};
return {
...jest.requireActual('react-router-dom'),
Navigate,
mockNavigate: mockNavigation,
};
});
describe('EmailField', () => {
let props = {};
let store = {};
const reduxWrapper = children => (
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
);
const routerWrapper = children => (
<Router>
{children}
</Router>
);
const initialState = {
register: {},
};
beforeEach(() => {
store = mockStore(initialState);
props = {
name: 'email',
value: '',
errorMessage: '',
handleChange: jest.fn(),
floatingLabel: '',
handleErrorChange: jest.fn(),
confirmEmailValue: '',
};
window.location = { search: '' };
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Test Email Field', () => {
const emptyFieldValidation = {
email: 'Enter your email',
};
it('should run email field validation when onBlur is fired', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate('blur', { target: { value: '', name: 'email' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'email',
emptyFieldValidation.email,
);
});
it('should update errors for frontend validations', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate('blur', { target: { value: 'ab', name: 'email' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'email',
'Enter a valid email address',
);
});
it('should clear error on focus', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate('focus', { target: { value: '', name: 'email' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'email',
'',
);
});
it('should call backend validation api on blur event, if frontend validations have passed', () => {
store.dispatch = jest.fn(store.dispatch);
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
// Enter a valid email so that frontend validations are passed
emailField.find('input#email').simulate('blur', { target: { value: 'test@gmail.com', name: 'email' } });
expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations({ email: 'test@gmail.com' }));
});
it('should give email suggestions for common service provider domain typos', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate('blur', { target: { value: 'john@yopmail.com', name: 'email' } });
expect(emailField.find('#email-warning').text()).toEqual('Did you mean: john@hotmail.com?');
});
it('should be able to click on email suggestions and set it as value', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate('blur', { target: { value: 'john@yopmail.com', name: 'email' } });
emailField.find('.email-suggestion-alert-warning').first().simulate('click');
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'email', value: 'john@hotmail.com' } },
);
});
it('should give error for common top level domain mistakes', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate(
'blur', { target: { value: 'john@gmail.mistake', name: 'email' } },
);
expect(emailField.find('.alert-danger').text()).toEqual('Did you mean john@gmail.com?');
});
it('should give error and suggestion for invalid email', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate(
'blur', { target: { value: 'john@gmail', name: 'email' } },
);
expect(emailField.find('.alert-danger').text()).toEqual('Did you mean john@gmail.com?');
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'email',
'Enter a valid email address',
);
});
it('should clear the registration validation error on focus on field', () => {
store.dispatch = jest.fn(store.dispatch);
store = mockStore({
...initialState,
register: {
...initialState.register,
registrationError: {
errorCode: 'duplicate-email',
email: [{ userMessage: `This email is already associated with an existing or previous ${ getConfig().SITE_NAME } account` }],
},
},
});
store.dispatch = jest.fn(store.dispatch);
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate('focus', { target: { value: 'a@gmail.com', name: 'email' } });
expect(store.dispatch).toHaveBeenCalledWith(clearRegistrationBackendError('email'));
});
it('should clear email suggestions when close icon is clicked', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate(
'blur', { target: { value: 'john@gmail.mistake', name: 'email' } },
);
expect(emailField.find('.alert-danger').text()).toEqual('Did you mean john@gmail.com?');
emailField.find('.email-suggestion__close').at(0).simulate('click');
expect(emailField.find('.alert-danger').exists()).toBeFalsy();
});
it('should set confirm email error if it exist', () => {
props = {
...props,
confirmEmailValue: 'confirmEmail@yopmail.com',
};
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate(
'blur', { target: { value: 'differentEmail@yopmail.com', name: 'email' } },
);
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'confirm_email',
'The email addresses do not match.',
);
});
});
});

View File

@@ -1,131 +0,0 @@
export const COMMON_EMAIL_PROVIDERS = [
'hotmail.com', 'yahoo.com', 'outlook.com', 'live.com', 'gmail.com',
];
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',
];

View File

@@ -1,126 +0,0 @@
import { distance } from 'fastest-levenshtein';
import {
COMMON_EMAIL_PROVIDERS,
DEFAULT_SERVICE_PROVIDER_DOMAINS,
DEFAULT_TOP_LEVEL_DOMAINS,
} from './constants';
import { VALID_EMAIL_REGEX } from '../../../data/constants';
import messages from '../../messages';
export const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i');
export 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;

View File

@@ -1,39 +0,0 @@
import { COMMON_EMAIL_PROVIDERS } from './constants';
import {
getLevenshteinSuggestion,
getSuggestionForInvalidEmail,
} from './validator';
describe('Email Validators Utils', () => {
describe('getLevenshteinSuggestion Tests', () => {
it('test getLevenshteinSuggestion returns null for invalid word', () => {
const output = getLevenshteinSuggestion('', COMMON_EMAIL_PROVIDERS);
expect(output).toEqual(null);
});
it('test getLevenshteinSuggestion returns output for valid word', () => {
const output = getLevenshteinSuggestion('gmail', COMMON_EMAIL_PROVIDERS);
expect(output).toEqual('gmail.com');
});
});
describe('getSuggestionForInvalidEmail Tests', () => {
it('test getSuggestionForInvalidEmail returns empty string for invalid domain', () => {
const output = getSuggestionForInvalidEmail('', 'username');
expect(output).toEqual('');
});
it('test getSuggestionForInvalidEmail returns valid suggestion when domain is nearly matched', () => {
const output = getSuggestionForInvalidEmail('gmail', 'username');
expect(output).toEqual('username@gmail.com');
});
it('test getSuggestionForInvalidEmail returns valid suggestion for default domains', () => {
const output = getSuggestionForInvalidEmail('aol', 'username');
expect(output).toEqual('username@aol.com');
});
it('test getSuggestionForInvalidEmail returns empty for totally different domain', () => {
const output = getSuggestionForInvalidEmail('invalid-domain', 'username');
expect(output).toEqual('');
});
});
});

View File

@@ -1,69 +0,0 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import validateName from './validator';
import { FormGroup } from '../../../common-components';
import { clearRegistrationBackendError, fetchRealtimeValidations } from '../../data/actions';
/**
* Name field wrapper. It accepts following handlers
* - handleChange for setting value change and
* - handleErrorChange for setting error
*
* It is responsible for
* - Making backend call for generating username suggestions
* - Performing name field validations
* - Clearing error on focus
* - Setting value on change
*/
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', fieldError);
} else if (shouldFetchUsernameSuggestions && !validationApiRateLimited) {
dispatch(fetchRealtimeValidations({ name: value }));
}
};
const handleOnFocus = () => {
handleErrorChange('name', '');
dispatch(clearRegistrationBackendError('name'));
};
return (
<FormGroup
{...props}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
/>
);
};
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;

View File

@@ -1,137 +0,0 @@
import React from 'react';
import { Provider } from 'react-redux';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { clearRegistrationBackendError, fetchRealtimeValidations } from '../../data/actions';
import { NameField } from '../index';
const IntlNameField = injectIntl(NameField);
const mockStore = configureStore();
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
// eslint-disable-next-line react/prop-types
const Navigate = ({ to }) => {
mockNavigation(to);
return <div />;
};
return {
...jest.requireActual('react-router-dom'),
Navigate,
mockNavigate: mockNavigation,
};
});
describe('NameField', () => {
let props = {};
let store = {};
const reduxWrapper = children => (
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
);
const routerWrapper = children => (
<Router>
{children}
</Router>
);
const initialState = {
register: {},
};
beforeEach(() => {
store = mockStore(initialState);
props = {
name: 'name',
value: '',
errorMessage: '',
handleChange: jest.fn(),
handleErrorChange: jest.fn(),
floatingLabel: '',
};
window.location = { search: '' };
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Test Name Field', () => {
const fieldValidation = { name: 'Enter your full name' };
it('should run name field validation when onBlur is fired', () => {
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
nameField.find('input#name').simulate('blur', { target: { value: '', name: 'name' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'name',
fieldValidation.name,
);
});
it('should update errors for frontend validations', () => {
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
nameField.find('input#name').simulate(
'blur', { target: { value: 'https://invalid-name.com', name: 'name' } },
);
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'name',
'Enter a valid name',
);
});
it('should clear error on focus', () => {
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
nameField.find('input#name').simulate('focus', { target: { value: '', name: 'name' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'name',
'',
);
});
it('should call backend validation api on blur event, if frontend validations have passed', () => {
store.dispatch = jest.fn(store.dispatch);
props = {
...props,
shouldFetchUsernameSuggestions: true,
};
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
// Enter a valid name so that frontend validations are passed
nameField.find('input#name').simulate('blur', { target: { value: 'test', name: 'name' } });
expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations({ name: 'test' }));
});
it('should clear the registration validation error on focus on field', () => {
const nameError = 'temp error';
store = mockStore({
...initialState,
register: {
...initialState.register,
registrationError: {
name: [{ userMessage: nameError }],
},
},
});
store.dispatch = jest.fn(store.dispatch);
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
nameField.find('input#name').simulate('focus', { target: { value: 'test', name: 'name' } });
expect(store.dispatch).toHaveBeenCalledWith(clearRegistrationBackendError('name'));
});
});
});

View File

@@ -1,16 +0,0 @@
import messages from '../../messages';
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);
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;

View File

@@ -1,299 +0,0 @@
import React from 'react';
import { Provider } from 'react-redux';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { clearRegistrationBackendError, clearUsernameSuggestions, fetchRealtimeValidations } from '../../data/actions';
import { UsernameField } from '../index';
const IntlUsernameField = injectIntl(UsernameField);
const mockStore = configureStore();
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
// eslint-disable-next-line react/prop-types
const Navigate = ({ to }) => {
mockNavigation(to);
return <div />;
};
return {
...jest.requireActual('react-router-dom'),
Navigate,
mockNavigate: mockNavigation,
};
});
describe('UsernameField', () => {
let props = {};
let store = {};
const reduxWrapper = children => (
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
);
const routerWrapper = children => (
<Router>
{children}
</Router>
);
const initialState = {
register: {
usernameSuggestions: [],
},
};
beforeEach(() => {
store = mockStore(initialState);
props = {
name: 'username',
value: '',
errorMessage: '',
handleChange: jest.fn(),
handleErrorChange: jest.fn(),
floatingLabel: '',
};
window.location = { search: '' };
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Test Username Field', () => {
const fieldValidation = {
username: 'Username must be between 2 and 30 characters',
};
it('should run username field validation when onBlur is fired', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('blur', { target: { value: '', name: 'username' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'username',
fieldValidation.username,
);
});
it('should update errors for frontend validations', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('blur', { target: { value: 'user#', name: 'username' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'username',
'Usernames can only contain letters (A-Z, a-z), numerals (0-9), underscores (_), and hyphens (-). Usernames cannot contain spaces',
);
});
it('should clear error on focus', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('focus', { target: { value: '', name: 'username' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'username',
'',
);
});
it('should remove space from field on focus if space exists', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('focus', { target: { value: ' ', name: 'username' } });
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'username', value: '' } },
);
});
it('should call backend validation api on blur event, if frontend validations have passed', () => {
store.dispatch = jest.fn(store.dispatch);
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
// Enter a valid username so that frontend validations are passed
usernameField.find('input#username').simulate('blur', { target: { value: 'test', name: 'username' } });
expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations({ username: 'test' }));
});
it('should remove space from the start of username on change', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate(
'change', { target: { value: ' test-user', name: 'username' } },
);
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'username', value: 'test-user' } },
);
});
it('should not set username if it is more than 30 character long', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate(
'change', { target: { value: 'why_this_is_not_valid_username_', name: 'username' } },
);
expect(props.handleChange).toHaveBeenCalledTimes(0);
});
it('should clear username suggestions when username field is focused in', () => {
store.dispatch = jest.fn(store.dispatch);
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('focus');
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
});
it('should show username suggestions in case of conflict with an existing username', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
usernameSuggestions: ['test_1', 'test_12', 'test_123'],
},
});
props = {
...props,
errorMessage: 'It looks like this username is already taken',
};
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(usernameField.find('button.username-suggestions--chip').length).toEqual(3);
});
it('should show username suggestions when they are populated in redux', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
usernameSuggestions: ['test_1', 'test_12', 'test_123'],
},
});
props = {
...props,
value: ' ',
};
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(usernameField.find('button.username-suggestions--chip').length).toEqual(3);
});
it('should show username suggestions even if there is an error in field', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
usernameSuggestions: ['test_1', 'test_12', 'test_123'],
},
});
props = {
...props,
value: ' ',
errorMessage: 'username error',
};
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(usernameField.find('button.username-suggestions--chip').length).toEqual(3);
});
it('should put space in username field if suggestions are populated in redux', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
usernameSuggestions: ['test_1', 'test_12', 'test_123'],
},
});
mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'username', value: ' ' } },
);
});
it('should set suggestion as username by clicking on it', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
usernameSuggestions: ['test_1', 'test_12', 'test_123'],
},
});
props = {
...props,
value: ' ',
};
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('.username-suggestions--chip').first().simulate('click');
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'username', value: 'test_1' } },
);
});
it('should clear username suggestions when close icon is clicked', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
usernameSuggestions: ['test_1', 'test_12', 'test_123'],
},
});
store.dispatch = jest.fn(store.dispatch);
props = {
...props,
value: ' ',
};
let usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('button.username-suggestions__close__button').at(0).simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
props = {
...props,
errorMessage: 'username error',
};
usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('button.username-suggestions__close__button').at(0).simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
});
it('should clear the registration validation error on focus on field', () => {
store.dispatch = jest.fn(store.dispatch);
const usernameError = 'It looks like this username is already taken';
store = mockStore({
...initialState,
register: {
...initialState.register,
registrationError: {
username: [{ userMessage: usernameError }],
},
},
});
store.dispatch = jest.fn(store.dispatch);
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('focus', { target: { value: 'test', name: 'username' } });
expect(store.dispatch).toHaveBeenCalledWith(clearRegistrationBackendError('username'));
});
});
});

View File

@@ -1,16 +0,0 @@
import messages from '../../messages';
export const VALID_USERNAME_REGEX = /^[a-zA-Z0-9_-]*$/i;
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;

View File

@@ -1,6 +0,0 @@
export { default as NameField } from './NameField/NameField';
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 './HonorCodeField/HonorCode';
export { default as TermsOfService } from './TermsOfServiceField/TermsOfService';

View File

@@ -1,122 +1,146 @@
import React, {
useEffect, useMemo, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
import { sendPageEvent } from '@edx/frontend-platform/analytics';
import {
getCountryList, getLocale, 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 './components/ConfigurableRegistrationForm';
import RegistrationFailure from './components/RegistrationFailure';
import ThirdPartyAuth from './components/ThirdPartyAuth';
import ConfigurableRegistrationForm from './ConfigurableRegistrationForm';
import {
backupRegistrationFormBegin,
clearRegistrationBackendError,
clearRegistertionBackendError,
clearUsernameSuggestions,
fetchRealtimeValidations,
registerNewUser,
setUserPipelineDataLoaded,
} from './data/actions';
import {
FIELDS,
FORM_SUBMISSION_ERROR,
TPA_AUTHENTICATION_FAILURE,
} from './data/constants';
import { getBackendValidations, isFormValid, prepareRegistrationPayload } from './data/utils';
import messages from './messages';
import { EmailField, NameField, UsernameField } from './RegistrationFields';
import { registrationErrorSelector, validationsSelector } from './data/selectors';
import {
InstitutionLogistration, PasswordField, RedirectLogistration, ThirdPartyAuthAlert,
emailRegex, getSuggestionForInvalidEmail, urlRegex, validateCountryField, validateEmailAddress,
} from './data/utils';
import messages from './messages';
import RegistrationFailure from './RegistrationFailure';
import { EmailField, UsernameField } from './registrationFields';
import ThirdPartyAuth from './ThirdPartyAuth';
import {
FormGroup, InstitutionLogistration, PasswordField, RedirectLogistration, ThirdPartyAuthAlert,
} from '../common-components';
import { getThirdPartyAuthContext as getRegistrationDataFromBackend } from '../common-components/data/actions';
import { getThirdPartyAuthContext } from '../common-components/data/actions';
import {
fieldDescriptionSelector, optionalFieldsSelector, thirdPartyAuthContextSelector,
} from '../common-components/data/selectors';
import EnterpriseSSO from '../common-components/EnterpriseSSO';
import {
COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE,
COMPLETE_STATE, DEFAULT_STATE, LETTER_REGEX, NUMBER_REGEX, PENDING_STATE, REGISTER_PAGE,
} from '../data/constants';
import {
getAllPossibleQueryParams, getTpaHint, getTpaProvider, setCookie,
} from '../data/utils';
/**
* Main Registration Page component
*/
const RegistrationPage = (props) => {
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const {
backedUpFormData,
backendValidations,
fieldDescriptions,
handleInstitutionLogin,
institutionLogin,
optionalFields,
registrationError,
registrationErrorCode,
registrationResult,
shouldBackupState,
submitState,
thirdPartyAuthApiStatus,
thirdPartyAuthContext,
validationApiRateLimited,
// Actions
backupFormState,
setUserPipelineDetailsLoaded,
getRegistrationDataFromBackend,
userPipelineDataLoaded,
validateFromBackend,
clearBackendError,
} = props;
const platformName = getConfig().SITE_NAME;
const { formatMessage } = useIntl();
const countryList = useMemo(() => getCountryList(getLocale()), []);
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
const tpaHint = useMemo(() => getTpaHint(), []);
const flags = {
showConfigurableEdxFields: getConfig().SHOW_CONFIGURABLE_EDX_FIELDS,
showConfigurableRegistrationFields: getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS,
showMarketingEmailOptInCheckbox: getConfig().MARKETING_EMAILS_OPT_IN,
};
const {
handleInstitutionLogin,
institutionLogin,
} = props;
const {
registrationFormData: backedUpFormData,
registrationError,
registrationError: {
errorCode: registrationErrorCode,
} = {},
registrationResult,
shouldBackupState,
userPipelineDataLoaded,
submitState,
validations,
} = useSelector(state => state.register);
const {
fieldDescriptions,
optionalFields,
thirdPartyAuthApiStatus,
thirdPartyAuthContext,
thirdPartyAuthContext: {
autoSubmitRegForm,
errorMessage: thirdPartyAuthErrorMessage,
finishAuthUrl,
currentProvider,
providers,
secondaryProviders,
pipelineUserDetails,
},
} = useSelector(state => state.commonComponents);
const backendValidations = useMemo(
() => getBackendValidations(registrationError, validations), [registrationError, validations],
);
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
const tpaHint = useMemo(() => getTpaHint(), []);
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
*/
useEffect(() => {
if (!userPipelineDataLoaded && thirdPartyAuthApiStatus === COMPLETE_STATE) {
if (thirdPartyAuthErrorMessage) {
const { autoSubmitRegForm, pipelineUserDetails, errorMessage } = thirdPartyAuthContext;
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) {
const { name = '', username = '', email = '' } = pipelineUserDetails;
setFormFields(prevState => ({
...prevState, name, username, email,
}));
dispatch(setUserPipelineDataLoaded(true));
setUserPipelineDetailsLoaded(true);
}
}
}, [ // eslint-disable-line react-hooks/exhaustive-deps
thirdPartyAuthContext,
userPipelineDataLoaded,
setUserPipelineDetailsLoaded,
]);
useEffect(() => {
@@ -126,24 +150,24 @@ const RegistrationPage = (props) => {
if (tpaHint) {
payload.tpa_hint = tpaHint;
}
dispatch(getRegistrationDataFromBackend(payload));
getRegistrationDataFromBackend(payload);
setFormStartTime(Date.now());
}
}, [dispatch, formStartTime, queryParams, tpaHint]);
}, [formStartTime, getRegistrationDataFromBackend, queryParams, tpaHint]);
/**
* Backup the registration form in redux when register page is toggled.
*/
useEffect(() => {
if (shouldBackupState) {
dispatch(backupRegistrationFormBegin({
...backedUpFormData,
backupFormState({
configurableFormFields: { ...configurableFormFields },
formFields: { ...formFields },
emailSuggestion: { ...emailSuggestion },
errors: { ...errors },
}));
});
}
}, [shouldBackupState, configurableFormFields, formFields, errors, dispatch, backedUpFormData]);
}, [shouldBackupState, configurableFormFields, formFields, errors, emailSuggestion, backupFormState]);
useEffect(() => {
if (backendValidations) {
@@ -159,8 +183,6 @@ const RegistrationPage = (props) => {
useEffect(() => {
if (registrationResult.success) {
sendTrackEvent('edx.bi.user.account.registered.client', {});
// Optimizely registration conversion event
window.optimizely = window.optimizely || [];
window.optimizely.push({
@@ -182,21 +204,192 @@ 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 handleOnChange = (event) => {
console.log('test handleOnChange', event.target);
const { name } = event.target;
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
if (registrationError[name]) {
dispatch(clearRegistrationBackendError(name));
clearBackendError(name);
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
}
setFormFields(prevState => ({ ...prevState, [name]: value }));
};
const handleErrorChange = (fieldName, error) => {
setErrors(prevErrors => ({
...prevErrors,
[fieldName]: error,
}));
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 } = 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);
};
const registerUser = () => {
@@ -208,32 +401,42 @@ const RegistrationPage = (props) => {
payload.social_auth_provider = currentProvider;
}
// Validating form data before submitting
const { isValid, fieldErrors } = isFormValid(
payload,
errors,
configurableFormFields,
fieldDescriptions,
formatMessage,
);
setErrors({ ...fieldErrors });
const { fieldError: focusedFieldError, countryFieldCode } = focusedField ? (
validateInput(
focusedField,
(focusedField in fieldDescriptions || ['country', 'marketingEmailsOptIn'].includes(focusedField)) ? (
configurableFormFields[focusedField]
) : formFields[focusedField],
payload,
false,
false,
)
) : '';
// returning if not valid
if (!isValid) {
if (!isFormValid(payload, focusedFieldError)) {
setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 }));
return;
}
// Preparing payload for submission
payload = prepareRegistrationPayload(
payload,
configurableFormFields,
flags.showMarketingEmailOptInCheckbox,
totalRegistrationTime,
queryParams);
Object.keys(configurableFormFields).forEach((fieldName) => {
if (fieldName === 'country') {
payload[fieldName] = focusedField === 'country' ? countryFieldCode : configurableFormFields[fieldName].countryCode;
} else {
payload[fieldName] = configurableFormFields[fieldName];
}
});
// making register call
dispatch(registerNewUser(payload));
// 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);
};
const handleSubmit = (e) => {
@@ -242,10 +445,10 @@ const RegistrationPage = (props) => {
};
useEffect(() => {
if (autoSubmitRegForm && userPipelineDataLoaded) {
if (autoSubmitRegisterForm && userPipelineDataLoaded) {
registerUser();
}
}, [autoSubmitRegForm, userPipelineDataLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
}, [autoSubmitRegisterForm, userPipelineDataLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
const renderForm = () => {
if (institutionLogin) {
@@ -262,21 +465,22 @@ const RegistrationPage = (props) => {
<title>{formatMessage(messages['register.page.title'], { siteName: getConfig().SITE_NAME })}</title>
</Helmet>
<RedirectLogistration
authenticatedUser={registrationResult.authenticatedUser}
success={registrationResult.success}
redirectUrl={registrationResult.redirectUrl}
finishAuthUrl={finishAuthUrl}
optionalFields={optionalFields}
redirectToProgressiveProfilingPage={
getConfig().ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN && !!Object.keys(optionalFields.fields).length
getConfig().ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN && Object.keys(optionalFields).includes('fields')
}
/>
{autoSubmitRegForm && !errorCode.type ? (
{autoSubmitRegisterForm && !errorCode.type ? (
<div className="mw-xs mt-5 text-center">
<Spinner animation="border" variant="primary" id="tpa-spinner" />
</div>
) : (
<div className="mw-xs mt-3">
<div
className="mw-xs mt-3"
>
<ThirdPartyAuthAlert
currentProvider={currentProvider}
platformName={platformName}
@@ -285,15 +489,15 @@ const RegistrationPage = (props) => {
<RegistrationFailure
errorCode={errorCode.type}
failureCount={errorCode.count}
context={{ provider: currentProvider, errorMessage: thirdPartyAuthErrorMessage }}
context={{ provider: currentProvider, errorMessage: thirdPartyAuthContext.errorMessage }}
/>
<Form id="registration-form" name="registration-form">
<NameField
<FormGroup
name="name"
value={formFields.name}
shouldFetchUsernameSuggestions={!formFields.username.trim()}
handleChange={handleOnChange}
handleErrorChange={handleErrorChange}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
errorMessage={errors.name}
helpText={[formatMessage(messages['help.text.name'])]}
floatingLabel={formatMessage(messages['registration.fullname.label'])}
@@ -301,9 +505,12 @@ const RegistrationPage = (props) => {
<EmailField
name="email"
value={formFields.email}
confirmEmailValue={configurableFormFields?.confirm_email}
handleErrorChange={handleErrorChange}
handleChange={handleOnChange}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
handleSuggestionClick={(e) => handleSuggestionClick(e, 'email')}
handleOnClose={handleEmailSuggestionClosed}
emailSuggestion={emailSuggestion}
errorMessage={errors.email}
helpText={[formatMessage(messages['help.text.email'])]}
floatingLabel={formatMessage(messages['registration.email.label'])}
@@ -312,8 +519,10 @@ const RegistrationPage = (props) => {
name="username"
spellCheck="false"
value={formFields.username}
handleBlur={handleOnBlur}
handleChange={handleOnChange}
handleErrorChange={handleErrorChange}
handleFocus={handleOnFocus}
handleSuggestionClick={handleSuggestionClick}
errorMessage={errors.username}
helpText={[formatMessage(messages['help.text.username.1']), formatMessage(messages['help.text.username.2'])]}
floatingLabel={formatMessage(messages['registration.username.label'])}
@@ -323,18 +532,20 @@ const RegistrationPage = (props) => {
name="password"
value={formFields.password}
handleChange={handleOnChange}
handleErrorChange={handleErrorChange}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
errorMessage={errors.password}
floatingLabel={formatMessage(messages['registration.password.label'])}
/>
)}
<ConfigurableRegistrationForm
countryList={countryList}
email={formFields.email}
fieldErrors={errors}
formFields={configurableFormFields}
setFieldErrors={setErrors}
setFormFields={setConfigurableFormFields}
autoSubmitRegisterForm={autoSubmitRegForm}
setFocusedField={setFocusedField}
fieldDescriptions={fieldDescriptions}
/>
<StatefulButton
@@ -382,15 +593,133 @@ const RegistrationPage = (props) => {
);
};
const mapStateToProps = state => {
const registerPageState = state.register;
return {
backedUpFormData: registerPageState.registrationFormData,
backendCountryCode: registerPageState.backendCountryCode,
backendValidations: validationsSelector(state),
fieldDescriptions: fieldDescriptionSelector(state),
optionalFields: optionalFieldsSelector(state),
registrationError: registerPageState.registrationError,
registrationErrorCode: registrationErrorSelector(state),
registrationResult: registerPageState.registrationResult,
shouldBackupState: registerPageState.shouldBackupState,
userPipelineDataLoaded: registerPageState.userPipelineDataLoaded,
submitState: registerPageState.submitState,
thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus,
thirdPartyAuthContext: thirdPartyAuthContextSelector(state),
validationApiRateLimited: registerPageState.validationApiRateLimited,
usernameSuggestions: registerPageState.usernameSuggestions,
};
};
RegistrationPage.propTypes = {
backedUpFormData: PropTypes.shape({
configurableFormFields: PropTypes.shape({}),
formFields: PropTypes.shape({}),
errors: PropTypes.shape({}),
emailSuggestion: PropTypes.shape({}),
}),
backendValidations: PropTypes.shape({
name: PropTypes.string,
email: PropTypes.string,
username: PropTypes.string,
password: PropTypes.string,
}),
fieldDescriptions: PropTypes.shape({}),
institutionLogin: PropTypes.bool,
optionalFields: PropTypes.shape({}),
registrationError: PropTypes.shape({}),
registrationErrorCode: PropTypes.string,
registrationResult: PropTypes.shape({
redirectUrl: PropTypes.string,
success: PropTypes.bool,
}),
shouldBackupState: PropTypes.bool,
submitState: PropTypes.string,
thirdPartyAuthApiStatus: PropTypes.string,
thirdPartyAuthContext: PropTypes.shape({
autoSubmitRegForm: PropTypes.bool,
countryCode: PropTypes.string,
currentProvider: PropTypes.string,
errorMessage: PropTypes.string,
finishAuthUrl: PropTypes.string,
pipelineUserDetails: PropTypes.shape({
email: PropTypes.string,
name: PropTypes.string,
firstName: PropTypes.string,
lastName: PropTypes.string,
username: PropTypes.string,
}),
platformName: PropTypes.string,
providers: PropTypes.arrayOf(
PropTypes.shape({}),
),
secondaryProviders: PropTypes.arrayOf(
PropTypes.shape({}),
),
}),
userPipelineDataLoaded: PropTypes.bool,
validationApiRateLimited: PropTypes.bool,
// Actions
backupFormState: PropTypes.func.isRequired,
clearBackendError: PropTypes.func.isRequired,
getRegistrationDataFromBackend: PropTypes.func.isRequired,
handleInstitutionLogin: PropTypes.func,
registerNewUser: PropTypes.func.isRequired,
setUserPipelineDetailsLoaded: PropTypes.func.isRequired,
validateFromBackend: PropTypes.func.isRequired,
};
RegistrationPage.defaultProps = {
backedUpFormData: {
configurableFormFields: {
marketingEmailsOptIn: true,
},
formFields: {
name: '', email: '', username: '', password: '',
},
errors: {
name: '', email: '', username: '', password: '',
},
emailSuggestion: {
suggestion: '', type: '',
},
},
backendValidations: null,
fieldDescriptions: {},
handleInstitutionLogin: null,
institutionLogin: false,
optionalFields: {},
registrationError: {},
registrationErrorCode: '',
registrationResult: null,
shouldBackupState: false,
submitState: DEFAULT_STATE,
thirdPartyAuthApiStatus: PENDING_STATE,
thirdPartyAuthContext: {
autoSubmitRegForm: false,
countryCode: null,
currentProvider: null,
errorMessage: null,
finishAuthUrl: null,
pipelineUserDetails: null,
providers: [],
secondaryProviders: [],
},
userPipelineDataLoaded: false,
validationApiRateLimited: false,
};
export default RegistrationPage;
export default connect(
mapStateToProps,
{
backupFormState: backupRegistrationFormBegin,
clearBackendError: clearRegistertionBackendError,
getRegistrationDataFromBackend: getThirdPartyAuthContext,
validateFromBackend: fetchRealtimeValidations,
registerNewUser,
setUserPipelineDetailsLoaded: setUserPipelineDataLoaded,
},
)(RegistrationPage);

View File

@@ -5,14 +5,14 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import Skeleton from 'react-loading-skeleton';
import messages from './messages';
import {
RenderInstitutionButton,
SocialAuthProviders,
} from '../../common-components';
} from '../common-components';
import {
PENDING_STATE, REGISTER_PAGE,
} from '../../data/constants';
import messages from '../messages';
} from '../data/constants';
/**
* This component renders the Single sign-on (SSO) buttons for the providers passed.

View File

@@ -1,261 +0,0 @@
import React, {
useEffect, useMemo, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form, StatefulButton } from '@edx/paragon';
import { Helmet } from 'react-helmet';
import ConfigurableRegistrationForm from './ConfigurableRegistrationForm';
import RegistrationFailure from './RegistrationFailure';
import { PasswordField } from '../../common-components';
import { getThirdPartyAuthContext as getRegistrationDataFromBackend } from '../../common-components/data/actions';
import { REDIRECT } from '../../data/constants';
import { getAllPossibleQueryParams, setCookie } from '../../data/utils';
import { clearRegistrationBackendError, registerNewUser } from '../data/actions';
import { FORM_SUBMISSION_ERROR } from '../data/constants';
import { getBackendValidations, isFormValid, prepareRegistrationPayload } from '../data/utils';
import messages from '../messages';
import { EmailField, NameField, UsernameField } from '../RegistrationFields';
/**
* Main Registration Page component
*/
const EmbeddableRegistrationPage = () => {
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const flags = {
showConfigurableEdxFields: getConfig().SHOW_CONFIGURABLE_EDX_FIELDS,
showConfigurableRegistrationFields: getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS,
showMarketingEmailOptInCheckbox: getConfig().MARKETING_EMAILS_OPT_IN,
};
const {
registrationFormData: backedUpFormData,
registrationError,
registrationError: {
errorCode: registrationErrorCode,
} = {},
registrationResult,
submitState,
validations,
} = useSelector(state => state.register);
const { fieldDescriptions } = useSelector(state => state.commonComponents);
const backendValidations = useMemo(
() => getBackendValidations(registrationError, validations), [registrationError, validations],
);
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields });
const [configurableFormFields, setConfigurableFormFields] = useState(
{ ...backedUpFormData.configurableFormFields },
);
const [errors, setErrors] = useState({ ...backedUpFormData.errors });
const [errorCode, setErrorCode] = useState({ type: '', count: 0 });
const [formStartTime, setFormStartTime] = useState(null);
// temporary error state for embedded experience because we don't want to show errors on blur
const [temporaryErrors, setTemporaryErrors] = useState({ ...backedUpFormData.errors });
const { cta, host } = queryParams;
const buttonLabel = cta
? formatMessage(messages['create.account.cta.button'], { label: cta })
: formatMessage(messages['create.account.for.free.button']);
useEffect(() => {
if (!formStartTime) {
sendPageEvent('login_and_registration', 'register');
const payload = { ...queryParams, is_register_page: true };
dispatch(getRegistrationDataFromBackend(payload));
setFormStartTime(Date.now());
}
}, [dispatch, formStartTime, queryParams]);
useEffect(() => {
if (backendValidations) {
setTemporaryErrors(prevErrors => ({ ...prevErrors, ...backendValidations }));
}
}, [backendValidations]);
useEffect(() => {
if (registrationErrorCode) {
setErrorCode(prevState => ({ type: registrationErrorCode, count: prevState.count + 1 }));
}
}, [registrationErrorCode]);
useEffect(() => {
if (registrationResult.success) {
sendTrackEvent('edx.bi.user.account.registered.client', {});
// Optimizely registration conversion event
window.optimizely = window.optimizely || [];
window.optimizely.push({
type: 'event',
eventName: 'authn-registration-conversion',
});
// We probably don't need this cookie because this fires the same event as
// above for optimizely using GTM.
setCookie(getConfig().REGISTER_CONVERSION_COOKIE_NAME, true);
// This is used by the "User Retention Rate Event" on GTM
setCookie('authn-returning-user');
// Fire GTM event used for integration with impact.com
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'ImpactRegistrationEvent',
});
window.parent.postMessage({
action: REDIRECT,
redirectUrl: encodeURIComponent(getConfig().POST_REGISTRATION_REDIRECT_URL),
}, host);
}
}, [host, registrationResult]);
const handleOnChange = (event) => {
const { name } = event.target;
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
if (registrationError[name]) {
dispatch(clearRegistrationBackendError(name));
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
}
setFormFields(prevState => ({ ...prevState, [name]: value }));
};
const handleErrorChange = (fieldName, error) => {
setTemporaryErrors(prevErrors => ({
...prevErrors,
[fieldName]: error,
}));
if (error === '' && errors[fieldName] !== '') {
setErrors(prevErrors => ({
...prevErrors,
[fieldName]: error,
}));
}
};
const registerUser = () => {
const totalRegistrationTime = (Date.now() - formStartTime) / 1000;
let payload = { ...formFields };
// Validating form data before submitting
const { isValid, fieldErrors } = isFormValid(
payload,
temporaryErrors,
configurableFormFields,
fieldDescriptions,
formatMessage,
);
setErrors({ ...fieldErrors });
// returning if not valid
if (!isValid) {
setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 }));
return;
}
// Preparing payload for submission
payload = prepareRegistrationPayload(
payload,
configurableFormFields,
flags.showMarketingEmailOptInCheckbox,
totalRegistrationTime,
queryParams);
// making register call
dispatch(registerNewUser(payload));
};
const handleSubmit = (e) => {
e.preventDefault();
registerUser();
};
return (
<>
<Helmet>
<title>{formatMessage(messages['register.page.title'], { siteName: getConfig().SITE_NAME })}</title>
</Helmet>
<div
className="mw-xs mt-3 w-100 m-auto pt-4 main-content"
>
<RegistrationFailure
errorCode={errorCode.type}
failureCount={errorCode.count}
/>
<Form id="registration-form" name="registration-form">
<NameField
name="name"
value={formFields.name}
shouldFetchUsernameSuggestions={!formFields.username.trim()}
handleChange={handleOnChange}
handleErrorChange={handleErrorChange}
errorMessage={errors.name}
helpText={[formatMessage(messages['help.text.name'])]}
floatingLabel={formatMessage(messages['registration.fullname.label'])}
/>
<EmailField
name="email"
value={formFields.email}
confirmEmailValue={configurableFormFields?.confirm_email}
handleErrorChange={handleErrorChange}
handleChange={handleOnChange}
errorMessage={errors.email}
helpText={[formatMessage(messages['help.text.email'])]}
floatingLabel={formatMessage(messages['registration.email.label'])}
/>
<UsernameField
name="username"
spellCheck="false"
value={formFields.username}
handleChange={handleOnChange}
handleErrorChange={handleErrorChange}
errorMessage={errors.username}
helpText={[formatMessage(messages['help.text.username.1']), formatMessage(messages['help.text.username.2'])]}
floatingLabel={formatMessage(messages['registration.username.label'])}
/>
<PasswordField
name="password"
value={formFields.password}
handleChange={handleOnChange}
handleErrorChange={handleErrorChange}
errorMessage={errors.password}
floatingLabel={formatMessage(messages['registration.password.label'])}
/>
<ConfigurableRegistrationForm
email={formFields.email}
fieldErrors={errors}
formFields={configurableFormFields}
setFieldErrors={setTemporaryErrors}
setFormFields={setConfigurableFormFields}
autoSubmitRegisterForm={false}
fieldDescriptions={fieldDescriptions}
/>
<StatefulButton
id="register-user"
name="register-user"
type="submit"
variant="brand"
className="register-button mt-4 mb-4"
state={submitState}
labels={{
default: buttonLabel,
pending: '',
}}
onClick={handleSubmit}
onMouseDown={(e) => e.preventDefault()}
/>
</Form>
</div>
</>
);
};
export default EmbeddableRegistrationPage;

View File

@@ -1,187 +0,0 @@
import React from 'react';
import { Provider } from 'react-redux';
import { mergeConfig } from '@edx/frontend-platform';
import {
getLocale, injectIntl, IntlProvider,
} from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { FIELDS } from '../../data/constants';
import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendPageEvent: jest.fn(),
sendTrackEvent: jest.fn(),
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
getLocale: jest.fn(),
}));
const IntlConfigurableRegistrationForm = injectIntl(ConfigurableRegistrationForm);
const mockStore = configureStore();
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
// eslint-disable-next-line react/prop-types
const Navigate = ({ to }) => {
mockNavigation(to);
return <div />;
};
return {
...jest.requireActual('react-router-dom'),
Navigate,
mockNavigate: mockNavigation,
};
});
describe('ConfigurableRegistrationForm', () => {
mergeConfig({
PRIVACY_POLICY: 'https://privacy-policy.com',
TOS_AND_HONOR_CODE: 'https://tos-and-honot-code.com',
REGISTER_CONVERSION_COOKIE_NAME: process.env.REGISTER_CONVERSION_COOKIE_NAME,
});
let props = {};
let store = {};
const registrationFormData = {
configurableFormFields: {
marketingEmailsOptIn: true,
},
formFields: {
name: '', email: '', username: '', password: '',
},
emailSuggestion: {
suggestion: '', type: '',
},
errors: {
name: '', email: '', username: '', password: '',
},
};
const reduxWrapper = children => (
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
);
const routerWrapper = children => (
<Router>
{children}
</Router>
);
const thirdPartyAuthContext = {
currentProvider: null,
finishAuthUrl: null,
providers: [],
secondaryProviders: [],
pipelineUserDetails: null,
countryCode: null,
};
const initialState = {
register: {
registrationResult: { success: false, redirectUrl: '' },
registrationError: {},
registrationFormData,
usernameSuggestions: [],
},
commonComponents: {
thirdPartyAuthApiStatus: null,
thirdPartyAuthContext,
fieldDescriptions: {},
optionalFields: {
fields: {},
extended_profile: [],
},
},
};
beforeEach(() => {
store = mockStore(initialState);
props = {
email: '',
fieldDescriptions: {},
fieldErrors: {},
formFields: {},
setFieldErrors: jest.fn(),
setFormFields: jest.fn(),
registrationEmbedded: false,
autoSubmitRegistrationForm: false,
};
window.location = { search: '' };
getLocale.mockImplementationOnce(() => ('en-us'));
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Test Configurable Fields', () => {
mergeConfig({
ENABLE_DYNAMIC_REGISTRATION_FIELDS: true,
});
it('should render fields returned by backend as field descriptions', () => {
props = {
...props,
fieldDescriptions: {
profession: { name: 'profession', type: 'text', label: 'Profession' },
terms_of_service: {
name: FIELDS.TERMS_OF_SERVICE,
error_message: 'You must agree to the Terms and Service agreement of our site',
},
},
};
const configurableRegistrationForm = mount(routerWrapper(reduxWrapper(
<IntlConfigurableRegistrationForm {...props} />,
)));
expect(configurableRegistrationForm.find('#profession').exists()).toBeTruthy();
expect(configurableRegistrationForm.find('#tos').exists()).toBeTruthy();
});
it('should check TOS and honor code fields if they exist when auto submitting register form', () => {
props = {
...props,
formFields: {
country: {
countryCode: '',
displayValue: '',
},
},
fieldDescriptions: {
terms_of_service: {
name: FIELDS.TERMS_OF_SERVICE,
error_message: 'You must agree to the Terms and Service agreement of our site',
},
honor_code: {
name: FIELDS.HONOR_CODE,
error_message: 'You must agree to the Honor Code agreement of our site',
},
},
autoSubmitRegistrationForm: true,
};
mount(routerWrapper(reduxWrapper(
<IntlConfigurableRegistrationForm {...props} />,
)));
expect(props.setFormFields).toHaveBeenCalledTimes(2);
expect(props.setFormFields.mock.calls[0][0]()).toEqual({
[FIELDS.HONOR_CODE]: true,
});
expect(props.setFormFields.mock.calls[1][0]()).toEqual({
[FIELDS.TERMS_OF_SERVICE]: true,
});
});
});
});

View File

@@ -1,672 +0,0 @@
import React from 'react';
import { Provider } from 'react-redux';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
configure, getLocale, injectIntl, IntlProvider,
} from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import renderer from 'react-test-renderer';
import configureStore from 'redux-mock-store';
import {
AUTHN_PROGRESSIVE_PROFILING, PENDING_STATE, REGISTER_PAGE,
} from '../../../data/constants';
import {
clearRegistrationBackendError,
registerNewUser,
} from '../../data/actions';
import {
FIELDS,
FORBIDDEN_REQUEST,
INTERNAL_SERVER_ERROR,
TPA_AUTHENTICATION_FAILURE,
TPA_SESSION_EXPIRED,
} from '../../data/constants';
import { EmbeddableRegistrationPage } from '../../index';
import RegistrationFailureMessage from '../RegistrationFailure';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendPageEvent: jest.fn(),
sendTrackEvent: jest.fn(),
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
getLocale: jest.fn(),
}));
const IntlEmbeddableRegistrationPage = injectIntl(EmbeddableRegistrationPage);
const IntlRegistrationFailure = injectIntl(RegistrationFailureMessage);
const mockStore = configureStore();
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
// eslint-disable-next-line react/prop-types
const Navigate = ({ to }) => {
mockNavigation(to);
return <div />;
};
return {
...jest.requireActual('react-router-dom'),
Navigate,
mockNavigate: mockNavigation,
};
});
describe('EmbeddableRegistrationPage', () => {
mergeConfig({
PRIVACY_POLICY: 'https://privacy-policy.com',
TOS_AND_HONOR_CODE: 'https://tos-and-honot-code.com',
REGISTER_CONVERSION_COOKIE_NAME: process.env.REGISTER_CONVERSION_COOKIE_NAME,
});
let props = {};
let store = {};
const registrationFormData = {
configurableFormFields: {
marketingEmailsOptIn: true,
},
formFields: {
name: '', email: '', username: '', password: '',
},
emailSuggestion: {
suggestion: '', type: '',
},
errors: {
name: '', email: '', username: '', password: '',
},
};
const reduxWrapper = children => (
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
);
const routerWrapper = children => (
<Router>
{children}
</Router>
);
const thirdPartyAuthContext = {
currentProvider: null,
finishAuthUrl: null,
providers: [],
secondaryProviders: [],
pipelineUserDetails: null,
countryCode: null,
};
const initialState = {
register: {
registrationResult: { success: false, redirectUrl: '' },
registrationError: {},
registrationFormData,
usernameSuggestions: [],
},
commonComponents: {
thirdPartyAuthApiStatus: null,
thirdPartyAuthContext,
fieldDescriptions: {},
optionalFields: {
fields: {},
extended_profile: [],
},
},
};
beforeEach(() => {
store = mockStore(initialState);
configure({
loggingService: { logError: jest.fn() },
config: {
ENVIRONMENT: 'production',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
},
messages: { 'es-419': {}, de: {}, 'en-us': {} },
});
props = {};
window.location = { search: '' };
window.parent.postMessage = jest.fn();
getLocale.mockImplementation(() => ('en-us'));
});
afterEach(() => {
jest.clearAllMocks();
});
const populateRequiredFields = (registrationPage, payload, isThirdPartyAuth = false) => {
registrationPage.find('input#name').simulate('change', { target: { value: payload.name, name: 'name' } });
registrationPage.find('input#username').simulate('change', { target: { value: payload.username, name: 'username' } });
registrationPage.find('input#email').simulate('change', { target: { value: payload.email, name: 'email' } });
registrationPage.find('input[name="country"]').simulate('change', { target: { value: payload.country, name: 'country' } });
registrationPage.find('input[name="country"]').simulate('blur', { target: { value: payload.country, name: 'country' } });
if (!isThirdPartyAuth) {
registrationPage.find('input#password').simulate('change', { target: { value: payload.password, name: 'password' } });
}
};
describe('Test Embeddable Registration Page', () => {
mergeConfig({
SHOW_CONFIGURABLE_EDX_FIELDS: true,
});
const emptyFieldValidation = {
name: 'Enter your full name',
username: 'Username must be between 2 and 30 characters',
email: 'Enter your email',
password: 'Password criteria has not been met',
country: 'Select your country or region of residence',
};
// ******** test registration form submission ********
it('should submit form for valid input', () => {
getLocale.mockImplementation(() => ('en-us'));
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
delete window.location;
window.location = { href: getConfig().BASE_URL, search: '?next=/course/demo-course-url' };
const payload = {
name: 'John Doe',
username: 'john_doe',
email: 'john.doe@gmail.com',
password: 'password1',
country: 'Pakistan',
honor_code: true,
totalRegistrationTime: 0,
next: '/course/demo-course-url',
};
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
populateRequiredFields(registrationPage, payload);
registrationPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
});
it('should submit form with marketing email opt in value', () => {
mergeConfig({
MARKETING_EMAILS_OPT_IN: 'true',
});
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
const payload = {
name: 'John Doe',
username: 'john_doe',
email: 'john.doe@gmail.com',
password: 'password1',
country: 'Pakistan',
honor_code: true,
totalRegistrationTime: 0,
marketing_emails_opt_in: true,
};
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
populateRequiredFields(registrationPage, payload);
registrationPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
mergeConfig({
MARKETING_EMAILS_OPT_IN: '',
});
});
it('should not dispatch registerNewUser on empty form Submission', () => {
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).not.toHaveBeenCalledWith(registerNewUser({}));
});
// ******** test registration form validations ********
it('should show error messages for required fields on empty form submission', () => {
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
expect(registrationPage.find('div[feedback-for="name"]').text()).toEqual(emptyFieldValidation.name);
expect(registrationPage.find('div[feedback-for="username"]').text()).toEqual(emptyFieldValidation.username);
expect(registrationPage.find('div[feedback-for="email"]').text()).toEqual(emptyFieldValidation.email);
expect(registrationPage.find('div[feedback-for="password"]').text()).toContain(emptyFieldValidation.password);
expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(emptyFieldValidation.country);
const alertBanner = 'We couldn\'t create your account.Please check your responses and try again.';
expect(registrationPage.find('#validation-errors').first().text()).toEqual(alertBanner);
});
it('should clear error on focus', () => {
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
expect(registrationPage.find('div[feedback-for="password"]').text()).toContain(emptyFieldValidation.password);
registrationPage.find('input#password').simulate('focus');
expect(registrationPage.find('div[feedback-for="password"]').exists()).toBeFalsy();
});
it('should clear registration backend error on change', () => {
getLocale.mockImplementation(() => ('en-us'));
const emailError = 'This email is already associated with an existing or previous account';
store = mockStore({
...initialState,
register: {
...initialState.register,
registrationError: {
email: [{ userMessage: emailError }],
},
},
});
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(
<IntlEmbeddableRegistrationPage {...props} />,
))).find('EmbeddableRegistrationPage');
registrationPage.find('input#email').simulate('change', { target: { value: 'test1@gmail.com', name: 'email' } });
expect(store.dispatch).toHaveBeenCalledWith(clearRegistrationBackendError('email'));
});
// ******** test alert messages ********
it('should match internal server error message', () => {
const expectedMessage = 'We couldn\'t create your account.An error has occurred. Try refreshing the page, or check your internet connection.';
props = {
errorCode: INTERNAL_SERVER_ERROR,
failureCount: 0,
};
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage);
});
it('should match registration api rate limit error message', () => {
const expectedMessage = 'We couldn\'t create your account.Too many failed registration attempts. Try again later.';
props = {
errorCode: FORBIDDEN_REQUEST,
failureCount: 0,
};
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage);
});
it('should match tpa session expired error message', () => {
const expectedMessage = 'We couldn\'t create your account.Registration using Google has timed out.';
props = {
context: {
provider: 'Google',
},
errorCode: TPA_SESSION_EXPIRED,
failureCount: 0,
};
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage);
});
it('should match tpa authentication failed error message', () => {
const expectedMessageSubstring = 'We are sorry, you are not authorized to access';
props = {
context: {
provider: 'Google',
},
errorCode: TPA_AUTHENTICATION_FAILURE,
failureCount: 0,
};
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toContain(expectedMessageSubstring);
});
// ******** test form buttons and fields ********
it('should match default button state', () => {
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
expect(registrationPage.find('button[type="submit"] span').first().text()).toEqual('Create an account for free');
});
it('should match pending button state', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
submitState: PENDING_STATE,
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
const button = registrationPage.find('button[type="submit"] span').first();
expect(button.find('.sr-only').text()).toEqual('pending');
});
it('should display opt-in/opt-out checkbox', () => {
mergeConfig({
MARKETING_EMAILS_OPT_IN: 'true',
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
expect(registrationPage.find('div.form-field--checkbox').length).toEqual(1);
mergeConfig({
MARKETING_EMAILS_OPT_IN: '',
});
});
it('should show button label based on cta query params value', () => {
const buttonLabel = 'Register';
delete window.location;
window.location = { href: getConfig().BASE_URL, search: `?cta=${buttonLabel}` };
const registrationPage = mount(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />));
expect(registrationPage.find('button[type="submit"] span').first().text()).toEqual(buttonLabel);
});
it('should check registration conversion cookie', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
registrationResult: {
success: true,
},
},
});
renderer.create(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
expect(document.cookie).toMatch(`${getConfig().REGISTER_CONVERSION_COOKIE_NAME}=true`);
});
// ******** miscellaneous tests ********
it('should send page event when register page is rendered', () => {
mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register');
});
it('should send track event when user has successfully registered', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
registrationResult: {
success: true,
redirectUrl: 'https://test.com/testing-dashboard/',
},
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL };
renderer.create(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.account.registered.client', {});
});
it('should display error message based on the error code returned by API', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
registrationError: {
errorCode: INTERNAL_SERVER_ERROR,
},
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />))).find('EmbeddableRegistrationPage');
expect(registrationPage.find('div#validation-errors').first().text()).toContain(
'An error has occurred. Try refreshing the page, or check your internet connection.',
);
});
it('should call the postMessage API on registration success for embedded experience', () => {
mergeConfig({
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: true,
});
window.parent.postMessage = jest.fn();
delete window.location;
window.location = {
href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING),
search: '?host=http://localhost/host-website',
};
store = mockStore({
...initialState,
register: {
...initialState.register,
registrationResult: {
success: true,
},
},
commonComponents: {
...initialState.commonComponents,
optionalFields: {
extended_profile: [],
fields: {
level_of_education: { name: 'level_of_education', error_message: false },
},
},
},
});
const progressiveProfilingPage = mount(reduxWrapper(
<IntlEmbeddableRegistrationPage {...props} />,
));
progressiveProfilingPage.update();
expect(window.parent.postMessage).toHaveBeenCalledTimes(1);
});
it('should not display validations error on blur event when embedded variant is rendered', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(REGISTER_PAGE), search: '?host=http://localhost/host-website' };
const registrationPage = mount(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />));
registrationPage.find('input#username').simulate('blur', { target: { value: '', name: 'username' } });
expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy();
registrationPage.find('input[name="country"]').simulate('blur', { target: { value: '', name: 'country' } });
expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy();
});
it('should set errors in temporary state when validations are returned by registration api', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(REGISTER_PAGE), search: '?host=http://localhost/host-website' };
const usernameError = 'It looks like this username is already taken';
const emailError = 'This email is already associated with an existing or previous account';
store = mockStore({
...initialState,
register: {
...initialState.register,
registrationError: {
username: [{ userMessage: usernameError }],
email: [{ userMessage: emailError }],
},
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(
<IntlEmbeddableRegistrationPage {...props} />),
)).find('EmbeddableRegistrationPage');
expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy();
expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeFalsy();
});
it('should clear error on focus for embedded experience also', () => {
delete window.location;
window.location = {
href: getConfig().BASE_URL.concat(REGISTER_PAGE),
search: '?host=http://localhost/host-website',
};
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
expect(registrationPage.find('div[feedback-for="password"]').text()).toContain(emptyFieldValidation.password);
registrationPage.find('input#password').simulate('focus');
expect(registrationPage.find('div[feedback-for="password"]').exists()).toBeFalsy();
});
});
describe('Test Configurable Fields', () => {
mergeConfig({
ENABLE_DYNAMIC_REGISTRATION_FIELDS: true,
SHOW_CONFIGURABLE_EDX_FIELDS: true,
});
it('should render fields returned by backend', () => {
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
fieldDescriptions: {
profession: { name: 'profession', type: 'text', label: 'Profession' },
terms_of_service: {
name: FIELDS.TERMS_OF_SERVICE,
error_message: 'You must agree to the Terms and Service agreement of our site',
},
},
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
expect(registrationPage.find('#profession').exists()).toBeTruthy();
expect(registrationPage.find('#tos').exists()).toBeTruthy();
});
it('should submit form with fields returned by backend in payload', () => {
getLocale.mockImplementation(() => ('en-us'));
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
fieldDescriptions: {
profession: { name: 'profession', type: 'text', label: 'Profession' },
},
extendedProfile: ['profession'],
},
});
const payload = {
name: 'John Doe',
username: 'john_doe',
email: 'john.doe@example.com',
password: 'password1',
country: 'Pakistan',
honor_code: true,
profession: 'Engineer',
totalRegistrationTime: 0,
};
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
populateRequiredFields(registrationPage, payload);
registrationPage.find('input#profession').simulate('change', { target: { value: 'Engineer', name: 'profession' } });
registrationPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
});
it('should show error messages for required fields on empty form submission', () => {
const professionError = 'Enter your profession';
const countryError = 'Select your country or region of residence';
const confirmEmailError = 'Enter your email';
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
fieldDescriptions: {
profession: {
name: 'profession', type: 'text', label: 'Profession', error_message: professionError,
},
confirm_email: {
name: 'confirm_email', type: 'text', label: 'Confirm Email', error_message: confirmEmailError,
},
country: { name: 'country' },
},
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
expect(registrationPage.find('#profession-error').last().text()).toEqual(professionError);
expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(countryError);
expect(registrationPage.find('#confirm_email-error').last().text()).toEqual(confirmEmailError);
});
it('should show error if email and confirm email fields do not match', () => {
store = mockStore({
...initialState,
register: {
...initialState.register,
registrationFormData: {
...initialState.register.registrationFormData,
configurableFormFields: {
...initialState.register.registrationFormData.configurableFormFields,
confirm_email: 'test2@yopmail.com',
},
formFields: {
...initialState.register.registrationFormData.formFields,
email: 'test1@yopmail.com',
},
},
},
commonComponents: {
...initialState.commonComponents,
fieldDescriptions: {
confirm_email: {
name: 'confirm_email', type: 'text', label: 'Confirm Email',
},
},
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
registrationPage.find('input#confirm_email').simulate('blur');
registrationPage.find('button.btn-brand').simulate('click');
expect(registrationPage.find('div#confirm_email-error').text()).toEqual('The email addresses do not match.');
});
it('should run validations for configurable focused field on form submission', () => {
const professionError = 'Enter your profession';
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
fieldDescriptions: {
profession: {
name: 'profession', type: 'text', label: 'Profession', error_message: professionError,
},
},
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlEmbeddableRegistrationPage {...props} />)));
registrationPage.find('input#profession').simulate('focus', { target: { value: '', name: 'profession' } });
registrationPage.find('button.btn-brand').simulate('click');
expect(registrationPage.find('#profession-error').last().text()).toEqual(professionError);
});
});
});

View File

@@ -4,7 +4,7 @@ export const BACKUP_REGISTRATION_DATA = new AsyncActionType('REGISTRATION', 'BAC
export const REGISTER_FORM_VALIDATIONS = new AsyncActionType('REGISTRATION', 'GET_FORM_VALIDATIONS');
export const REGISTER_NEW_USER = new AsyncActionType('REGISTRATION', 'REGISTER_NEW_USER');
export const REGISTER_CLEAR_USERNAME_SUGGESTIONS = 'REGISTRATION_CLEAR_USERNAME_SUGGESTIONS';
export const REGISTRATION_CLEAR_BACKEND_ERROR = 'REGISTRATION_CLEAR_BACKEND_ERROR';
export const REGISTERATION_CLEAR_BACKEND_ERROR = 'REGISTERATION_CLEAR_BACKEND_ERROR';
export const REGISTER_SET_COUNTRY_CODE = 'REGISTER_SET_COUNTRY_CODE';
export const REGISTER_SET_USER_PIPELINE_DATA_LOADED = 'REGISTER_SET_USER_PIPELINE_DATA_LOADED';
@@ -47,9 +47,9 @@ export const registerNewUserBegin = () => ({
type: REGISTER_NEW_USER.BEGIN,
});
export const registerNewUserSuccess = (authenticatedUser, redirectUrl, success) => ({
export const registerNewUserSuccess = (redirectUrl, success) => ({
type: REGISTER_NEW_USER.SUCCESS,
payload: { authenticatedUser, redirectUrl, success },
payload: { redirectUrl, success },
});
@@ -62,8 +62,8 @@ export const clearUsernameSuggestions = () => ({
type: REGISTER_CLEAR_USERNAME_SUGGESTIONS,
});
export const clearRegistrationBackendError = (fieldName) => ({
type: REGISTRATION_CLEAR_BACKEND_ERROR,
export const clearRegistertionBackendError = (fieldName) => ({
type: REGISTERATION_CLEAR_BACKEND_ERROR,
payload: fieldName,
});

View File

@@ -11,3 +11,165 @@ export const FORM_SUBMISSION_ERROR = 'form-submission-error';
export const INTERNAL_SERVER_ERROR = 'internal-server-error';
export const TPA_AUTHENTICATION_FAILURE = 'tpa-authentication-failure';
export const TPA_SESSION_EXPIRED = 'tpa-session-expired';
export const YEAR_OF_BIRTH_OPTIONS = (() => {
const currentYear = new Date().getFullYear();
const years = [];
let startYear = currentYear - 120;
while (startYear < currentYear) {
startYear += 1;
years.push({ value: startYear.toString(), label: startYear });
}
return years.reverse();
})();
export const EDUCATION_LEVELS = [
'',
'p',
'm',
'b',
'a',
'hs',
'jhs',
'el',
'none',
'other',
];
export const GENDER_OPTIONS = ['', 'f', 'm', 'o'];
export const COMMON_EMAIL_PROVIDERS = [
'hotmail.com', 'yahoo.com', 'outlook.com', 'live.com', 'gmail.com',
];
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',
];
export const COUNTRY_CODE_KEY = 'code';
export const COUNTRY_DISPLAY_KEY = 'name';

View File

@@ -4,15 +4,13 @@ import {
REGISTER_FORM_VALIDATIONS,
REGISTER_NEW_USER,
REGISTER_SET_COUNTRY_CODE, REGISTER_SET_USER_PIPELINE_DATA_LOADED,
REGISTRATION_CLEAR_BACKEND_ERROR,
REGISTERATION_CLEAR_BACKEND_ERROR,
} from './actions';
import {
DEFAULT_STATE,
PENDING_STATE,
} from '../../data/constants';
export const storeName = 'register';
export const defaultState = {
backendCountryCode: '',
registrationError: {},
@@ -75,7 +73,7 @@ const reducer = (state = defaultState, action = {}) => {
usernameSuggestions: usernameSuggestions || state.usernameSuggestions,
};
}
case REGISTRATION_CLEAR_BACKEND_ERROR: {
case REGISTERATION_CLEAR_BACKEND_ERROR: {
const registrationErrorTemp = state.registrationError;
delete registrationErrorTemp[action.payload];
return {

View File

@@ -19,10 +19,9 @@ export function* handleNewUserRegistration(action) {
try {
yield put(registerNewUserBegin());
const { authenticatedUser, redirectUrl, success } = yield call(registerRequest, action.payload.registrationInfo);
const { redirectUrl, success } = yield call(registerRequest, action.payload.registrationInfo);
yield put(registerNewUserSuccess(
camelCaseObject(authenticatedUser),
redirectUrl,
success,
));

View File

@@ -0,0 +1,33 @@
import { createSelector } from 'reselect';
export const storeName = 'register';
export const registerSelector = state => ({ ...state[storeName] });
export const registrationErrorSelector = createSelector(
registerSelector,
register => register.registrationError.errorCode,
);
export const validationsSelector = createSelector(
registerSelector,
(register) => {
const { registrationError, validations } = register;
if (validations) {
return validations.validationDecisions;
}
if (Object.keys(registrationError).length > 0) {
const fields = Object.keys(registrationError).filter((fieldName) => !(fieldName in ['errorCode', 'usernameSuggestions']));
const validationDecisions = {};
fields.forEach(field => {
validationDecisions[field] = registrationError[field][0].userMessage || '';
});
return validationDecisions;
}
return null;
},
);

View File

@@ -21,7 +21,6 @@ export async function registerRequest(registrationInformation) {
return {
redirectUrl: data.redirect_url || `${getConfig().LMS_BASE_URL}/dashboard`,
success: data.success || false,
authenticatedUser: data.authenticated_user,
};
}

View File

@@ -8,7 +8,7 @@ import {
REGISTER_NEW_USER,
REGISTER_SET_COUNTRY_CODE,
REGISTER_SET_USER_PIPELINE_DATA_LOADED,
REGISTRATION_CLEAR_BACKEND_ERROR,
REGISTERATION_CLEAR_BACKEND_ERROR,
} from '../actions';
import reducer from '../reducers';
@@ -116,7 +116,7 @@ describe('Registration Reducer Tests', () => {
},
);
});
it('should set the register user when SSO pipeline data is loaded', () => {
it('should set the register user when SSO pipline data is loaded', () => {
const payload = { value: true };
const action = {
type: REGISTER_SET_USER_PIPELINE_DATA_LOADED,
@@ -224,7 +224,7 @@ describe('Registration Reducer Tests', () => {
registrationError: { email: `This email is already associated with an existing or previous ${ getConfig().SITE_NAME } account` },
};
const action = {
type: REGISTRATION_CLEAR_BACKEND_ERROR,
type: REGISTERATION_CLEAR_BACKEND_ERROR,
payload: 'email',
};

View File

@@ -129,12 +129,7 @@ describe('handleNewUserRegistration', () => {
});
it('should call service and dispatch success action', async () => {
const authenticatedUser = { username: 'test', user_id: 123 };
const data = {
redirectUrl: '/dashboard',
success: true,
authenticatedUser,
};
const data = { redirectUrl: '/dashboard', success: true };
const registerRequest = jest.spyOn(api, 'registerRequest')
.mockImplementation(() => Promise.resolve(data));
@@ -148,7 +143,7 @@ describe('handleNewUserRegistration', () => {
expect(registerRequest).toHaveBeenCalledTimes(1);
expect(dispatched).toEqual([
actions.registerNewUserBegin(),
actions.registerNewUserSuccess(camelCaseObject(authenticatedUser), data.redirectUrl, data.success),
actions.registerNewUserSuccess(data.redirectUrl, data.success),
]);
registerRequest.mockClear();
});

View File

@@ -1,129 +1,118 @@
import { snakeCaseObject } from '@edx/frontend-platform';
import { distance } from 'fastest-levenshtein';
import { LETTER_REGEX, NUMBER_REGEX } from '../../data/constants';
import messages from '../messages';
import {
COMMON_EMAIL_PROVIDERS,
COUNTRY_CODE_KEY,
COUNTRY_DISPLAY_KEY,
DEFAULT_SERVICE_PROVIDER_DOMAINS,
DEFAULT_TOP_LEVEL_DOMAINS,
} from './constants';
import {
INVALID_NAME_REGEX, VALID_EMAIL_REGEX,
} from '../../data/constants';
/**
* It validates the password field value
* @param value
* @param formatMessage
* @returns {string}
*/
export const validatePasswordField = (value, formatMessage) => {
let fieldError = '';
if (!value || !LETTER_REGEX.test(value) || !NUMBER_REGEX.test(value) || value.length < 8) {
fieldError = formatMessage(messages['password.validation.message']);
}
return fieldError;
};
/**
* It accepts complete registration data as payload and checks if the form is valid.
* @param payload
* @param errors
* @param configurableFormFields
* @param fieldDescriptions
* @param formatMessage
* @returns {{fieldErrors, isValid: boolean}}
*/
export const isFormValid = (
payload,
errors,
configurableFormFields,
fieldDescriptions,
formatMessage,
) => {
const fieldErrors = { ...errors };
let isValid = true;
Object.keys(payload).forEach(key => {
if (!payload[key]) {
fieldErrors[key] = formatMessage(messages[`empty.${key}.field.error`]);
}
if (fieldErrors[key]) {
isValid = false;
}
});
if (!configurableFormFields?.country?.displayValue) {
fieldErrors.country = formatMessage(messages['empty.country.field.error']);
isValid = false;
function getLevenshteinSuggestion(word, knownWords, similarityThreshold = 4) {
if (!word) {
return null;
}
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;
}
});
let minEditDistance = 100;
let mostSimilar = word;
return { isValid, fieldErrors };
};
/**
* It prepares a payload for registration data that can be passed to registration API endpoint.
* @param initPayload
* @param configurableFormFields
* @param showMarketingEmailOptInCheckbox
* @param totalRegistrationTime
* @param queryParams
* @returns {*}
*/
export const prepareRegistrationPayload = (
initPayload,
configurableFormFields,
showMarketingEmailOptInCheckbox,
totalRegistrationTime,
queryParams,
) => {
let payload = { ...initPayload };
Object.keys(configurableFormFields).forEach((fieldName) => {
if (fieldName === 'country') {
payload[fieldName] = configurableFormFields[fieldName].countryCode;
} else {
payload[fieldName] = configurableFormFields[fieldName];
for (let i = 0; i < knownWords.length; i++) {
const editDistance = distance(knownWords[i].toLowerCase(), word.toLowerCase());
if (editDistance < minEditDistance) {
minEditDistance = editDistance;
mostSimilar = knownWords[i];
}
});
// Don't send the marketing email opt-in value if the flag is turned off
if (!showMarketingEmailOptInCheckbox) {
delete payload.marketingEmailsOptIn;
}
payload = snakeCaseObject(payload);
payload.totalRegistrationTime = totalRegistrationTime;
return minEditDistance <= similarityThreshold && word !== mostSimilar ? mostSimilar : null;
}
// add query params to the payload
payload = { ...payload, ...queryParams };
return payload;
};
/**
* A helper for backend validations selector. It processes the api output and generates a
* key value dict for field errors.
* @param registrationError
* @param validations
* @returns {{username: string}|{name: string}|*|{}|null}
*/
export const getBackendValidations = (registrationError, validations) => {
if (validations) {
return validations.validationDecisions;
export function getSuggestionForInvalidEmail(domain, username) {
if (!domain) {
return '';
}
if (Object.keys(registrationError).length > 0) {
const fields = Object.keys(registrationError).filter(
(fieldName) => !(fieldName in ['errorCode', 'usernameSuggestions']),
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 function 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;
}
export function 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
),
);
const validationDecisions = {};
fields.forEach(field => {
validationDecisions[field] = registrationError[field][0].userMessage || '';
});
return validationDecisions;
if (selectedCountry) {
countryCode = selectedCountry[COUNTRY_CODE_KEY];
displayValue = selectedCountry[COUNTRY_DISPLAY_KEY];
error = '';
}
}
return { error, countryCode, displayValue };
}
return null;
};
export const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i');
export const urlRegex = new RegExp(INVALID_NAME_REGEX);

View File

@@ -1,5 +1,5 @@
export { default as RegistrationPage } from './RegistrationPage';
export { default as EmbeddableRegistrationPage } from './components/EmbeddableRegistrationPage';
export { default as EmbeddableRegistrationPage } from './EmbeddableRegistrationPage';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { storeName } from './data/reducers';
export { storeName } from './data/selectors';

View File

@@ -0,0 +1,95 @@
import React from 'react';
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 } from '../data/constants';
import messages from '../messages';
const CountryField = (props) => {
const { countryList, selectedCountry } = props;
const { formatMessage } = useIntl();
const handleSelected = (value) => {
if (props.onBlurHandler) { props.onBlurHandler({ target: { name: 'country', value } }); }
};
const onBlurHandler = (event) => {
// Do not run validations when drop-down arrow is clicked
if (event.relatedTarget && event.relatedTarget.className.includes('pgn__form-autosuggest__icon-button')) {
return;
}
if (props.onBlurHandler) { props.onBlurHandler(event); }
};
const onFocusHandler = (event) => {
if (props.onFocusHandler) { props.onFocusHandler(event); }
};
const onChangeHandler = (value) => {
if (props.onChangeHandler) {
props.onChangeHandler({ target: { name: 'country' } }, { countryCode: '', displayValue: value });
}
};
const getCountryList = () => countryList.map((country) => (
<FormAutosuggestOption key={country[COUNTRY_CODE_KEY]}>
{country[COUNTRY_DISPLAY_KEY]}
</FormAutosuggestOption>
));
return (
<div className="mb-4">
<FormAutosuggest
floatingLabel={formatMessage(messages['registration.country.label'])}
aria-label="form autosuggest"
name="country"
value={selectedCountry.displayValue || ''}
onSelected={(value) => handleSelected(value)}
onFocus={(e) => onFocusHandler(e)}
onBlur={(e) => onBlurHandler(e)}
onChange={(value) => onChangeHandler(value)}
>
{getCountryList()}
</FormAutosuggest>
{props.errorMessage !== '' && (
<FormControlFeedback
key="error"
className="form-text-size"
hasIcon={false}
feedback-for="country"
type="invalid"
>
{props.errorMessage}
</FormControlFeedback>
)}
</div>
);
};
CountryField.propTypes = {
countryList: PropTypes.arrayOf(
PropTypes.shape({
code: PropTypes.string,
name: PropTypes.string,
}),
).isRequired,
errorMessage: PropTypes.string,
onBlurHandler: PropTypes.func.isRequired,
onChangeHandler: 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;

View File

@@ -0,0 +1,81 @@
import React from 'react';
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 { FormGroup } from '../../common-components';
import messages from '../messages';
const EmailField = (props) => {
const { formatMessage } = useIntl();
const {
emailSuggestion,
handleSuggestionClick,
handleOnClose,
} = props;
const renderEmailFeedback = () => {
if (emailSuggestion.type === 'error') {
return (
<Alert variant="danger" className="email-suggestion-alert-error mt-1" icon={Error}>
<span className="email-suggestion__text">
{formatMessage(messages['did.you.mean.alert.text'])}{' '}
<Alert.Link
href="#"
name="email"
onClick={handleSuggestionClick}
>
{emailSuggestion.suggestion}
</Alert.Link>?<Icon src={Close} className="email-suggestion__close" onClick={handleOnClose} tabIndex="0" />
</span>
</Alert>
);
}
return (
<span id="email-warning" className="small">
{formatMessage(messages['did.you.mean.alert.text'])}:{' '}
<Alert.Link
href="#"
name="email"
className="email-suggestion-alert-warning"
onClick={handleSuggestionClick}
>
{emailSuggestion.suggestion}
</Alert.Link>?
</span>
);
};
return (
<FormGroup
borderClass={emailSuggestion.type === 'warning' ? 'yellow-border' : ''}
maxLength={254} // Limit per RFCs is 254
{...props}
>
{emailSuggestion.suggestion ? renderEmailFeedback() : null}
</FormGroup>
);
};
EmailField.defaultProps = {
emailSuggestion: {
suggestion: '',
type: '',
},
errorMessage: '',
};
EmailField.propTypes = {
errorMessage: PropTypes.string,
emailSuggestion: PropTypes.shape({
suggestion: PropTypes.string,
type: PropTypes.string,
}),
value: PropTypes.string.isRequired,
handleOnClose: PropTypes.func.isRequired,
handleSuggestionClick: PropTypes.func.isRequired,
};
export default EmailField;

View File

@@ -5,7 +5,7 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@edx/paragon';
import PropTypes from 'prop-types';
import messages from '../../messages';
import messages from '../messages';
const HonorCode = (props) => {
const { formatMessage } = useIntl();

View File

@@ -5,7 +5,7 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@edx/paragon';
import PropTypes from 'prop-types';
import messages from '../../messages';
import messages from '../messages';
const TermsOfService = (props) => {
const { formatMessage } = useIntl();

Some files were not shown because too many files have changed in this diff Show More