Compare commits
81 Commits
sajjad/aut
...
sajjad/VAN
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57994bd85 | ||
|
|
aeec576d8c | ||
|
|
90db7ba1d8 | ||
|
|
d8b5653224 | ||
|
|
4cc4ff6c4b | ||
|
|
48a3c57e5f | ||
|
|
efdefc300e | ||
|
|
9730a4f55d | ||
|
|
fc62241332 | ||
|
|
0846001b6d | ||
|
|
90658722e1 | ||
|
|
240752c6cd | ||
|
|
429d4547e4 | ||
|
|
e278b5f74a | ||
|
|
a723058bc1 | ||
|
|
59fa7d5de3 | ||
|
|
60578189bd | ||
|
|
82cd11e01e | ||
|
|
4a10540d4a | ||
|
|
aeda262fb0 | ||
|
|
dff3903617 | ||
|
|
1399caf003 | ||
|
|
2dfb6bc528 | ||
|
|
a392395876 | ||
|
|
5542311c95 | ||
|
|
21e6bb6eec | ||
|
|
bfa7874108 | ||
|
|
423958c899 | ||
|
|
cb380a2031 | ||
|
|
f4e89efdb4 | ||
|
|
f5cb7a1dbd | ||
|
|
72e601948c | ||
|
|
29e30981ae | ||
|
|
06a61e6a22 | ||
|
|
1c83020b43 | ||
|
|
fa4a0ac2d5 | ||
|
|
2addf57cbd | ||
|
|
d521fd20ec | ||
|
|
38d44ac586 | ||
|
|
4768306f53 | ||
|
|
6c6b527dfc | ||
|
|
e14c9bd1b7 | ||
|
|
a2bdc4031b | ||
|
|
8f38eb9e3a | ||
|
|
c22aa58904 | ||
|
|
067bddf892 | ||
|
|
7e4bccbc29 | ||
|
|
f39bb35dc8 | ||
|
|
0d760c04b7 | ||
|
|
6f113542f5 | ||
|
|
1e4c342703 | ||
|
|
3ce0585d7e | ||
|
|
5bf6dd6361 | ||
|
|
929abdff69 | ||
|
|
f295d69e76 | ||
|
|
32a4c55e4a | ||
|
|
615ba91bdb | ||
|
|
dfb2f89a36 | ||
|
|
c9783234cc | ||
|
|
0513e6c2de | ||
|
|
3cdc0234ef | ||
|
|
e06d12be07 | ||
|
|
56394881fc | ||
|
|
61056240c4 | ||
|
|
a59e7c548c | ||
|
|
f63d7674e2 | ||
|
|
fa3a70e9a9 | ||
|
|
b817a8d122 | ||
|
|
2a6668cef3 | ||
|
|
a802821ae9 | ||
|
|
9e13141f6b | ||
|
|
4b64ce2534 | ||
|
|
c550069e11 | ||
|
|
1e10e9c89c | ||
|
|
cd6c1c0e42 | ||
|
|
5edcee9eb9 | ||
|
|
d41c06b1fd | ||
|
|
2a2c5abc81 | ||
|
|
ccdd648603 | ||
|
|
5c1ea04970 | ||
|
|
5ebd22f088 |
10
.env
10
.env
@@ -16,20 +16,26 @@ SITE_NAME=null
|
||||
INFO_EMAIL=''
|
||||
# ***** Cookies *****
|
||||
REGISTER_CONVERSION_COOKIE_NAME=null
|
||||
USER_SURVEY_COOKIE_NAME=null
|
||||
# ***** Links *****
|
||||
LOGIN_ISSUE_SUPPORT_LINK=''
|
||||
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK=null
|
||||
POST_REGISTRATION_REDIRECT_URL=''
|
||||
SEARCH_CATALOG_URL=''
|
||||
# ***** Features flags *****
|
||||
DISABLE_ENTERPRISE_LOGIN=''
|
||||
ENABLE_DYNAMIC_REGISTRATION_FIELDS=''
|
||||
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN=''
|
||||
ENABLE_PERSONALIZED_RECOMMENDATIONS=''
|
||||
ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS=''
|
||||
MARKETING_EMAILS_OPT_IN=''
|
||||
SHOW_CONFIGURABLE_EDX_FIELDS=''
|
||||
# ***** Zendesk related keys *****
|
||||
ZENDESK_KEY=''
|
||||
ZENDESK_LOGO_URL=''
|
||||
# ***** Base Container Images *****
|
||||
BANNER_IMAGE_LARGE=''
|
||||
BANNER_IMAGE_MEDIUM=''
|
||||
BANNER_IMAGE_SMALL=''
|
||||
BANNER_IMAGE_EXTRA_SMALL=''
|
||||
# ***** Miscellaneous *****
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
|
||||
@@ -23,13 +23,17 @@ INFO_EMAIL='info@example.com'
|
||||
REGISTER_CONVERSION_COOKIE_NAME='openedx-user-register-conversion'
|
||||
SESSION_COOKIE_DOMAIN='localhost'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
USER_SURVEY_COOKIE_NAME='openedx-user-survey-type'
|
||||
# ***** Links *****
|
||||
LOGIN_ISSUE_SUPPORT_LINK='http://localhost:18000/login-issue-support-url'
|
||||
TOS_AND_HONOR_CODE='http://localhost:18000/honor'
|
||||
TOS_LINK='http://localhost:18000/tos'
|
||||
PRIVACY_POLICY='http://localhost:18000/privacy'
|
||||
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK='http://localhost:1999/welcome'
|
||||
# ***** Base Container Images *****
|
||||
BANNER_IMAGE_LARGE=''
|
||||
BANNER_IMAGE_MEDIUM=''
|
||||
BANNER_IMAGE_SMALL=''
|
||||
BANNER_IMAGE_EXTRA_SMALL=''
|
||||
# ***** Miscellaneous *****
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
|
||||
@@ -16,7 +16,6 @@ ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME='Your Platform Name Here'
|
||||
USER_SURVEY_COOKIE_NAME='openedx-user-survey-type'
|
||||
REGISTER_CONVERSION_COOKIE_NAME='openedx-user-register-conversion'
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
|
||||
9945
package-lock.json
generated
9945
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -33,51 +33,51 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||
"@edx/frontend-platform": "4.5.1",
|
||||
"@edx/paragon": "20.40.2",
|
||||
"@fortawesome/fontawesome-svg-core": "6.4.0",
|
||||
"@fortawesome/free-brands-svg-icons": "6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "6.4.0",
|
||||
"@edx/frontend-platform": "^5.0.0",
|
||||
"@edx/paragon": "20.46.2",
|
||||
"@fortawesome/fontawesome-svg-core": "6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "6.4.2",
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@optimizely/react-sdk": "^2.9.1",
|
||||
"@redux-devtools/extension": "3.2.5",
|
||||
"algoliasearch": "^4.14.3",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.30.2",
|
||||
"core-js": "3.32.0",
|
||||
"fastest-levenshtein": "1.0.16",
|
||||
"form-urlencoded": "6.1.0",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "7.1.3",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-loading-skeleton": "3.2.1",
|
||||
"react-loading-skeleton": "3.3.1",
|
||||
"react-redux": "7.2.9",
|
||||
"react-responsive": "8.2.0",
|
||||
"react-router": "5.3.4",
|
||||
"react-router-dom": "5.3.4",
|
||||
"react-router": "6.15.0",
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-zendesk": "^0.1.13",
|
||||
"redux": "4.2.0",
|
||||
"redux-logger": "3.0.6",
|
||||
"redux-mock-store": "1.5.4",
|
||||
"redux-saga": "1.2.3",
|
||||
"redux-thunk": "2.4.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"regenerator-runtime": "0.14.0",
|
||||
"reselect": "4.1.8",
|
||||
"universal-cookie": "4.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "^1.1.1",
|
||||
"@edx/frontend-build": "12.8.38",
|
||||
"@edx/frontend-build": "12.9.8",
|
||||
"@edx/reactifex": "1.1.0",
|
||||
"babel-plugin-formatjs": "10.5.1",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.8.0",
|
||||
"babel-plugin-formatjs": "10.5.3",
|
||||
"enzyme": "3.11.0",
|
||||
"enzyme-adapter-react-16": "1.15.7",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-import": "2.28.0",
|
||||
"glob": "7.2.3",
|
||||
"history": "5.3.0",
|
||||
"husky": "7.0.4",
|
||||
"jest": "29.5.0",
|
||||
"react-test-renderer": "16.14.0"
|
||||
"jest": "29.6.2",
|
||||
"react-test-renderer": "^17.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
<title>Authn | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.6/iframeResizer.contentWindow.min.js"
|
||||
integrity="sha512-R7Piufj0/o6jG9ZKrAvS2dblFr2kkuG4XVQwStX+/4P+KwOLUXn2DXy0l1AJDxxqGhkM/FJllZHG2PKOAheYzg=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer">
|
||||
</script>
|
||||
<% if (process.env.OPTIMIZELY_URL) { %>
|
||||
<script
|
||||
src="<%= process.env.OPTIMIZELY_URL %>"
|
||||
></script>
|
||||
<% } else if (process.env.OPTIMIZELY_PROJECT_ID) { %>
|
||||
<script
|
||||
src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"
|
||||
></script>
|
||||
<% } %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,10 +3,10 @@ import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
NotFoundPage, registerIcons, UnAuthOnlyRoute, Zendesk,
|
||||
EmbeddedRegistrationRoute, NotFoundPage, registerIcons, UnAuthOnlyRoute, Zendesk,
|
||||
} from './common-components';
|
||||
import configureStore from './data/configureStore';
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PAGE_NOT_FOUND,
|
||||
PASSWORD_RESET_CONFIRM,
|
||||
RECOMMENDATIONS,
|
||||
REGISTER_EMBEDDED_PAGE,
|
||||
REGISTER_PAGE,
|
||||
RESET_PAGE,
|
||||
} from './data/constants';
|
||||
@@ -23,7 +24,9 @@ import { ForgotPasswordPage } from './forgot-password';
|
||||
import Logistration from './logistration/Logistration';
|
||||
import { ProgressiveProfiling } from './progressive-profiling';
|
||||
import { RecommendationsPage } from './recommendations';
|
||||
import { EmbeddableRegistrationPage } from './register';
|
||||
import { ResetPasswordPage } from './reset-password';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
registerIcons();
|
||||
@@ -34,21 +37,26 @@ const MainApp = () => (
|
||||
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
|
||||
</Helmet>
|
||||
{getConfig().ZENDESK_KEY && <Zendesk />}
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Redirect to={updatePathWithQueryParams(REGISTER_PAGE)} />
|
||||
</Route>
|
||||
<UnAuthOnlyRoute exact path={LOGIN_PAGE} render={() => <Logistration selectedPage={LOGIN_PAGE} />} />
|
||||
<UnAuthOnlyRoute exact path={REGISTER_PAGE} component={Logistration} />
|
||||
<UnAuthOnlyRoute exact path={RESET_PAGE} component={ForgotPasswordPage} />
|
||||
<Route exact path={PASSWORD_RESET_CONFIRM} component={ResetPasswordPage} />
|
||||
<Route exact path={AUTHN_PROGRESSIVE_PROFILING} component={ProgressiveProfiling} />
|
||||
<Route exact path={RECOMMENDATIONS} component={RecommendationsPage} />
|
||||
<Route path={PAGE_NOT_FOUND} component={NotFoundPage} />
|
||||
<Route path="*">
|
||||
<Redirect to={PAGE_NOT_FOUND} />
|
||||
</Route>
|
||||
</Switch>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate replace to={updatePathWithQueryParams(REGISTER_PAGE)} />} />
|
||||
<Route
|
||||
path={REGISTER_EMBEDDED_PAGE}
|
||||
element={<EmbeddedRegistrationRoute><EmbeddableRegistrationPage /></EmbeddedRegistrationRoute>}
|
||||
/>
|
||||
<Route
|
||||
path={LOGIN_PAGE}
|
||||
element={
|
||||
<UnAuthOnlyRoute><Logistration selectedPage={LOGIN_PAGE} /></UnAuthOnlyRoute>
|
||||
}
|
||||
/>
|
||||
<Route path={REGISTER_PAGE} element={<UnAuthOnlyRoute><Logistration /></UnAuthOnlyRoute>} />
|
||||
<Route path={RESET_PAGE} element={<UnAuthOnlyRoute><ForgotPasswordPage /></UnAuthOnlyRoute>} />
|
||||
<Route path={PASSWORD_RESET_CONFIRM} element={<ResetPasswordPage />} />
|
||||
<Route path={AUTHN_PROGRESSIVE_PROFILING} element={<ProgressiveProfiling />} />
|
||||
<Route path={RECOMMENDATIONS} element={<RecommendationsPage />} />
|
||||
<Route path={PAGE_NOT_FOUND} element={<NotFoundPage />} />
|
||||
<Route path="*" element={<Navigate replace to={PAGE_NOT_FOUND} />} />
|
||||
</Routes>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { breakpoints } from '@edx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import MediaQuery from 'react-responsive';
|
||||
|
||||
import AuthLargeLayout from './AuthLargeLayout';
|
||||
import AuthMediumLayout from './AuthMediumLayout';
|
||||
import AuthSmallLayout from './AuthSmallLayout';
|
||||
import LargeLayout from './LargeLayout';
|
||||
import MediumLayout from './MediumLayout';
|
||||
import SmallLayout from './SmallLayout';
|
||||
|
||||
const BaseComponent = ({ children, showWelcomeBanner }) => {
|
||||
const authenticatedUser = showWelcomeBanner ? getAuthenticatedUser() : null;
|
||||
const username = authenticatedUser ? authenticatedUser.username : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-md-12 extra-large-screen-top-stripe" />
|
||||
<div className="layout">
|
||||
<MediaQuery maxWidth={breakpoints.small.maxWidth - 1}>
|
||||
{authenticatedUser ? <AuthSmallLayout username={username} /> : <SmallLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
|
||||
{authenticatedUser ? <AuthMediumLayout username={username} /> : <MediumLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.extraLarge.minWidth} maxWidth={breakpoints.extraExtraLarge.maxWidth}>
|
||||
{authenticatedUser ? <AuthLargeLayout username={username} /> : <LargeLayout />}
|
||||
</MediaQuery>
|
||||
|
||||
<div className={classNames('content', { 'align-items-center mt-0': authenticatedUser })}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
BaseComponent.defaultProps = {
|
||||
showWelcomeBanner: false,
|
||||
};
|
||||
|
||||
BaseComponent.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
showWelcomeBanner: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default BaseComponent;
|
||||
@@ -1,2 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as BaseComponent } from './BaseComponent';
|
||||
@@ -3,16 +3,14 @@ import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import LargeLayout from '../LargeLayout';
|
||||
import MediumLayout from '../MediumLayout';
|
||||
import SmallLayout from '../SmallLayout';
|
||||
import { DefaultLargeLayout, DefaultMediumLayout, DefaultSmallLayout } from './index';
|
||||
|
||||
describe('ScreenLayout', () => {
|
||||
it('should display the form, pass as a child in SmallScreenLayout', () => {
|
||||
describe('Default Layout tests', () => {
|
||||
it('should display the form passed as a child in SmallScreenLayout', () => {
|
||||
const smallScreen = mount(
|
||||
<IntlProvider locale="en">
|
||||
<div>
|
||||
<SmallLayout />
|
||||
<DefaultSmallLayout />
|
||||
<form>
|
||||
<input type="text" />
|
||||
</form>
|
||||
@@ -22,11 +20,11 @@ describe('ScreenLayout', () => {
|
||||
expect(smallScreen.find('form').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
it('should display the form, pass as a child in MediumScreenLayout', () => {
|
||||
it('should display the form passed as a child in MediumScreenLayout', () => {
|
||||
const mediumScreen = mount(
|
||||
<IntlProvider locale="en">
|
||||
<div>
|
||||
<MediumLayout />
|
||||
<DefaultMediumLayout />
|
||||
<form>
|
||||
<input type="text" />
|
||||
</form>
|
||||
@@ -36,11 +34,11 @@ describe('ScreenLayout', () => {
|
||||
expect(mediumScreen.find('form').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
it('should display the form, pass as a child in LargeScreenLayout', () => {
|
||||
it('should display the form passed as a child in LargeScreenLayout', () => {
|
||||
const largeScreen = mount(
|
||||
<IntlProvider locale="en">
|
||||
<div>
|
||||
<LargeLayout />
|
||||
<DefaultLargeLayout />
|
||||
<form>
|
||||
<input type="text" />
|
||||
</form>
|
||||
3
src/base-container/components/default-layout/index.jsx
Normal file
3
src/base-container/components/default-layout/index.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as DefaultLargeLayout } from './LargeLayout';
|
||||
export { default as DefaultMediumLayout } from './MediumLayout';
|
||||
export { default as DefaultSmallLayout } from './SmallLayout';
|
||||
16
src/base-container/components/default-layout/messages.js
Normal file
16
src/base-container/components/default-layout/messages.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'start.learning': {
|
||||
id: 'start.learning',
|
||||
defaultMessage: 'Start learning',
|
||||
description: 'Header text for logistration MFE pages',
|
||||
},
|
||||
'with.site.name': {
|
||||
id: 'with.site.name',
|
||||
defaultMessage: 'with {siteName}',
|
||||
description: 'Header text with site name for logistration MFE pages',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const ExtraSmallLayout = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<span
|
||||
className="w-100 bg-primary-500 banner__image extra-small-layout"
|
||||
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_EXTRA_SMALL})` }}
|
||||
>
|
||||
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
|
||||
<Image className="company-logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
|
||||
</Hyperlink>
|
||||
<div className="ml-4.5 mr-1 pb-3.5 pt-3.5">
|
||||
<h1 className="banner__heading">
|
||||
<span className="text-light-500">
|
||||
{formatMessage(messages['your.career.turning.point'])}{' '}
|
||||
</span>
|
||||
<span className="text-warning-300">
|
||||
{formatMessage(messages['is.here'])}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtraSmallLayout;
|
||||
35
src/base-container/components/image-layout/LargeLayout.jsx
Normal file
35
src/base-container/components/image-layout/LargeLayout.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
|
||||
import './index.scss';
|
||||
import messages from './messages';
|
||||
|
||||
const LargeLayout = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-50 bg-primary-500 banner__image large-layout"
|
||||
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_LARGE})` }}
|
||||
>
|
||||
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
|
||||
<Image className="company-logo position-absolute" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
|
||||
</Hyperlink>
|
||||
<div className="min-vh-100 p-5 d-flex align-items-end">
|
||||
<h1 className="display-2 mw-sm mb-3 d-flex flex-column flex-shrink-0 justify-content-center">
|
||||
<span className="text-light-500">
|
||||
{formatMessage(messages['your.career.turning.point'])}
|
||||
</span>
|
||||
<span className="text-warning-300">
|
||||
{formatMessage(messages['is.here'])}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LargeLayout;
|
||||
35
src/base-container/components/image-layout/MediumLayout.jsx
Normal file
35
src/base-container/components/image-layout/MediumLayout.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
|
||||
import './index.scss';
|
||||
import messages from './messages';
|
||||
|
||||
const MediumLayout = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-100 mb-3 bg-primary-500 banner__image medium-layout"
|
||||
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_MEDIUM})` }}
|
||||
>
|
||||
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
|
||||
<Image className="company-logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
|
||||
</Hyperlink>
|
||||
<div className="ml-5 pb-4 pt-4">
|
||||
<h1 className="display-2 banner__heading">
|
||||
<span className="text-light-500">
|
||||
{formatMessage(messages['your.career.turning.point'])}{' '}
|
||||
</span>
|
||||
<span className="text-warning-300 d-inline-block">
|
||||
{formatMessage(messages['is.here'])}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediumLayout;
|
||||
34
src/base-container/components/image-layout/SmallLayout.jsx
Normal file
34
src/base-container/components/image-layout/SmallLayout.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, Image } from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const SmallLayout = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<span
|
||||
className="w-100 bg-primary-500 banner__image small-layout"
|
||||
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_SMALL})` }}
|
||||
>
|
||||
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
|
||||
<Image className="company-logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
|
||||
</Hyperlink>
|
||||
<div className="ml-5 mr-1 pb-3.5 pt-3.5">
|
||||
<h1 className="display-2">
|
||||
<span className="text-light-500">
|
||||
{formatMessage(messages['your.career.turning.point'])}{' '}
|
||||
</span>
|
||||
<span className="text-warning-300">
|
||||
{formatMessage(messages['is.here'])}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default SmallLayout;
|
||||
4
src/base-container/components/image-layout/index.jsx
Normal file
4
src/base-container/components/image-layout/index.jsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as ImageLargeLayout } from './LargeLayout';
|
||||
export { default as ImageMediumLayout } from './MediumLayout';
|
||||
export { default as ImageSmallLayout } from './SmallLayout';
|
||||
export { default as ImageExtraSmallLayout } from './ExtraSmallLayout';
|
||||
37
src/base-container/components/image-layout/index.scss
Normal file
37
src/base-container/components/image-layout/index.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
.company-logo {
|
||||
width: 71px;
|
||||
margin-top: 2rem;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.company-logo {
|
||||
width: 44.67px;
|
||||
margin-top: 1.25rem;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.banner__image {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
border:none;
|
||||
}
|
||||
|
||||
@media (min-width: 464px) and (max-width: 575.98px) {
|
||||
.banner__heading {
|
||||
font-size: 60px;
|
||||
font-weight: 700;
|
||||
line-height: 60px;
|
||||
letter-spacing: -1.2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 800px) {
|
||||
.banner__heading {
|
||||
font-size: 60px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 60px !important;
|
||||
letter-spacing: -2px !important;
|
||||
}
|
||||
}
|
||||
16
src/base-container/components/image-layout/messages.js
Normal file
16
src/base-container/components/image-layout/messages.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'your.career.turning.point': {
|
||||
id: 'your.career.turning.point',
|
||||
defaultMessage: 'Your career turning point',
|
||||
description: 'Part of the heading "Your career turning point is here." shown on Authn MFE',
|
||||
},
|
||||
'is.here': {
|
||||
id: 'is.here',
|
||||
defaultMessage: 'is here.',
|
||||
description: 'Part of the heading "Your career turning point is here." shown on Authn MFE',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AuthLargeLayout = ({ username }) => {
|
||||
const LargeLayout = ({ username }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
@@ -42,8 +42,8 @@ const AuthLargeLayout = ({ username }) => {
|
||||
);
|
||||
};
|
||||
|
||||
AuthLargeLayout.propTypes = {
|
||||
LargeLayout.propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AuthLargeLayout;
|
||||
export default LargeLayout;
|
||||
@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AuthMediumLayout = ({ username }) => {
|
||||
const MediumLayout = ({ username }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
@@ -45,8 +45,8 @@ const AuthMediumLayout = ({ username }) => {
|
||||
);
|
||||
};
|
||||
|
||||
AuthMediumLayout.propTypes = {
|
||||
MediumLayout.propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AuthMediumLayout;
|
||||
export default MediumLayout;
|
||||
@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AuthSmallLayout = ({ username }) => {
|
||||
const SmallLayout = ({ username }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
@@ -34,8 +34,8 @@ const AuthSmallLayout = ({ username }) => {
|
||||
);
|
||||
};
|
||||
|
||||
AuthSmallLayout.propTypes = {
|
||||
SmallLayout.propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AuthSmallLayout;
|
||||
export default SmallLayout;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as AuthLargeLayout } from './LargeLayout';
|
||||
export { default as AuthMediumLayout } from './MediumLayout';
|
||||
export { default as AuthSmallLayout } from './SmallLayout';
|
||||
@@ -1,17 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'start.learning': {
|
||||
id: 'start.learning',
|
||||
defaultMessage: 'Start learning',
|
||||
description: 'Header text for logistration MFE pages',
|
||||
'welcome.to.platform': {
|
||||
id: 'welcome.to.platform',
|
||||
defaultMessage: 'Welcome to {siteName}, {username}!',
|
||||
description: 'Welcome message that appears on progressive profile page',
|
||||
},
|
||||
'with.site.name': {
|
||||
id: 'with.site.name',
|
||||
defaultMessage: 'with {siteName}',
|
||||
description: 'Header text with site name for logistration MFE pages',
|
||||
},
|
||||
// authenticated user base component text
|
||||
'complete.your.profile.1': {
|
||||
id: 'complete.your.profile.1',
|
||||
defaultMessage: 'Complete',
|
||||
@@ -22,11 +16,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'your profile',
|
||||
description: 'part of text "complete your profile"',
|
||||
},
|
||||
'welcome.to.platform': {
|
||||
id: 'welcome.to.platform',
|
||||
defaultMessage: 'Welcome to {siteName}, {username}!',
|
||||
description: 'Welcome message that appears on progressive profile page',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
4
src/base-container/data/constants.js
Normal file
4
src/base-container/data/constants.js
Normal file
@@ -0,0 +1,4 @@
|
||||
const IMAGE_LAYOUT = 'image-layout';
|
||||
const DEFAULT_LAYOUT = 'default-layout';
|
||||
|
||||
export { DEFAULT_LAYOUT, IMAGE_LAYOUT };
|
||||
89
src/base-container/index.jsx
Normal file
89
src/base-container/index.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
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';
|
||||
import MediaQuery from 'react-responsive';
|
||||
|
||||
import { DefaultLargeLayout, DefaultMediumLayout, DefaultSmallLayout } from './components/default-layout';
|
||||
import {
|
||||
ImageExtraSmallLayout, ImageLargeLayout, ImageMediumLayout, ImageSmallLayout,
|
||||
} from './components/image-layout';
|
||||
import { AuthLargeLayout, AuthMediumLayout, AuthSmallLayout } from './components/welcome-page-layout';
|
||||
import { DEFAULT_LAYOUT, IMAGE_LAYOUT } from './data/constants';
|
||||
|
||||
const BaseContainer = ({ children, showWelcomeBanner }) => {
|
||||
const authenticatedUser = showWelcomeBanner ? getAuthenticatedUser() : null;
|
||||
const username = authenticatedUser ? authenticatedUser.username : null;
|
||||
|
||||
const [baseContainerVersion, setBaseContainerVersion] = useState(DEFAULT_LAYOUT);
|
||||
|
||||
useEffect(() => {
|
||||
const initRebrandExperiment = () => {
|
||||
if (window.experiments?.rebrandExperiment) {
|
||||
setBaseContainerVersion(window.experiments?.rebrandExperiment?.variation);
|
||||
} else {
|
||||
window.experiments = window.experiments || {};
|
||||
window.experiments.rebrandExperiment = {};
|
||||
window.experiments.rebrandExperiment.handleLoaded = () => {
|
||||
setBaseContainerVersion(window.experiments?.rebrandExperiment?.variation);
|
||||
};
|
||||
}
|
||||
};
|
||||
initRebrandExperiment();
|
||||
}, []);
|
||||
|
||||
if (baseContainerVersion === IMAGE_LAYOUT) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<MediaQuery maxWidth={breakpoints.extraSmall.maxWidth - 1}>
|
||||
{authenticatedUser ? <AuthSmallLayout username={username} /> : <ImageExtraSmallLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.small.minWidth} maxWidth={breakpoints.small.maxWidth - 1}>
|
||||
{authenticatedUser ? <AuthSmallLayout username={username} /> : <ImageSmallLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
|
||||
{authenticatedUser ? <AuthMediumLayout username={username} /> : <ImageMediumLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.extraLarge.minWidth}>
|
||||
{authenticatedUser ? <AuthLargeLayout username={username} /> : <ImageLargeLayout />}
|
||||
</MediaQuery>
|
||||
<div className={classNames('content', { 'align-items-center mt-0': authenticatedUser })}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-md-12 extra-large-screen-top-stripe" />
|
||||
<div className="layout">
|
||||
<MediaQuery maxWidth={breakpoints.small.maxWidth - 1}>
|
||||
{authenticatedUser ? <AuthSmallLayout username={username} /> : <DefaultSmallLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
|
||||
{authenticatedUser ? <AuthMediumLayout username={username} /> : <DefaultMediumLayout />}
|
||||
</MediaQuery>
|
||||
<MediaQuery minWidth={breakpoints.extraLarge.minWidth}>
|
||||
{authenticatedUser ? <AuthLargeLayout username={username} /> : <DefaultLargeLayout />}
|
||||
</MediaQuery>
|
||||
<div className={classNames('content', { 'align-items-center mt-0': authenticatedUser })}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
BaseContainer.defaultProps = {
|
||||
showWelcomeBanner: false,
|
||||
};
|
||||
|
||||
BaseContainer.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
showWelcomeBanner: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default BaseContainer;
|
||||
43
src/base-container/tests/BaseContainer.test.jsx
Normal file
43
src/base-container/tests/BaseContainer.test.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { Context as ResponsiveContext } from 'react-responsive';
|
||||
|
||||
import BaseContainer from '../index';
|
||||
|
||||
const LargeScreen = {
|
||||
wrappingComponent: ResponsiveContext.Provider,
|
||||
wrappingComponentProps: { value: { width: 1200 } },
|
||||
};
|
||||
|
||||
describe('Base component tests', () => {
|
||||
it('should should default layout', () => {
|
||||
const baseContainer = mount(
|
||||
<IntlProvider locale="en">
|
||||
<BaseContainer />
|
||||
</IntlProvider>,
|
||||
LargeScreen,
|
||||
);
|
||||
|
||||
expect(baseContainer.find('.banner__image').exists()).toBeFalsy();
|
||||
expect(baseContainer.find('.large-screen-svg-primary').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('[experiment] should show image layout for treatment group', () => {
|
||||
window.experiments = {
|
||||
rebrandExperiment: {
|
||||
variation: 'image-layout',
|
||||
},
|
||||
};
|
||||
|
||||
const baseContainer = mount(
|
||||
<IntlProvider locale="en">
|
||||
<BaseContainer />
|
||||
</IntlProvider>,
|
||||
LargeScreen,
|
||||
);
|
||||
|
||||
expect(baseContainer.find('.banner__image').exists()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
28
src/common-components/EmbeddedRegistrationRoute.jsx
Normal file
28
src/common-components/EmbeddedRegistrationRoute.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { PAGE_NOT_FOUND } from '../data/constants';
|
||||
import { isHostAvailableInQueryParams } from '../data/utils';
|
||||
|
||||
/**
|
||||
* This wrapper redirects the requester to embedded register page only if host
|
||||
* query param is present.
|
||||
*/
|
||||
const EmbeddedRegistrationRoute = ({ children }) => {
|
||||
const registrationEmbedded = isHostAvailableInQueryParams();
|
||||
|
||||
// Show registration page for embedded experience even if the user is authenticated
|
||||
if (registrationEmbedded) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return <Navigate to={PAGE_NOT_FOUND} replace />;
|
||||
};
|
||||
|
||||
EmbeddedRegistrationRoute.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default EmbeddedRegistrationRoute;
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { AUTHN_PROGRESSIVE_PROFILING, RECOMMENDATIONS } from '../data/constants';
|
||||
import {
|
||||
AUTHN_PROGRESSIVE_PROFILING, RECOMMENDATIONS,
|
||||
} from '../data/constants';
|
||||
import { setCookie } from '../data/utils';
|
||||
|
||||
const RedirectLogistration = (props) => {
|
||||
@@ -35,15 +35,16 @@ const RedirectLogistration = (props) => {
|
||||
if (redirectToProgressiveProfilingPage) {
|
||||
// TODO: Do we still need this cookie?
|
||||
setCookie('van-504-returning-user', true);
|
||||
|
||||
const registrationResult = { redirectUrl: finalRedirectUrl, success };
|
||||
return (
|
||||
<Redirect to={{
|
||||
pathname: AUTHN_PROGRESSIVE_PROFILING,
|
||||
state: {
|
||||
<Navigate
|
||||
to={AUTHN_PROGRESSIVE_PROFILING}
|
||||
state={{
|
||||
registrationResult,
|
||||
optionalFields,
|
||||
},
|
||||
}}
|
||||
}}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -52,14 +53,14 @@ const RedirectLogistration = (props) => {
|
||||
if (redirectToRecommendationsPage) {
|
||||
const registrationResult = { redirectUrl: finalRedirectUrl, success };
|
||||
return (
|
||||
<Redirect to={{
|
||||
pathname: RECOMMENDATIONS,
|
||||
state: {
|
||||
<Navigate
|
||||
to={RECOMMENDATIONS}
|
||||
state={{
|
||||
registrationResult,
|
||||
educationLevel,
|
||||
userId,
|
||||
},
|
||||
}}
|
||||
}}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { Route } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DEFAULT_REDIRECT_URL } from '../data/constants';
|
||||
import {
|
||||
DEFAULT_REDIRECT_URL,
|
||||
} from '../data/constants';
|
||||
|
||||
/**
|
||||
* This wrapper redirects the requester to our default redirect url if they are
|
||||
* already authenticated.
|
||||
*/
|
||||
const UnAuthOnlyRoute = (props) => {
|
||||
const UnAuthOnlyRoute = ({ children }) => {
|
||||
const [authUser, setAuthUser] = useState({});
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
@@ -27,10 +29,14 @@ const UnAuthOnlyRoute = (props) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Route {...props} />;
|
||||
return children;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
UnAuthOnlyRoute.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default UnAuthOnlyRoute;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import Zendesk from 'react-zendesk';
|
||||
|
||||
import messages from './messages';
|
||||
import { REGISTER_EMBEDDED_PAGE } from '../data/constants';
|
||||
|
||||
const ZendeskHelp = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
@@ -48,6 +49,10 @@ const ZendeskHelp = () => {
|
||||
},
|
||||
};
|
||||
|
||||
if (window.location.pathname === REGISTER_EMBEDDED_PAGE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Zendesk defer zendeskKey={getConfig().ZENDESK_KEY} {...setting} />
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { THIRD_PARTY_AUTH_CONTEXT, THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG } from './actions';
|
||||
import { COMPLETE_STATE, PENDING_STATE } from '../../data/constants';
|
||||
import { COMPLETE_STATE, FAILURE_STATE, PENDING_STATE } from '../../data/constants';
|
||||
|
||||
export const defaultState = {
|
||||
fieldDescriptions: {},
|
||||
optionalFields: {},
|
||||
optionalFields: {
|
||||
fields: {},
|
||||
extended_profile: [],
|
||||
},
|
||||
thirdPartyAuthApiStatus: null,
|
||||
thirdPartyAuthContext: {
|
||||
currentProvider: null,
|
||||
@@ -13,6 +16,7 @@ export const defaultState = {
|
||||
secondaryProviders: [],
|
||||
pipelineUserDetails: null,
|
||||
errorMessage: null,
|
||||
welcomePageRedirectUrl: null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,7 +39,11 @@ const reducer = (state = defaultState, action = {}) => {
|
||||
case THIRD_PARTY_AUTH_CONTEXT.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
thirdPartyAuthApiStatus: COMPLETE_STATE,
|
||||
thirdPartyAuthApiStatus: FAILURE_STATE,
|
||||
thirdPartyAuthContext: {
|
||||
...state.thirdPartyAuthContext,
|
||||
errorMessage: null,
|
||||
},
|
||||
};
|
||||
case THIRD_PARTY_AUTH_CONTEXT_CLEAR_ERROR_MSG:
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as RedirectLogistration } from './RedirectLogistration';
|
||||
export { default as registerIcons } from './RegisterFaIcons';
|
||||
export { default as EmbeddedRegistrationRoute } from './EmbeddedRegistrationRoute';
|
||||
export { default as UnAuthOnlyRoute } from './UnAuthOnlyRoute';
|
||||
export { default as NotFoundPage } from './NotFoundPage';
|
||||
export { default as SocialAuthProviders } from './SocialAuthProviders';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable import/no-import-module-exports */
|
||||
/* eslint-disable react/function-component-definition */
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { mount } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { REGISTER_EMBEDDED_PAGE } from '../../data/constants';
|
||||
import EmbeddedRegistrationRoute from '../EmbeddedRegistrationRoute';
|
||||
|
||||
import {
|
||||
MemoryRouter, Route, BrowserRouter as Router, Routes,
|
||||
} from 'react-router-dom';
|
||||
|
||||
const RRD = require('react-router-dom');
|
||||
// Just render plain div with its children
|
||||
// eslint-disable-next-line react/prop-types
|
||||
RRD.BrowserRouter = ({ children }) => <div>{ children }</div>;
|
||||
module.exports = RRD;
|
||||
|
||||
const TestApp = () => (
|
||||
<Router>
|
||||
<div>
|
||||
<Routes>
|
||||
<Route
|
||||
path={REGISTER_EMBEDDED_PAGE}
|
||||
element={<EmbeddedRegistrationRoute><span>Embedded Register Page</span></EmbeddedRegistrationRoute>}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
|
||||
describe('EmbeddedRegistrationRoute', () => {
|
||||
const routerWrapper = () => (
|
||||
<MemoryRouter initialEntries={[REGISTER_EMBEDDED_PAGE]}>
|
||||
<TestApp />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should not render embedded register page if host query param is not available in the url', async () => {
|
||||
let embeddedRegistrationPage = null;
|
||||
|
||||
await act(async () => {
|
||||
embeddedRegistrationPage = await mount(routerWrapper());
|
||||
});
|
||||
|
||||
expect(embeddedRegistrationPage.find('span').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render embedded register page if host query param is available in the url (embedded)', async () => {
|
||||
delete window.location;
|
||||
window.location = {
|
||||
href: getConfig().BASE_URL.concat(REGISTER_EMBEDDED_PAGE),
|
||||
search: '?host=http://localhost/host-websit',
|
||||
};
|
||||
|
||||
let embeddedRegistrationPage = null;
|
||||
|
||||
await act(async () => {
|
||||
embeddedRegistrationPage = await mount(routerWrapper());
|
||||
});
|
||||
|
||||
expect(embeddedRegistrationPage.find('span').exists()).toBeTruthy();
|
||||
expect(embeddedRegistrationPage.find('span').text()).toBe('Embedded Register Page');
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,11 @@ import { mount } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { UnAuthOnlyRoute } from '..';
|
||||
import { LOGIN_PAGE } from '../../data/constants';
|
||||
import { REGISTER_PAGE } from '../../data/constants';
|
||||
|
||||
import { MemoryRouter, BrowserRouter as Router, Switch } from 'react-router-dom';
|
||||
import {
|
||||
MemoryRouter, Route, BrowserRouter as Router, Routes,
|
||||
} from 'react-router-dom';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(),
|
||||
@@ -25,16 +27,16 @@ module.exports = RRD;
|
||||
const TestApp = () => (
|
||||
<Router>
|
||||
<div>
|
||||
<Switch>
|
||||
<UnAuthOnlyRoute path={LOGIN_PAGE} render={() => (<span>Login Page</span>)} />
|
||||
</Switch>
|
||||
<Routes>
|
||||
<Route path={REGISTER_PAGE} element={<UnAuthOnlyRoute><span>Register Page</span></UnAuthOnlyRoute>} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
|
||||
describe('UnAuthOnlyRoute', () => {
|
||||
const routerWrapper = () => (
|
||||
<MemoryRouter initialEntries={[LOGIN_PAGE]}>
|
||||
<MemoryRouter initialEntries={[REGISTER_PAGE]}>
|
||||
<TestApp />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ exports[`SocialAuthProviders should match social auth provider with default icon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11 7L9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5-5-5zm9 12h-8v2h10V3H12v2h8v14z"
|
||||
d="M11 7 9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5-5-5zm9 12h-8v2h10V3H12v2h8v14z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -2,12 +2,11 @@ const configuration = {
|
||||
// Cookies related configs
|
||||
SESSION_COOKIE_DOMAIN: process.env.SESSION_COOKIE_DOMAIN,
|
||||
REGISTER_CONVERSION_COOKIE_NAME: process.env.REGISTER_CONVERSION_COOKIE_NAME || null,
|
||||
USER_SURVEY_COOKIE_NAME: process.env.USER_SURVEY_COOKIE_NAME || null,
|
||||
// Features
|
||||
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_PERSONALIZED_RECOMMENDATIONS: process.env.ENABLE_PERSONALIZED_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
|
||||
@@ -15,11 +14,21 @@ const configuration = {
|
||||
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: process.env.AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK || null,
|
||||
LOGIN_ISSUE_SUPPORT_LINK: process.env.LOGIN_ISSUE_SUPPORT_LINK || null,
|
||||
PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK || null,
|
||||
POST_REGISTRATION_REDIRECT_URL: process.env.POST_REGISTRATION_REDIRECT_URL || '',
|
||||
PRIVACY_POLICY: process.env.PRIVACY_POLICY || null,
|
||||
SEARCH_CATALOG_URL: process.env.SEARCH_CATALOG_URL || null,
|
||||
TOS_AND_HONOR_CODE: process.env.TOS_AND_HONOR_CODE || null,
|
||||
TOS_LINK: process.env.TOS_LINK || null,
|
||||
// Miscellaneous
|
||||
// Base container images
|
||||
BANNER_IMAGE_LARGE: process.env.BANNER_IMAGE_LARGE || '',
|
||||
BANNER_IMAGE_MEDIUM: process.env.BANNER_IMAGE_MEDIUM || '',
|
||||
BANNER_IMAGE_SMALL: process.env.BANNER_IMAGE_SMALL || '',
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// URL Paths
|
||||
export const LOGIN_PAGE = '/login';
|
||||
export const REGISTER_PAGE = '/register';
|
||||
export const REGISTER_EMBEDDED_PAGE = '/register-embedded';
|
||||
export const RESET_PAGE = '/reset';
|
||||
export const AUTHN_PROGRESSIVE_PROFILING = '/welcome';
|
||||
export const DEFAULT_REDIRECT_URL = '/dashboard';
|
||||
@@ -23,6 +24,7 @@ export const PENDING_STATE = 'pending';
|
||||
export const COMPLETE_STATE = 'complete';
|
||||
export const FAILURE_STATE = 'failure';
|
||||
export const FORBIDDEN_STATE = 'forbidden';
|
||||
export const EMBEDDED = 'embedded';
|
||||
|
||||
// Regex
|
||||
export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+)*'
|
||||
@@ -30,9 +32,11 @@ export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+
|
||||
+ ')@((?:[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.
|
||||
export const AUTH_PARAMS = ['course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow', 'next', 'save_for_later', 'register_for_free', 'track', 'is_account_recovery'];
|
||||
export const AUTH_PARAMS = ['course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow', 'next', 'register_for_free', 'track', 'is_account_recovery', 'variant', 'host', 'cta'];
|
||||
export const REDIRECT = 'redirect';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import Cookies from 'universal-cookie';
|
||||
|
||||
export function setCookie(cookieName, cookieValue, cookieExpiry) {
|
||||
export default function setCookie(cookieName, cookieValue, cookieExpiry) {
|
||||
const cookies = new Cookies();
|
||||
const options = { domain: getConfig().SESSION_COOKIE_DOMAIN, path: '/' };
|
||||
if (cookieExpiry) {
|
||||
@@ -9,13 +9,3 @@ export function setCookie(cookieName, cookieValue, cookieExpiry) {
|
||||
}
|
||||
cookies.set(cookieName, cookieValue, options);
|
||||
}
|
||||
|
||||
export default function setSurveyCookie(surveyType) {
|
||||
const cookieName = getConfig().USER_SURVEY_COOKIE_NAME;
|
||||
if (cookieName) {
|
||||
const signupTimestamp = (new Date()).getTime();
|
||||
// set expiry to exactly 24 hours from now
|
||||
const cookieExpiry = new Date(signupTimestamp + 1 * 864e5);
|
||||
setCookie(cookieName, surveyType, cookieExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,3 +76,8 @@ export const windowScrollTo = (options) => {
|
||||
|
||||
return window.scrollTo(options.top, options.left);
|
||||
};
|
||||
|
||||
export const isHostAvailableInQueryParams = () => {
|
||||
const queryParams = getAllPossibleQueryParams();
|
||||
return 'host' in queryParams;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export {
|
||||
getTpaProvider,
|
||||
getTpaHint,
|
||||
updatePathWithQueryParams,
|
||||
getAllPossibleQueryParams,
|
||||
getActivationStatus,
|
||||
isHostAvailableInQueryParams,
|
||||
updatePathWithQueryParams,
|
||||
windowScrollTo,
|
||||
} from './dataUtils';
|
||||
export { default as AsyncActionType } from './reduxUtils';
|
||||
export { default as setSurveyCookie, setCookie } from './cookies';
|
||||
export { default as setCookie } from './cookies';
|
||||
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
import { ChevronLeft } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { forgotPassword, setForgotPasswordFormData } from './data/actions';
|
||||
import { forgotPasswordResultSelector } from './data/selectors';
|
||||
import ForgotPasswordAlert from './ForgotPasswordAlert';
|
||||
import messages from './messages';
|
||||
import { BaseComponent } from '../base-component';
|
||||
import BaseContainer from '../base-container';
|
||||
import { FormGroup } from '../common-components';
|
||||
import { DEFAULT_STATE, LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants';
|
||||
import { updatePathWithQueryParams, windowScrollTo } from '../data/utils';
|
||||
@@ -38,7 +38,7 @@ const ForgotPasswordPage = (props) => {
|
||||
const [bannerEmail, setBannerEmail] = useState('');
|
||||
const [formErrors, setFormErrors] = useState('');
|
||||
const [validationError, setValidationError] = useState(emailValidationError);
|
||||
const [key, setKey] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
sendPageEvent('login_and_registration', 'reset');
|
||||
@@ -95,19 +95,16 @@ const ForgotPasswordPage = (props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseComponent>
|
||||
<BaseContainer>
|
||||
<Helmet>
|
||||
<title>{formatMessage(messages['forgot.password.page.title'],
|
||||
{ siteName: getConfig().SITE_NAME })}
|
||||
</title>
|
||||
</Helmet>
|
||||
<div>
|
||||
<Tabs activeKey="" id="controlled-tab" onSelect={(k) => setKey(k)}>
|
||||
<Tabs activeKey="" id="controlled-tab" onSelect={(key) => navigate(updatePathWithQueryParams(key))}>
|
||||
<Tab title={tabTitle} eventKey={LOGIN_PAGE} />
|
||||
</Tabs>
|
||||
{ key && (
|
||||
<Redirect to={updatePathWithQueryParams(key)} />
|
||||
)}
|
||||
<div id="main-content" className="main-content">
|
||||
<Form id="forget-password-form" name="forget-password-form" className="mw-xs">
|
||||
<ForgotPasswordAlert email={bannerEmail} emailError={formErrors} status={status} />
|
||||
@@ -163,7 +160,7 @@ const ForgotPasswordPage = (props) => {
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</BaseComponent>
|
||||
</BaseContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ import { Provider } from 'react-redux';
|
||||
import { mergeConfig } from '@edx/frontend-platform';
|
||||
import { configure, injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter, Router } from 'react-router-dom';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import { INTERNAL_SERVER_ERROR, LOGIN_PAGE } from '../../data/constants';
|
||||
@@ -14,15 +13,20 @@ import { PASSWORD_RESET } from '../../reset-password/data/constants';
|
||||
import { setForgotPasswordFormData } from '../data/actions';
|
||||
import ForgotPasswordPage from '../ForgotPasswordPage';
|
||||
|
||||
const mockedNavigator = jest.fn();
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||
sendPageEvent: jest.fn(),
|
||||
sendTrackEvent: jest.fn(),
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...(jest.requireActual('react-router-dom')),
|
||||
useNavigate: () => mockedNavigator,
|
||||
}));
|
||||
|
||||
const IntlForgotPasswordPage = injectIntl(ForgotPasswordPage);
|
||||
const mockStore = configureStore();
|
||||
const history = createMemoryHistory();
|
||||
|
||||
const initialState = {
|
||||
forgotPassword: {
|
||||
@@ -225,15 +229,11 @@ describe('ForgotPasswordPage', () => {
|
||||
});
|
||||
|
||||
it('should redirect onto login page', async () => {
|
||||
const forgotPasswordPage = mount(reduxWrapper(
|
||||
<Router history={history}>
|
||||
<IntlForgotPasswordPage {...props} />
|
||||
</Router>,
|
||||
));
|
||||
const forgotPasswordPage = mount(reduxWrapper(<IntlForgotPasswordPage {...props} />));
|
||||
|
||||
await act(async () => { await forgotPasswordPage.find('nav').find('a').first().simulate('click'); });
|
||||
|
||||
forgotPasswordPage.update();
|
||||
expect(history.location.pathname).toEqual(LOGIN_PAGE);
|
||||
expect(mockedNavigator).toHaveBeenCalledWith(LOGIN_PAGE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "ابدأ التعلم ",
|
||||
"with.site.name": "مع {siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "is here.",
|
||||
"welcome.to.platform": "أهلا بك {username} في {siteName}",
|
||||
"complete.your.profile.1": "أكمل",
|
||||
"complete.your.profile.2": "ملفك الشخصي",
|
||||
"welcome.to.platform": "أهلا بك {username} في {siteName}",
|
||||
"institution.login.page.sub.heading": "اختر مؤسستك من القائمة أدناه",
|
||||
"logistration.sign.in": "تسجيل الدخول",
|
||||
"logistration.register": "التسجيل",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "Start learning",
|
||||
"with.site.name": "with {siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "is here.",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Complete",
|
||||
"complete.your.profile.2": "your profile",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Choose your institution from the list below",
|
||||
"logistration.sign.in": "Sign in",
|
||||
"logistration.register": "Register",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "Beginne zu lernen",
|
||||
"with.site.name": "mit {siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "is here.",
|
||||
"welcome.to.platform": "Willkommen bei {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Vervollständige",
|
||||
"complete.your.profile.2": "dein Profil",
|
||||
"welcome.to.platform": "Willkommen bei {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Wählen Sie Ihre Institution aus der folgenden Liste aus",
|
||||
"logistration.sign.in": "Anmelden",
|
||||
"logistration.register": "Registrieren",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"start.learning": "Empieza a aprender",
|
||||
"with.site.name": "con {siteName}",
|
||||
"your.career.turning.point": "El punto de inflexión de tu carrera",
|
||||
"is.here": "es aquí.",
|
||||
"welcome.to.platform": "¡Bienvenido a {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Completado",
|
||||
"complete.your.profile.2": "tu perfil ",
|
||||
"welcome.to.platform": "¡Bienvenido a {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Selecciona tu institución de la lista siguiente",
|
||||
"logistration.sign.in": "Iniciar sesión",
|
||||
"logistration.register": "Registrarse",
|
||||
"enterprisetpa.title.heading": "¿Deseas iniciar sesión con tus credenciales de {providerName}?",
|
||||
"enterprisetpa.login.button.text": "Mostrar otras formas de iniciar sesión o de registrarme",
|
||||
"enterprisetpa.login.button.text.public.account.creation.disabled": "Show me other ways to sign in",
|
||||
"enterprisetpa.login.button.text.public.account.creation.disabled": "Mostrar otras formas de iniciar sesión",
|
||||
"sso.sign.in.with": "Inicio de sesión con {providerName}",
|
||||
"sso.create.account.using": "Crear una cuenta con {providerName}",
|
||||
"show.password": "Mostrar contraseña",
|
||||
@@ -22,8 +24,8 @@
|
||||
"login.third.party.auth.account.not.linked": "Te has registrado correctamente en {currentProvider}, pero tu cuenta de {currentProvider} no tiene una cuenta de {platformName} asociada. Para asociar tus cuentas, inicia sesión ahora usando tu contraseña de {platformName}.",
|
||||
"register.third.party.auth.account.not.linked": "¡Has iniciado sesión con éxito en {currentProvider}! Sólo necesitamos un poco más de información antes de que empieces a aprender con {platformName}.",
|
||||
"registration.using.tpa.form.heading": "Termina de crear tu cuenta",
|
||||
"zendesk.supportTitle": "edX Support",
|
||||
"zendesk.selectTicketForm": "Please choose your request type:",
|
||||
"zendesk.supportTitle": "Soporte edX",
|
||||
"zendesk.selectTicketForm": "Elegir el tipo de solicitud:",
|
||||
"error.notfound.message": "La página que estas buscando no está disponible o hay un error en la URL. Por favor, verifica la URL y vuelve a intentarlo.",
|
||||
"forgot.password.confirmation.message": "Hemos enviado un correo electrónico a {email} con instrucciones para restablecer tu contraseña.\n Si no recibes un mensaje de restablecimiento de contraseña después de 1 minuto, verifica que has introducido\n la dirección de correo electrónico correcta, o comprueba tu carpeta de correo no deseado. Si necesitas más ayuda, {supportLink}.",
|
||||
"forgot.password.page.title": "Olvidé la contraseña | {siteName}",
|
||||
@@ -96,8 +98,8 @@
|
||||
"password.security.block.body": "Nuestro sistema detectó que su contraseña es vulnerable. Cambie su contraseña para que su cuenta permanezca segura.",
|
||||
"password.security.close.button": "Cerrar",
|
||||
"password.security.redirect.to.reset.password.button": "Restablece tu contraseña",
|
||||
"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": "Welcome | {siteName}",
|
||||
"login.tpa.authentication.failure": "Lo sentimos, no está autorizado para acceder a {platform_name} a través de este canal. Comuníquese con su administrador o gerente de aprendizaje para acceder a {platform_name}.{lineBreak}{lineBreak}Detalles del error:{lineBreak}{errorMessage}",
|
||||
"progressive.profiling.page.title": "Bienvenido | {siteName}",
|
||||
"progressive.profiling.page.heading": "Unas cuantas preguntas para ti nos ayudarán a mejorar.",
|
||||
"optional.fields.information.link": "Aprende más sobre cómo usamos esta información.",
|
||||
"optional.fields.submit.button": "Enviar",
|
||||
@@ -141,7 +143,7 @@
|
||||
"registration.request.server.error": "Se ha producido un error. Intenta actualizar la página o comprueba tu conexión a Internet.",
|
||||
"registration.rate.limit.error": "Demasiados intentos de registro fallidos. Vuelve a intentarlo más tarde.",
|
||||
"registration.tpa.session.expired": "Inscripción usando {provider} ha expirado.",
|
||||
"registration.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}",
|
||||
"registration.tpa.authentication.failure": "Lo sentimos, no está autorizado para acceder a {platform_name} a través de este canal. Comuníquese con su administrador o gerente de aprendizaje para acceder a {platform_name}.{lineBreak}{lineBreak}Detalles del error:{lineBreak}{errorMessage}",
|
||||
"terms.of.service.and.honor.code": "Condiciones de servicio y código de honor",
|
||||
"privacy.policy": "Política de privacidad ",
|
||||
"honor.code": "Código de Honor",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "Démarrer l'apprentissage",
|
||||
"with.site.name": "avec {siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "is here.",
|
||||
"welcome.to.platform": "Bienvenue sur {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Terminé",
|
||||
"complete.your.profile.2": "votre profil",
|
||||
"welcome.to.platform": "Bienvenue sur {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Sélectionner votre institution dans la liste ci-dessous",
|
||||
"logistration.sign.in": "Connectez-vous",
|
||||
"logistration.register": "S'inscrire",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "Démarrer l'apprentissage",
|
||||
"with.site.name": "avec {siteName}",
|
||||
"your.career.turning.point": "Votre tournant de carrière",
|
||||
"is.here": "est là.",
|
||||
"welcome.to.platform": "Bienvenue sur {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Complet",
|
||||
"complete.your.profile.2": "votre profil",
|
||||
"welcome.to.platform": "Bienvenue sur {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Sélectionner votre institution dans la liste ci-dessous",
|
||||
"logistration.sign.in": "Connexion",
|
||||
"logistration.register": "Inscription",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "Start learning",
|
||||
"with.site.name": "with {siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "is here.",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Complete",
|
||||
"complete.your.profile.2": "your profile",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Choose your institution from the list below",
|
||||
"logistration.sign.in": "Sign in",
|
||||
"logistration.register": "Register",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "Start learning",
|
||||
"with.site.name": "with {siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "is here.",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Complete",
|
||||
"complete.your.profile.2": "your profile",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Choose your institution from the list below",
|
||||
"logistration.sign.in": "Sign in",
|
||||
"logistration.register": "Register",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "Inizia a imparare",
|
||||
"with.site.name": "con {siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "is here.",
|
||||
"welcome.to.platform": "Benvenuto in {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Completata",
|
||||
"complete.your.profile.2": "Il tuo profilo",
|
||||
"welcome.to.platform": "Benvenuto in {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Scegli il tuo istituto dall'elenco sottostante",
|
||||
"logistration.sign.in": "Accedi",
|
||||
"logistration.register": "Registrazione",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "Start learning",
|
||||
"with.site.name": "with {siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "is here.",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Complete",
|
||||
"complete.your.profile.2": "your profile",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Choose your institution from the list below",
|
||||
"logistration.sign.in": "Sign in",
|
||||
"logistration.register": "Register",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "Começar a aprender",
|
||||
"with.site.name": "com {siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "is here.",
|
||||
"welcome.to.platform": "Bem vindo a {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Concluído",
|
||||
"complete.your.profile.2": "o seu perfil",
|
||||
"welcome.to.platform": "Bem vindo a {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Escolha a sua instituição a partir da lista abaixo",
|
||||
"logistration.sign.in": "Iniciar sessão",
|
||||
"logistration.register": "Registe-se",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "Start learning",
|
||||
"with.site.name": "with {siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "is here.",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Complete",
|
||||
"complete.your.profile.2": "your profile",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Choose your institution from the list below",
|
||||
"logistration.sign.in": "Sign in",
|
||||
"logistration.register": "Register",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"start.learning": "Start learning",
|
||||
"with.site.name": "with {siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "is here.",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"complete.your.profile.1": "Complete",
|
||||
"complete.your.profile.2": "your profile",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Choose your institution from the list below",
|
||||
"logistration.sign.in": "Sign in",
|
||||
"logistration.register": "Register",
|
||||
|
||||
@@ -1,168 +1,170 @@
|
||||
{
|
||||
"start.learning": "Start learning",
|
||||
"with.site.name": "with {siteName}",
|
||||
"complete.your.profile.1": "Complete",
|
||||
"complete.your.profile.2": "your profile",
|
||||
"welcome.to.platform": "Welcome to {siteName}, {username}!",
|
||||
"institution.login.page.sub.heading": "Choose your institution from the list below",
|
||||
"logistration.sign.in": "Sign in",
|
||||
"logistration.register": "Register",
|
||||
"enterprisetpa.title.heading": "Would you like to sign in using your {providerName} credentials?",
|
||||
"enterprisetpa.login.button.text": "Show me other ways to sign in or register",
|
||||
"enterprisetpa.login.button.text.public.account.creation.disabled": "Show me other ways to sign in",
|
||||
"sso.sign.in.with": "Sign in with {providerName}",
|
||||
"sso.create.account.using": "Create account using {providerName}",
|
||||
"show.password": "Show password",
|
||||
"hide.password": "Hide password",
|
||||
"one.letter": "1 letter",
|
||||
"one.number": "1 number",
|
||||
"eight.characters": "8 characters",
|
||||
"password.sr.only.helping.text": "Password must contain at least 8 characters, at least one letter, and at least one number",
|
||||
"tpa.alert.heading": "Almost done!",
|
||||
"login.third.party.auth.account.not.linked": "You have successfully signed into {currentProvider}, but your {currentProvider} account does not have a linked {platformName} account. To link your accounts, sign in now using your {platformName} password.",
|
||||
"register.third.party.auth.account.not.linked": "You've successfully signed into {currentProvider}! We just need a little more information before you start learning with {platformName}.",
|
||||
"registration.using.tpa.form.heading": "Finish creating your account",
|
||||
"start.learning": "开始学习",
|
||||
"with.site.name": "{siteName}",
|
||||
"your.career.turning.point": "Your career turning point",
|
||||
"is.here": "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 Support",
|
||||
"zendesk.selectTicketForm": "Please choose your request type:",
|
||||
"error.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
|
||||
"forgot.password.confirmation.message": "We sent an email to {email} with instructions to reset your password.\n If you do not receive a password reset message after 1 minute, verify that you entered\n the correct email address, or check your spam folder. If you need further assistance, {supportLink}.",
|
||||
"forgot.password.page.title": "Forgot Password | {siteName}",
|
||||
"forgot.password.page.heading": "Reset password",
|
||||
"forgot.password.page.instructions": "Please enter your email address below and we will send you an email with instructions on how to reset your password.",
|
||||
"forgot.password.page.invalid.email.message": "Enter a valid email address",
|
||||
"forgot.password.page.email.field.label": "Email",
|
||||
"forgot.password.page.submit.button": "Submit",
|
||||
"forgot.password.error.alert.title.": "We were unable to contact you.",
|
||||
"forgot.password.error.message.title": "An error occurred.",
|
||||
"forgot.password.request.in.progress.message": "Your previous request is in progress, please try again in a few moments.",
|
||||
"forgot.password.empty.email.field.error": "Enter your email",
|
||||
"forgot.password.email.help.text": "The email address you used to register with {platformName}",
|
||||
"confirmation.message.title": "Check your email",
|
||||
"confirmation.support.link": "contact technical support",
|
||||
"need.help.sign.in.text": "Need help signing in?",
|
||||
"additional.help.text": "For additional help, contact {platformName} support at ",
|
||||
"sign.in.text": "Sign in",
|
||||
"extend.field.errors": "{emailError} below.",
|
||||
"invalid.token.heading": "Invalid password reset link",
|
||||
"invalid.token.error.message": "This password reset link is invalid. It may have been used already. Enter your email below to receive a new link.",
|
||||
"token.validation.rate.limit.error.heading": "Too many requests",
|
||||
"token.validation.rate.limit.error": "An error has occurred because of too many requests. Please try again after some time.",
|
||||
"token.validation.internal.sever.error.heading": "Token validation failure",
|
||||
"token.validation.internal.sever.error": "An error has occurred. Try refreshing the page, or check your internet connection.",
|
||||
"internal.server.error": "An error has occurred. Try refreshing the page, or check your internet connection.",
|
||||
"account.activation.error.message": "Something went wrong, please {supportLink} to resolve this issue.",
|
||||
"login.inactive.user.error": "In order to sign in, you need to activate your account.{lineBreak}\n {lineBreak}We just sent an activation link to {email}. If you do not receive an email,\n check your spam folders or {supportLink}.",
|
||||
"allowed.domain.login.error": "As {allowedDomain} user, You must login with your {allowedDomain} {tpaLink}.",
|
||||
"login.incorrect.credentials.error.attempts.text.1": "The username, email or password you entered is incorrect. You have {remainingAttempts} more sign in\n attempts before your account is temporarily locked.",
|
||||
"login.incorrect.credentials.error.attempts.text.2": "If you've forgotten your password, {resetLink}",
|
||||
"account.locked.out.message.2": "To be on the safe side, you can {resetLink} before trying again.",
|
||||
"login.incorrect.credentials.error.with.reset.link": "The username, email, or password you entered is incorrect. Please try again or {resetLink}.",
|
||||
"login.page.title": "Login | {siteName}",
|
||||
"login.user.identity.label": "Username or email",
|
||||
"login.password.label": "Password",
|
||||
"sign.in.button": "Sign in",
|
||||
"forgot.password": "Forgot password",
|
||||
"institution.login.button": "Institution/campus credentials",
|
||||
"institution.login.page.title": "Sign in with institution/campus credentials",
|
||||
"login.other.options.heading": "Or sign in with:",
|
||||
"non.compliant.password.title": "We recently changed our password requirements",
|
||||
"non.compliant.password.message": "Your current password does not meet the new security requirements. We just sent a password-reset message to the email address associated with this account. Thank you for helping us keep your data safe.",
|
||||
"account.locked.out.message.1": "To protect your account, it's been temporarily locked. Try again in 30 minutes.",
|
||||
"enterprise.login.btn.text": "Company or school credentials",
|
||||
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 3 characters.",
|
||||
"email.validation.message": "Enter your username or email",
|
||||
"password.validation.message": "Password criteria has not been met",
|
||||
"account.activation.success.message.title": "Success! You have activated your account.",
|
||||
"account.activation.success.message": "You will now receive email updates and alerts from us related to the courses you are enrolled in. Sign in to continue.",
|
||||
"account.activation.info.message": "This account has already been activated.",
|
||||
"account.activation.error.message.title": "Your account could not be activated",
|
||||
"account.activation.support.link": "contact support",
|
||||
"account.confirmation.success.message.title": "Success! You have confirmed your email.",
|
||||
"account.confirmation.success.message": "Sign in to continue.",
|
||||
"account.confirmation.info.message": "This email has already been confirmed.",
|
||||
"account.confirmation.error.message.title": "Your email could not be confirmed",
|
||||
"tpa.account.link": "{provider} account",
|
||||
"internal.server.error.message": "An error has occurred. Try refreshing the page, or check your internet connection.",
|
||||
"login.rate.limit.reached.message": "Too many failed login attempts. Try again later.",
|
||||
"login.failure.header.title": "We couldn't sign you in.",
|
||||
"contact.support.link": "contact {platformName} support",
|
||||
"login.incorrect.credentials.error": "The username, email, or password you entered is incorrect. Please try again.",
|
||||
"login.form.invalid.error.message": "Please fill in the fields below.",
|
||||
"login.incorrect.credentials.error.reset.link.text": "reset your password",
|
||||
"login.incorrect.credentials.error.before.account.blocked.text": "click here to reset it.",
|
||||
"password.security.nudge.title": "Password security",
|
||||
"password.security.block.title": "Password change required",
|
||||
"password.security.nudge.body": "Our system detected that your password is vulnerable. We recommend you change it so that your account stays secure.",
|
||||
"password.security.block.body": "Our system detected that your password is vulnerable. Change your password so that your account stays secure.",
|
||||
"password.security.close.button": "Close",
|
||||
"password.security.redirect.to.reset.password.button": "Reset your password",
|
||||
"error.notfound.message": "您访问的地址不存在或有误。请检查URL后重新尝试访问。",
|
||||
"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": "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": "Welcome | {siteName}",
|
||||
"progressive.profiling.page.heading": "A few questions for you will help us get smarter.",
|
||||
"optional.fields.information.link": "Learn more about how we use this information.",
|
||||
"optional.fields.submit.button": "Submit",
|
||||
"optional.fields.skip.button": "Skip for now",
|
||||
"optional.fields.next.button": "Next",
|
||||
"continue.to.platform": "Continue to {platformName}",
|
||||
"modal.title": "Thanks for letting us know.",
|
||||
"modal.description": "You can complete your profile in settings at any time if you change your mind.",
|
||||
"welcome.page.error.heading": "We couldn't update your profile",
|
||||
"welcome.page.error.message": "An error occurred. You can complete your profile in settings at any time.",
|
||||
"recommendation.page.title": "Recommendations | {siteName}",
|
||||
"recommendation.page.heading": "We have a few recommendations to get you started.",
|
||||
"recommendation.skip.button": "Skip for now",
|
||||
"register.page.title": "Register | {siteName}",
|
||||
"registration.fullname.label": "Full name",
|
||||
"registration.email.label": "Email",
|
||||
"registration.username.label": "Public username",
|
||||
"registration.password.label": "Password",
|
||||
"registration.country.label": "Country/Region",
|
||||
"registration.opt.in.label": "I agree that {siteName} may send me marketing messages.",
|
||||
"help.text.name": "This name will be used by any certificates that you earn.",
|
||||
"help.text.username.1": "The name that will identify you in your courses.",
|
||||
"help.text.username.2": "This can not be changed later.",
|
||||
"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:",
|
||||
"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",
|
||||
"empty.email.field.error": "Enter your email",
|
||||
"empty.username.field.error": "Username must be between 2 and 30 characters",
|
||||
"empty.password.field.error": "Password criteria has not been met",
|
||||
"empty.country.field.error": "Select your country or region of residence",
|
||||
"email.do.not.match": "The email addresses do not match.",
|
||||
"email.invalid.format.error": "Enter a valid email address",
|
||||
"username.validation.message": "Username must be between 2 and 30 characters",
|
||||
"name.validation.message": "Enter a valid name",
|
||||
"username.format.validation.message": "Usernames can only contain letters (A-Z, a-z), numerals (0-9), underscores (_), and hyphens (-). Usernames cannot contain spaces",
|
||||
"registration.request.failure.header": "We couldn't create your account.",
|
||||
"registration.empty.form.submission.error": "Please check your responses and try again.",
|
||||
"registration.request.server.error": "An error has occurred. Try refreshing the page, or check your internet connection.",
|
||||
"registration.rate.limit.error": "Too many failed registration attempts. Try again later.",
|
||||
"registration.tpa.session.expired": "Registration using {provider} has timed out.",
|
||||
"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": "暂时跳过",
|
||||
"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": "或注册:",
|
||||
"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": "用户名只能包含字母(AZ、az)、数字(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": "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}",
|
||||
"terms.of.service.and.honor.code": "Terms of Service and Honor Code",
|
||||
"privacy.policy": "Privacy Policy",
|
||||
"honor.code": "Honor Code",
|
||||
"terms.of.service": "Terms of Service",
|
||||
"registration.username.suggestion.label": "Suggested:",
|
||||
"did.you.mean.alert.text": "Did you mean",
|
||||
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each\n Member process your personal data in accordance with the {privacyPolicy}.",
|
||||
"register.page.honor.code": "I agree to the {platformName} {tosAndHonorCode}",
|
||||
"register.page.terms.of.service": "I agree to the {platformName} {termsOfService}",
|
||||
"sign.in": "Sign in",
|
||||
"reset.password.page.title": "Reset Password | {siteName}",
|
||||
"reset.password": "Reset password",
|
||||
"reset.password.page.instructions": "Enter and confirm your new password.",
|
||||
"new.password.label": "New password",
|
||||
"confirm.password.label": "Confirm password",
|
||||
"passwords.do.not.match": "Passwords do not match",
|
||||
"confirm.your.password": "Confirm your password",
|
||||
"reset.password.failure.heading": "We couldn't reset your password.",
|
||||
"reset.password.form.submission.error": "Please check your responses and try again.",
|
||||
"reset.server.rate.limit.error": "Too many requests.",
|
||||
"reset.password.success.heading": "Password reset complete.",
|
||||
"reset.password.success": "Your password has been reset. Sign in to your account.",
|
||||
"rate.limit.error": "An error has occurred because of too many requests. Please try again after some time."
|
||||
"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": "由于请求过多而发生错误。请稍后重试。"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@edx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import messages from './messages';
|
||||
import { DEFAULT_REDIRECT_URL, RESET_PAGE } from '../data/constants';
|
||||
@@ -29,10 +29,14 @@ const ChangePasswordPrompt = ({ variant, redirectUrl }) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const [isOpen, open, close] = useToggle(true, handlers);
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectToResetPasswordPage) {
|
||||
navigate(updatePathWithQueryParams(RESET_PAGE));
|
||||
}
|
||||
}, [redirectToResetPasswordPage, navigate]);
|
||||
|
||||
if (redirectToResetPasswordPage) {
|
||||
return <Redirect to={updatePathWithQueryParams(RESET_PAGE)} />;
|
||||
}
|
||||
return (
|
||||
<ModalDialog
|
||||
title="Password security"
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
getAllPossibleQueryParams,
|
||||
getTpaHint,
|
||||
getTpaProvider,
|
||||
setSurveyCookie,
|
||||
updatePathWithQueryParams,
|
||||
windowScrollTo,
|
||||
} from '../data/utils';
|
||||
@@ -230,9 +229,6 @@ class LoginPage extends React.Component {
|
||||
};
|
||||
tpaAuthenticationError.errorCode = TPA_AUTHENTICATION_FAILURE;
|
||||
}
|
||||
if (this.props.loginResult.success) {
|
||||
setSurveyCookie('login');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -3,15 +3,19 @@ import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter, Router } from 'react-router-dom';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { RESET_PAGE } from '../../data/constants';
|
||||
import ChangePasswordPrompt from '../ChangePasswordPrompt';
|
||||
|
||||
const IntlChangePasswordPrompt = injectIntl(ChangePasswordPrompt);
|
||||
const history = createMemoryHistory();
|
||||
const mockedNavigator = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...(jest.requireActual('react-router-dom')),
|
||||
useNavigate: () => mockedNavigator,
|
||||
}));
|
||||
|
||||
describe('ChangePasswordPromptTests', () => {
|
||||
let props = {};
|
||||
@@ -55,9 +59,7 @@ describe('ChangePasswordPromptTests', () => {
|
||||
const changePasswordPrompt = mount(
|
||||
<IntlProvider locale="en">
|
||||
<MemoryRouter>
|
||||
<Router history={history}>
|
||||
<IntlChangePasswordPrompt {...props} />
|
||||
</Router>
|
||||
<IntlChangePasswordPrompt {...props} />
|
||||
</MemoryRouter>
|
||||
</IntlProvider>,
|
||||
);
|
||||
@@ -67,6 +69,6 @@ describe('ChangePasswordPromptTests', () => {
|
||||
});
|
||||
|
||||
changePasswordPrompt.update();
|
||||
expect(history.location.pathname).toEqual(RESET_PAGE);
|
||||
expect(mockedNavigator).toHaveBeenCalledWith(RESET_PAGE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,9 +30,6 @@ const IntlLoginPage = injectIntl(LoginPage);
|
||||
const mockStore = configureStore();
|
||||
|
||||
describe('LoginPage', () => {
|
||||
mergeConfig({
|
||||
USER_SURVEY_COOKIE_NAME: process.env.USER_SURVEY_COOKIE_NAME,
|
||||
});
|
||||
let props = {};
|
||||
let store = {};
|
||||
let loginFormData = {};
|
||||
@@ -685,21 +682,6 @@ describe('LoginPage', () => {
|
||||
expect(loginPage.find('LoginPage').state('isSubmitted')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should set login survey cookie', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
login: {
|
||||
...initialState.login,
|
||||
loginResult: {
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderer.create(reduxWrapper(<IntlLoginPage {...props} />));
|
||||
expect(document.cookie).toMatch(`${getConfig().USER_SURVEY_COOKIE_NAME}=login`);
|
||||
});
|
||||
|
||||
it('should reset login form errors', () => {
|
||||
const errorState = { emailOrUsername: '', password: '' };
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
@@ -12,16 +12,18 @@ import {
|
||||
} from '@edx/paragon';
|
||||
import { ChevronLeft } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { BaseComponent } from '../base-component';
|
||||
import BaseContainer from '../base-container';
|
||||
import { clearThirdPartyAuthContextErrorMessage } from '../common-components/data/actions';
|
||||
import {
|
||||
tpaProvidersSelector,
|
||||
} from '../common-components/data/selectors';
|
||||
import messages from '../common-components/messages';
|
||||
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
|
||||
import { getTpaHint, getTpaProvider, updatePathWithQueryParams } from '../data/utils';
|
||||
import {
|
||||
getTpaHint, getTpaProvider, updatePathWithQueryParams,
|
||||
} from '../data/utils';
|
||||
import { LoginPage } from '../login';
|
||||
import { RegistrationPage } from '../register';
|
||||
import { backupRegistrationForm } from '../register/data/actions';
|
||||
@@ -34,7 +36,7 @@ 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;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,6 +46,12 @@ const Logistration = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (disablePublicAccountCreation) {
|
||||
navigate(updatePathWithQueryParams(LOGIN_PAGE));
|
||||
}
|
||||
}, [navigate, disablePublicAccountCreation]);
|
||||
|
||||
const handleInstitutionLogin = (e) => {
|
||||
sendTrackEvent('edx.bi.institution_login_form.toggled', { category: 'user-engagement' });
|
||||
if (typeof e === 'string') {
|
||||
@@ -61,7 +69,7 @@ const Logistration = (props) => {
|
||||
if (tabKey === LOGIN_PAGE) {
|
||||
props.backupRegistrationForm();
|
||||
}
|
||||
setKey(tabKey);
|
||||
navigate(updatePathWithQueryParams(tabKey));
|
||||
};
|
||||
|
||||
const tabTitle = (
|
||||
@@ -81,12 +89,11 @@ const Logistration = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseComponent>
|
||||
<BaseContainer>
|
||||
<div>
|
||||
{disablePublicAccountCreation
|
||||
? (
|
||||
<>
|
||||
<Redirect to={updatePathWithQueryParams(LOGIN_PAGE)} />
|
||||
{institutionLogin && (
|
||||
<Tabs defaultActiveKey="" id="controlled-tab" onSelect={handleInstitutionLogin}>
|
||||
<Tab title={tabTitle} eventKey={LOGIN_PAGE} />
|
||||
@@ -114,9 +121,6 @@ const Logistration = (props) => {
|
||||
<Tab title={formatMessage(messages['logistration.sign.in'])} eventKey={LOGIN_PAGE} />
|
||||
</Tabs>
|
||||
))}
|
||||
{ key && (
|
||||
<Redirect to={updatePathWithQueryParams(key)} />
|
||||
)}
|
||||
<div id="main-content" className="main-content">
|
||||
{selectedPage === LOGIN_PAGE
|
||||
? <LoginPage institutionLogin={institutionLogin} handleInstitutionLogin={handleInstitutionLogin} />
|
||||
@@ -130,7 +134,7 @@ const Logistration = (props) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseComponent>
|
||||
</BaseContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ import configureStore from 'redux-mock-store';
|
||||
import Logistration from './Logistration';
|
||||
import { clearThirdPartyAuthContextErrorMessage } from '../common-components/data/actions';
|
||||
import { RenderInstitutionButton } from '../common-components/InstitutionLogistration';
|
||||
import { COMPLETE_STATE, LOGIN_PAGE } from '../data/constants';
|
||||
import {
|
||||
COMPLETE_STATE, LOGIN_PAGE,
|
||||
} from '../data/constants';
|
||||
import { backupRegistrationForm } from '../register/data/actions';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||
|
||||
@@ -21,97 +21,135 @@ import {
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { saveUserProfile } from './data/actions';
|
||||
import { welcomePageSelector } from './data/selectors';
|
||||
import { welcomePageContextSelector } from './data/selectors';
|
||||
import messages from './messages';
|
||||
import ProgressiveProfilingPageModal from './ProgressiveProfilingPageModal';
|
||||
import { BaseComponent } from '../base-component';
|
||||
import BaseContainer from '../base-container';
|
||||
import { RedirectLogistration } from '../common-components';
|
||||
import { getThirdPartyAuthContext } from '../common-components/data/actions';
|
||||
import {
|
||||
DEFAULT_REDIRECT_URL, DEFAULT_STATE, FAILURE_STATE,
|
||||
COMPLETE_STATE,
|
||||
DEFAULT_REDIRECT_URL,
|
||||
DEFAULT_STATE,
|
||||
FAILURE_STATE,
|
||||
PENDING_STATE,
|
||||
} from '../data/constants';
|
||||
import { getAllPossibleQueryParams } from '../data/utils';
|
||||
import { getAllPossibleQueryParams, isHostAvailableInQueryParams } from '../data/utils';
|
||||
import { FormFieldRenderer } from '../field-renderer';
|
||||
import {
|
||||
activateRecommendationsExperiment, RECOMMENDATIONS_EXP_VARIATION, trackRecommendationViewedOptimizely,
|
||||
activateRecommendationsExperiment, RECOMMENDATIONS_EXP_VARIATION,
|
||||
} from '../recommendations/optimizelyExperiment';
|
||||
import { trackRecommendationsGroup, trackRecommendationsViewed } from '../recommendations/track';
|
||||
|
||||
const ProgressiveProfiling = (props) => {
|
||||
const {
|
||||
formRenderState, submitState, showError, location,
|
||||
} = props;
|
||||
const enablePersonalizedRecommendations = getConfig().ENABLE_PERSONALIZED_RECOMMENDATIONS;
|
||||
const registrationResponse = location.state?.registrationResult;
|
||||
|
||||
const { formatMessage } = useIntl();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [registrationResult, setRegistrationResult] = useState({ redirectUrl: '' });
|
||||
const [values, setValues] = useState({});
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [showRecommendationsPage, setShowRecommendationsPage] = useState(false);
|
||||
const {
|
||||
getFieldDataFromBackend,
|
||||
submitState,
|
||||
showError,
|
||||
welcomePageContext,
|
||||
welcomePageContextApiStatus,
|
||||
} = props;
|
||||
const location = useLocation();
|
||||
const registrationEmbedded = isHostAvailableInQueryParams();
|
||||
|
||||
const queryParams = getAllPossibleQueryParams();
|
||||
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(() => {
|
||||
configureAuth(AxiosJwtAuthService, { loggingService: getLoggingService(), config: getConfig() });
|
||||
ensureAuthenticatedUser(DASHBOARD_URL)
|
||||
.then(() => {
|
||||
hydrateAuthenticatedUser().then(() => {
|
||||
setReady(true);
|
||||
setCanViewWelcomePage(true);
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
if (registrationResponse) {
|
||||
setRegistrationResult(registrationResponse);
|
||||
}
|
||||
}, [DASHBOARD_URL, registrationResponse]);
|
||||
}, [DASHBOARD_URL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && authenticatedUser?.userId) {
|
||||
const registrationResponse = location.state?.registrationResult;
|
||||
if (registrationResponse) {
|
||||
setRegistrationResult(registrationResponse);
|
||||
setFormFieldData({
|
||||
fields: location.state?.optionalFields.fields,
|
||||
extendedProfile: location.state?.optionalFields.extended_profile,
|
||||
});
|
||||
}
|
||||
}, [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({
|
||||
fields: welcomePageContext.fields,
|
||||
extendedProfile: welcomePageContext.extended_profile,
|
||||
});
|
||||
const nextUrl = welcomePageContext.nextUrl ? welcomePageContext.nextUrl : getConfig().SEARCH_CATALOG_URL;
|
||||
setRegistrationResult({ redirectUrl: nextUrl });
|
||||
}
|
||||
}, [registrationEmbedded, welcomePageContext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canViewWelcomePage && authenticatedUser?.userId) {
|
||||
identifyAuthenticatedUser(authenticatedUser.userId);
|
||||
sendPageEvent('login_and_registration', 'welcome');
|
||||
}
|
||||
}, [authenticatedUser, ready]);
|
||||
}, [authenticatedUser, canViewWelcomePage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registrationResponse && authenticatedUser?.userId) {
|
||||
const queryParams = getAllPossibleQueryParams(registrationResponse.redirectUrl);
|
||||
if (enablePersonalizedRecommendations && !('enrollment_action' in queryParams)) {
|
||||
if (registrationResult.redirectUrl && authenticatedUser?.userId) {
|
||||
const redirectQueryParams = getAllPossibleQueryParams(registrationResult.redirectUrl);
|
||||
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);
|
||||
trackRecommendationViewedOptimizely(userIdStr);
|
||||
setShowRecommendationsPage(showRecommendations);
|
||||
if (!showRecommendations) {
|
||||
trackRecommendationsViewed([], true, authenticatedUser.userId);
|
||||
trackRecommendationsViewed([], '', true, authenticatedUser.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [authenticatedUser, enablePersonalizedRecommendations, registrationResponse]);
|
||||
}, [authenticatedUser, enablePopularAndTrendingRecommendations, registrationResult.redirectUrl, queryParams?.next]);
|
||||
|
||||
if (!location.state || !location.state.registrationResult || formRenderState === FAILURE_STATE) {
|
||||
if (
|
||||
!(location.state?.registrationResult || registrationEmbedded)
|
||||
|| welcomePageContextApiStatus === FAILURE_STATE
|
||||
|| (welcomePageContextApiStatus === COMPLETE_STATE && !Object.keys(welcomePageContext).includes('fields'))
|
||||
) {
|
||||
global.location.assign(DASHBOARD_URL);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
if (!canViewWelcomePage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const optionalFields = location.state.optionalFields.fields;
|
||||
const extendedProfile = location.state.optionalFields.extended_profile;
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
window.history.replaceState(location.state, null, '');
|
||||
const payload = { ...values, extendedProfile: [] };
|
||||
if (Object.keys(extendedProfile).length > 0) {
|
||||
extendedProfile.forEach(fieldName => {
|
||||
if (Object.keys(formFieldData.extendedProfile).length > 0) {
|
||||
formFieldData.extendedProfile.forEach(fieldName => {
|
||||
if (values[fieldName]) {
|
||||
payload.extendedProfile.push({ fieldName, fieldValue: values[fieldName] });
|
||||
}
|
||||
@@ -126,15 +164,21 @@ const ProgressiveProfiling = (props) => {
|
||||
isGenderSelected: !!values.gender,
|
||||
isYearOfBirthSelected: !!values.year_of_birth,
|
||||
isLevelOfEducationSelected: !!values.level_of_education,
|
||||
host: queryParams?.host || '',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleSkip = (e) => {
|
||||
e.preventDefault();
|
||||
window.history.replaceState(props.location.state, null, '');
|
||||
setOpenDialog(true);
|
||||
sendTrackEvent('edx.bi.welcome.page.skip.link.clicked');
|
||||
window.history.replaceState(location.state, null, '');
|
||||
setShowModal(true);
|
||||
sendTrackEvent(
|
||||
'edx.bi.welcome.page.skip.link.clicked',
|
||||
{
|
||||
host: queryParams?.host || '',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onChangeHandler = (e) => {
|
||||
@@ -145,8 +189,8 @@ const ProgressiveProfiling = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const formFields = Object.keys(optionalFields).map((fieldName) => {
|
||||
const fieldData = optionalFields[fieldName];
|
||||
const formFields = Object.keys(formFieldData.fields).map((fieldName) => {
|
||||
const fieldData = formFieldData.fields[fieldName];
|
||||
return (
|
||||
<span key={fieldData.name}>
|
||||
<FormFieldRenderer
|
||||
@@ -159,14 +203,20 @@ const ProgressiveProfiling = (props) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseComponent showWelcomeBanner>
|
||||
<BaseContainer showWelcomeBanner>
|
||||
<Helmet>
|
||||
<title>{formatMessage(messages['progressive.profiling.page.title'],
|
||||
{ siteName: getConfig().SITE_NAME })}
|
||||
</title>
|
||||
</Helmet>
|
||||
<ProgressiveProfilingPageModal isOpen={openDialog} redirectUrl={registrationResult.redirectUrl} />
|
||||
{props.shouldRedirect ? (
|
||||
<ProgressiveProfilingPageModal isOpen={showModal} redirectUrl={registrationResult.redirectUrl} />
|
||||
{(props.shouldRedirect && welcomePageContext.nextUrl) && (
|
||||
<RedirectLogistration
|
||||
success
|
||||
redirectUrl={registrationResult.redirectUrl}
|
||||
/>
|
||||
)}
|
||||
{props.shouldRedirect && (
|
||||
<RedirectLogistration
|
||||
success
|
||||
redirectUrl={registrationResult.redirectUrl}
|
||||
@@ -174,7 +224,7 @@ const ProgressiveProfiling = (props) => {
|
||||
educationLevel={values?.level_of_education}
|
||||
userId={authenticatedUser?.userId}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
<div className="mw-xs m-4 pp-page-content">
|
||||
<div>
|
||||
<h2 className="pp-page__heading text-primary">{formatMessage(messages['progressive.profiling.page.heading'])}</h2>
|
||||
@@ -228,46 +278,49 @@ const ProgressiveProfiling = (props) => {
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</BaseComponent>
|
||||
</BaseContainer>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressiveProfiling.propTypes = {
|
||||
formRenderState: PropTypes.string.isRequired,
|
||||
location: PropTypes.shape({
|
||||
state: PropTypes.shape({
|
||||
registrationResult: PropTypes.shape({
|
||||
redirectUrl: PropTypes.string,
|
||||
}),
|
||||
optionalFields: PropTypes.shape({
|
||||
extended_profile: PropTypes.arrayOf(PropTypes.string),
|
||||
fields: PropTypes.shape({}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
saveUserProfile: PropTypes.func.isRequired,
|
||||
showError: PropTypes.bool,
|
||||
shouldRedirect: PropTypes.bool,
|
||||
submitState: PropTypes.string,
|
||||
welcomePageContext: PropTypes.shape({
|
||||
extended_profile: PropTypes.arrayOf(PropTypes.string),
|
||||
fields: PropTypes.shape({}),
|
||||
nextUrl: PropTypes.string,
|
||||
}),
|
||||
welcomePageContextApiStatus: PropTypes.string,
|
||||
// Actions
|
||||
getFieldDataFromBackend: PropTypes.func.isRequired,
|
||||
saveUserProfile: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
ProgressiveProfiling.defaultProps = {
|
||||
location: { state: {} },
|
||||
shouldRedirect: false,
|
||||
showError: false,
|
||||
submitState: DEFAULT_STATE,
|
||||
welcomePageContext: {},
|
||||
welcomePageContextApiStatus: PENDING_STATE,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
formRenderState: welcomePageSelector(state).formRenderState,
|
||||
shouldRedirect: welcomePageSelector(state).success,
|
||||
submitState: welcomePageSelector(state).submitState,
|
||||
showError: welcomePageSelector(state).showError,
|
||||
});
|
||||
const mapStateToProps = state => {
|
||||
const welcomePageStore = state.welcomePage;
|
||||
|
||||
return {
|
||||
shouldRedirect: welcomePageStore.success,
|
||||
showError: welcomePageStore.showError,
|
||||
submitState: welcomePageStore.submitState,
|
||||
welcomePageContext: welcomePageContextSelector(state),
|
||||
welcomePageContextApiStatus: state.commonComponents.thirdPartyAuthApiStatus,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{
|
||||
saveUserProfile,
|
||||
getFieldDataFromBackend: getThirdPartyAuthContext,
|
||||
},
|
||||
)(ProgressiveProfiling);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
export const defaultState = {
|
||||
extendedProfile: [],
|
||||
fieldDescriptions: {},
|
||||
formRenderState: DEFAULT_STATE,
|
||||
success: false,
|
||||
submitState: DEFAULT_STATE,
|
||||
showError: false,
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
export const storeName = 'welcomePage';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
export const welcomePageSelector = state => ({ ...state[storeName] });
|
||||
export const storeName = 'commonComponents';
|
||||
|
||||
export const commonComponentsSelector = state => ({ ...state[storeName] });
|
||||
|
||||
export const welcomePageContextSelector = createSelector(
|
||||
commonComponentsSelector,
|
||||
commonComponents => ({
|
||||
fields: commonComponents.optionalFields.fields,
|
||||
extended_profile: commonComponents.optionalFields.extended_profile,
|
||||
nextUrl: commonComponents.thirdPartyAuthContext.welcomePageRedirectUrl,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const storeName = 'welcomePage';
|
||||
|
||||
export { default as ProgressiveProfiling } from './ProgressiveProfiling';
|
||||
export { default as reducer } from './data/reducers';
|
||||
export { default as saga } from './data/sagas';
|
||||
export { storeName } from './data/selectors';
|
||||
|
||||
@@ -6,14 +6,18 @@ 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 { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter, Router } from 'react-router-dom';
|
||||
import { MemoryRouter, mockNavigate, useLocation } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import {
|
||||
COMPLETE_STATE, DEFAULT_REDIRECT_URL, FAILURE_STATE, RECOMMENDATIONS,
|
||||
AUTHN_PROGRESSIVE_PROFILING,
|
||||
COMPLETE_STATE, DEFAULT_REDIRECT_URL,
|
||||
EMBEDDED,
|
||||
FAILURE_STATE,
|
||||
RECOMMENDATIONS,
|
||||
} from '../../data/constants';
|
||||
import { activateRecommendationsExperiment } from '../../recommendations/optimizelyExperiment';
|
||||
import { saveUserProfile } from '../data/actions';
|
||||
import ProgressiveProfiling from '../ProgressiveProfiling';
|
||||
|
||||
@@ -34,8 +38,27 @@ jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
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();
|
||||
|
||||
const history = createMemoryHistory();
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const Navigate = ({ to }) => {
|
||||
mockNavigation(to);
|
||||
return <div />;
|
||||
};
|
||||
|
||||
return {
|
||||
...jest.requireActual('react-router-dom'),
|
||||
Navigate,
|
||||
mockNavigate: mockNavigation,
|
||||
useLocation: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('ProgressiveProfilingTests', () => {
|
||||
mergeConfig({
|
||||
@@ -53,12 +76,16 @@ describe('ProgressiveProfilingTests', () => {
|
||||
};
|
||||
const extendedProfile = ['company'];
|
||||
const optionalFields = { fields, extended_profile: extendedProfile };
|
||||
let props = {};
|
||||
let store = {};
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
const initialState = {
|
||||
welcomePage: {
|
||||
formRenderState: COMPLETE_STATE,
|
||||
welcomePage: {},
|
||||
commonComponents: {
|
||||
thirdPartyAuthApiStatus: null,
|
||||
optionalFields: {},
|
||||
thirdPartyAuthContext: {
|
||||
welcomePageRedirectUrl: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,11 +98,7 @@ describe('ProgressiveProfilingTests', () => {
|
||||
);
|
||||
|
||||
const getProgressiveProfilingPage = async () => {
|
||||
const progressiveProfilingPage = mount(reduxWrapper(
|
||||
<Router history={history}>
|
||||
<IntlProgressiveProfilingPage {...props} />
|
||||
</Router>,
|
||||
));
|
||||
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
|
||||
await act(async () => {
|
||||
await Promise.resolve(progressiveProfilingPage);
|
||||
await new Promise(resolve => { setImmediate(resolve); });
|
||||
@@ -95,18 +118,15 @@ describe('ProgressiveProfilingTests', () => {
|
||||
},
|
||||
messages: { 'es-419': {}, de: {}, 'en-us': {} },
|
||||
});
|
||||
props = {
|
||||
getFieldData: jest.fn(),
|
||||
location: {
|
||||
state: {
|
||||
registrationResult,
|
||||
optionalFields,
|
||||
},
|
||||
useLocation.mockReturnValue({
|
||||
state: {
|
||||
registrationResult,
|
||||
optionalFields,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('not should display button "Learn more about how we use this information."', async () => {
|
||||
it('should not display button "Learn more about how we use this information."', async () => {
|
||||
mergeConfig({
|
||||
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: '',
|
||||
});
|
||||
@@ -124,11 +144,6 @@ describe('ProgressiveProfilingTests', () => {
|
||||
expect(progressiveProfilingPage.find('a.pgn__hyperlink').text()).toEqual('Learn more about how we use this information.');
|
||||
});
|
||||
|
||||
it('should render fields returned by backend api', async () => {
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
expect(progressiveProfilingPage.find('#gender').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should make identify call to segment on progressive profiling page', async () => {
|
||||
getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123' });
|
||||
await getProgressiveProfilingPage();
|
||||
@@ -151,12 +166,30 @@ describe('ProgressiveProfilingTests', () => {
|
||||
expect(store.dispatch).toHaveBeenCalledWith(saveUserProfile('abc123', formPayload));
|
||||
});
|
||||
|
||||
it('should set host property value empty for non-embedded experience', async () => {
|
||||
getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123' });
|
||||
const expectedEventProperties = {
|
||||
isGenderSelected: false,
|
||||
isYearOfBirthSelected: false,
|
||||
isLevelOfEducationSelected: false,
|
||||
host: '',
|
||||
};
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING) };
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
progressiveProfilingPage.find('button.btn-brand').simulate('click');
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.submit.clicked', expectedEventProperties);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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');
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host: '' });
|
||||
});
|
||||
|
||||
it('should send analytic event for support link click', async () => {
|
||||
@@ -168,6 +201,7 @@ describe('ProgressiveProfilingTests', () => {
|
||||
|
||||
it('should show error message when patch request fails', async () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
welcomePage: {
|
||||
...initialState.welcomePage,
|
||||
showError: true,
|
||||
@@ -178,29 +212,44 @@ describe('ProgressiveProfilingTests', () => {
|
||||
expect(progressiveProfilingPage.find('#pp-page-errors').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should redirect to dashboard if no form fields are configured', async () => {
|
||||
store = mockStore({
|
||||
welcomePage: {
|
||||
formRenderState: FAILURE_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
delete window.location;
|
||||
window.location = {
|
||||
href: getConfig().BASE_URL,
|
||||
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
|
||||
};
|
||||
await getProgressiveProfilingPage();
|
||||
expect(window.location.href).toBe(DASHBOARD_URL);
|
||||
});
|
||||
|
||||
describe('Recommendations test', () => {
|
||||
mergeConfig({
|
||||
ENABLE_PERSONALIZED_RECOMMENDATIONS: true,
|
||||
ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS: true,
|
||||
});
|
||||
|
||||
it.skip('should redirect to recommendations page if recommendations are enabled', async () => {
|
||||
it('should redirect to recommendations page if recommendations are enabled', async () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
welcomePage: {
|
||||
...initialState.welcomePage,
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
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,
|
||||
@@ -208,34 +257,25 @@ describe('ProgressiveProfilingTests', () => {
|
||||
});
|
||||
|
||||
getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123' });
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
|
||||
expect(progressiveProfilingPage.find('button.btn-brand').text()).toEqual('Next');
|
||||
expect(history.location.pathname).toEqual(RECOMMENDATIONS);
|
||||
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 () => {
|
||||
delete window.location;
|
||||
window.location = {
|
||||
href: getConfig().BASE_URL,
|
||||
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
|
||||
};
|
||||
|
||||
const redirectUrl = `${getConfig().LMS_BASE_URL}${DEFAULT_REDIRECT_URL}?enrollment_action=1`;
|
||||
props = {
|
||||
getFieldData: jest.fn(),
|
||||
location: {
|
||||
state: {
|
||||
registrationResult: {
|
||||
redirectUrl,
|
||||
success: true,
|
||||
},
|
||||
optionalFields,
|
||||
useLocation.mockReturnValue({
|
||||
state: {
|
||||
registrationResult: {
|
||||
redirectUrl,
|
||||
success: true,
|
||||
},
|
||||
optionalFields,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
welcomePage: {
|
||||
...initialState.welcomePage,
|
||||
success: true,
|
||||
@@ -249,4 +289,114 @@ describe('ProgressiveProfilingTests', () => {
|
||||
expect(window.location.href).toEqual(redirectUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Embedded Form Workflow Test', () => {
|
||||
mergeConfig({
|
||||
SEARCH_CATALOG_URL: 'http://localhost/search',
|
||||
});
|
||||
const host = 'http://example.com';
|
||||
|
||||
beforeEach(() => {
|
||||
useLocation.mockReturnValue({
|
||||
state: {},
|
||||
});
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
commonComponents: {
|
||||
...initialState.commonComponents,
|
||||
thirdPartyAuthApiStatus: COMPLETE_STATE,
|
||||
optionalFields,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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 = 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', async () => {
|
||||
const expectedEventProperties = {
|
||||
isGenderSelected: false,
|
||||
isYearOfBirthSelected: false,
|
||||
isLevelOfEducationSelected: false,
|
||||
host: 'http://example.com',
|
||||
};
|
||||
delete window.location;
|
||||
window.location = {
|
||||
href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING),
|
||||
search: `?host=${host}`,
|
||||
};
|
||||
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', async () => {
|
||||
delete window.location;
|
||||
window.location = {
|
||||
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
|
||||
href: getConfig().BASE_URL,
|
||||
search: `?variant=${EMBEDDED}&host=${host}`,
|
||||
};
|
||||
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
expect(progressiveProfilingPage.find('#gender').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
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; }),
|
||||
href: getConfig().BASE_URL,
|
||||
search: `?variant=${EMBEDDED}`,
|
||||
};
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
commonComponents: {
|
||||
...initialState.commonComponents,
|
||||
thirdPartyAuthApiStatus: FAILURE_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
await getProgressiveProfilingPage();
|
||||
expect(window.location.href).toBe(DASHBOARD_URL);
|
||||
});
|
||||
|
||||
it('should redirect to provided redirect url', async () => {
|
||||
const redirectUrl = 'https://redirect-test.com';
|
||||
delete window.location;
|
||||
window.location = {
|
||||
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
|
||||
href: getConfig().BASE_URL,
|
||||
search: `?variant=${EMBEDDED}&host=${host}&next=${redirectUrl}`,
|
||||
};
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
commonComponents: {
|
||||
...initialState.commonComponents,
|
||||
thirdPartyAuthApiStatus: COMPLETE_STATE,
|
||||
optionalFields,
|
||||
thirdPartyAuthContext: {
|
||||
welcomePageRedirectUrl: redirectUrl,
|
||||
},
|
||||
},
|
||||
welcomePage: {
|
||||
...initialState.welcomePage,
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
progressiveProfilingPage.find('button.btn-brand').simulate('click');
|
||||
expect(window.location.href).toBe(redirectUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
76
src/recommendations/ProductCard/BaseCard/index.jsx
Normal file
76
src/recommendations/ProductCard/BaseCard/index.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Badge, Card, Hyperlink } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { truncateText } from '../../data/utils';
|
||||
|
||||
const BaseCard = ({
|
||||
customHeaderImage,
|
||||
schoolLogo,
|
||||
title,
|
||||
uuid,
|
||||
subtitle,
|
||||
variant,
|
||||
productTypeCopy,
|
||||
footer,
|
||||
handleOnClick,
|
||||
isLoading = false,
|
||||
}) => (
|
||||
<div className="mr-4 recommendation-card" key={`container-${uuid}`}>
|
||||
<Hyperlink
|
||||
target="_blank"
|
||||
className="card-box"
|
||||
showLaunchIcon={false}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
<Card
|
||||
className={`base-card ${variant}`}
|
||||
variant={variant}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<Card.ImageCap
|
||||
className="base-card-image-show optanon-category-C0001"
|
||||
src={customHeaderImage}
|
||||
srcAlt={`header image for ${subtitle}`}
|
||||
logoSrc={schoolLogo}
|
||||
logoAlt={`logo for ${subtitle}`}
|
||||
imageLoadingType="lazy"
|
||||
/>
|
||||
<Card.Header
|
||||
className="mt-2"
|
||||
title={truncateText(title)}
|
||||
subtitle={truncateText(subtitle)}
|
||||
/>
|
||||
<Card.Section className="d-flex">
|
||||
<div className="product-badge">
|
||||
<Badge>
|
||||
{productTypeCopy}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="footer-content mt-2">
|
||||
{footer}
|
||||
</div>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Hyperlink>
|
||||
</div>
|
||||
);
|
||||
|
||||
BaseCard.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
uuid: PropTypes.string.isRequired,
|
||||
footer: PropTypes.element.isRequired,
|
||||
productTypeCopy: PropTypes.string.isRequired,
|
||||
subtitle: PropTypes.string.isRequired,
|
||||
variant: PropTypes.string.isRequired,
|
||||
customHeaderImage: PropTypes.string.isRequired,
|
||||
schoolLogo: PropTypes.string.isRequired,
|
||||
isLoading: PropTypes.bool,
|
||||
handleOnClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
BaseCard.defaultProps = {
|
||||
isLoading: false,
|
||||
};
|
||||
export default BaseCard;
|
||||
75
src/recommendations/ProductCard/Footer/index.jsx
Normal file
75
src/recommendations/ProductCard/Footer/index.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
cardFooterMessages,
|
||||
} from '../../messages';
|
||||
|
||||
const ProductCardFooter = ({
|
||||
factoid,
|
||||
quickFacts,
|
||||
courseLength,
|
||||
cardType,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const courseLengthLabel = courseLength > 1 ? 'Courses' : 'Course';
|
||||
|
||||
if (courseLength) {
|
||||
return (
|
||||
<p className="x-small">
|
||||
{intl.formatMessage(
|
||||
cardFooterMessages[
|
||||
'recommendation.product-card.footer-text.number-of-courses'
|
||||
],
|
||||
{ length: courseLength, label: courseLengthLabel },
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (cardType === 'program') {
|
||||
if (quickFacts && quickFacts.length > 0) {
|
||||
const quickFactsCount = quickFacts.length;
|
||||
|
||||
const threeFactsArrangement = [1, 3, 0];
|
||||
const twoFactsArrangement = [0, 2];
|
||||
return (
|
||||
<>
|
||||
{(quickFactsCount > 3 ? threeFactsArrangement : twoFactsArrangement)
|
||||
.map((index) => quickFacts[index])
|
||||
.filter(Boolean)
|
||||
.map((fact, idx) => (
|
||||
<p key={fact.text} className="d-inline-block x-small">
|
||||
{idx > 0 && <span className="p-2">•</span>}
|
||||
{fact && fact.text}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (factoid) {
|
||||
return <p className="x-small">{factoid}</p>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
ProductCardFooter.propTypes = {
|
||||
cardType: PropTypes.string,
|
||||
factoid: PropTypes.string,
|
||||
quickFacts: PropTypes.arrayOf(PropTypes.shape({})),
|
||||
courseLength: PropTypes.number,
|
||||
};
|
||||
|
||||
ProductCardFooter.defaultProps = {
|
||||
cardType: '',
|
||||
factoid: '',
|
||||
quickFacts: [],
|
||||
courseLength: undefined,
|
||||
};
|
||||
|
||||
export default ProductCardFooter;
|
||||
112
src/recommendations/ProductCard/index.jsx
Normal file
112
src/recommendations/ProductCard/index.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import BaseCard from './BaseCard';
|
||||
import Footer from './Footer';
|
||||
import { createCodeFriendlyProduct, getVariant, useProductType } from '../data/utils';
|
||||
import {
|
||||
cardBadgesMessages,
|
||||
} from '../messages';
|
||||
import { trackRecommendationClick } from '../track';
|
||||
|
||||
const ProductCard = ({
|
||||
product,
|
||||
userId,
|
||||
position,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const productType = useProductType(product?.courseType, product?.type);
|
||||
|
||||
const variant = getVariant(productType);
|
||||
|
||||
const headerImage = product?.cardImageUrl || product?.image?.src;
|
||||
|
||||
const schoolName = product?.organizationShortCodeOverride
|
||||
|| product?.owners?.[0]?.name
|
||||
|| product?.authoringOrganizations?.[0]?.name
|
||||
|| product?.partner;
|
||||
const schoolLogo = product?.organizationLogoOverrideUrl
|
||||
|| product?.logoFilename
|
||||
|| product?.authoringOrganizations?.[0]?.logoImageUrl
|
||||
|| product?.owners?.[0]?.logoImageUrl;
|
||||
|
||||
const { owners } = product;
|
||||
const multipleSchoolNames = [];
|
||||
const isMultipleOwner = owners?.length > 1;
|
||||
|
||||
if ((owners?.length > 1)) {
|
||||
owners.forEach((owner, index, arr) => {
|
||||
let school;
|
||||
if (index === arr.length - 1) {
|
||||
school = (
|
||||
<span key={owner.name}>{owner.name}</span>
|
||||
);
|
||||
} else {
|
||||
school = (
|
||||
<>
|
||||
<span key={owner.name}>{owner.name}</span>
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
multipleSchoolNames.push(school);
|
||||
});
|
||||
}
|
||||
|
||||
const productTypeCopy = formatMessage(
|
||||
cardBadgesMessages[
|
||||
`recommendation.product-card.pill-text.${createCodeFriendlyProduct(productType)}`
|
||||
],
|
||||
);
|
||||
const handleCardClick = () => {
|
||||
trackRecommendationClick(
|
||||
product,
|
||||
position + 1,
|
||||
false,
|
||||
userId,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
customHeaderImage={headerImage}
|
||||
schoolLogo={isMultipleOwner ? '' : schoolLogo}
|
||||
title={product.title}
|
||||
uuid={product.uuid}
|
||||
key={product.uuid}
|
||||
subtitle={isMultipleOwner ? multipleSchoolNames : schoolName}
|
||||
productTypeCopy={productTypeCopy}
|
||||
productType={productType}
|
||||
variant={variant}
|
||||
footer={(
|
||||
<Footer
|
||||
quickFacts={product.degree?.quickFacts}
|
||||
externalUrl={product.additionalMetadata?.externalUrl
|
||||
|| product.degree?.additionalMetadata?.externalUrl}
|
||||
courseLength={product.courses?.length}
|
||||
isSubscriptionView={!!product.subscriptionEligible}
|
||||
is2UDegreeProgram={product.is2UDegreeProgram}
|
||||
cardType={product.cardType}
|
||||
/>
|
||||
)}
|
||||
handleOnClick={handleCardClick}
|
||||
isSubscriptionView={!!product.subscriptionEligible}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCard.propTypes = {
|
||||
product: PropTypes.shape([
|
||||
PropTypes.shape({}),
|
||||
]).isRequired,
|
||||
userId: PropTypes.number.isRequired,
|
||||
position: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
ProductCard.defaultProps = {
|
||||
};
|
||||
export default ProductCard;
|
||||
@@ -1,87 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Card, Hyperlink } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { trackRecommendationCardClickOptimizely } from './optimizelyExperiment';
|
||||
import { trackRecommendationsClicked } from './track';
|
||||
|
||||
const RecommendationCard = (props) => {
|
||||
const { recommendation, position, userId } = props;
|
||||
const showPartnerLogo = recommendation.owners.length === 1;
|
||||
|
||||
const getOwners = () => {
|
||||
if (recommendation.owners.length === 1) {
|
||||
return recommendation.owners[0].key;
|
||||
}
|
||||
|
||||
let keys = '';
|
||||
recommendation.owners.forEach((owner) => {
|
||||
keys += `${owner.key }, `;
|
||||
});
|
||||
return keys.slice(0, -2);
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
trackRecommendationCardClickOptimizely(userId?.toString());
|
||||
trackRecommendationsClicked(
|
||||
recommendation.courseKey,
|
||||
false,
|
||||
position + 1,
|
||||
userId,
|
||||
recommendation.marketingUrl,
|
||||
recommendation.recommendationType || 'algolia',
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mr-4 recommendation-card">
|
||||
<Hyperlink
|
||||
destination={recommendation.marketingUrl}
|
||||
target="_blank"
|
||||
className="card-box"
|
||||
showLaunchIcon={false}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<Card isClickable>
|
||||
<Card.ImageCap
|
||||
src={recommendation.cardImageUrl}
|
||||
srcAlt="Card image"
|
||||
logoSrc={showPartnerLogo ? recommendation.owners[0].logoImageUrl : ''}
|
||||
logoAlt="Card logo"
|
||||
/>
|
||||
<Card.Header
|
||||
title={recommendation.title}
|
||||
subtitle={getOwners()}
|
||||
/>
|
||||
<Card.Section />
|
||||
<Card.Footer textElement={<small className="pgn__card__footer-text">Course</small>} />
|
||||
</Card>
|
||||
</Hyperlink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RecommendationCard.propTypes = {
|
||||
recommendation: PropTypes.shape({
|
||||
courseKey: PropTypes.string.isRequired,
|
||||
activeRunKey: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
cardImageUrl: PropTypes.string.isRequired,
|
||||
owners: PropTypes.arrayOf(PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
logoImageUrl: PropTypes.string.isRequired,
|
||||
})),
|
||||
marketingUrl: PropTypes.string.isRequired,
|
||||
recommendationType: PropTypes.string,
|
||||
}).isRequired,
|
||||
position: PropTypes.number.isRequired,
|
||||
userId: PropTypes.number,
|
||||
};
|
||||
|
||||
RecommendationCard.defaultProps = {
|
||||
userId: null,
|
||||
};
|
||||
|
||||
export default RecommendationCard;
|
||||
@@ -1,48 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Container } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import RecommendationCard from './RecommendationCard';
|
||||
import ProductCard from './ProductCard';
|
||||
|
||||
const RecommendationsList = (props) => {
|
||||
const { title, recommendations, userId } = props;
|
||||
const { recommendations, userId } = props;
|
||||
|
||||
return (
|
||||
<Container id="course-recommendations" size="lg" className="recommendations-container">
|
||||
<h2 className="text-sm-center mb-4 text-left recommendations-container__heading">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="d-flex recommendations-container__card-list">
|
||||
{
|
||||
recommendations.map((recommendation, idx) => (
|
||||
<RecommendationCard
|
||||
key={recommendation.activeRunKey}
|
||||
recommendation={recommendation}
|
||||
<div className="d-flex recommendations-container__card-list">
|
||||
{
|
||||
recommendations.map((recommendation, idx) => (
|
||||
<span key={recommendation.uuid}>
|
||||
<ProductCard
|
||||
product={recommendation}
|
||||
position={idx}
|
||||
userId={userId}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</Container>
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RecommendationsList.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
recommendations: PropTypes.arrayOf(PropTypes.shape({
|
||||
courseKey: PropTypes.string.isRequired,
|
||||
activeRunKey: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
cardImageUrl: PropTypes.string.isRequired,
|
||||
owners: PropTypes.arrayOf(PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
logoImageUrl: PropTypes.string.isRequired,
|
||||
})),
|
||||
marketingUrl: PropTypes.string.isRequired,
|
||||
recommendationType: PropTypes.string,
|
||||
uuid: PropTypes.string,
|
||||
})),
|
||||
userId: PropTypes.number,
|
||||
};
|
||||
|
||||
@@ -1,78 +1,34 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Hyperlink, Image, Spinner, StatefulButton,
|
||||
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, RECOMMENDATIONS_COUNT } from './data/constants';
|
||||
import getPersonalizedRecommendations from './data/service';
|
||||
import { convertCourseRunKeytoCourseKey } from './data/utils';
|
||||
import { POPULAR, TRENDING } from './data/constants';
|
||||
import useProducts from './data/hooks/useProducts';
|
||||
import messages from './messages';
|
||||
import RecommendationsList from './RecommendationsList';
|
||||
import { trackRecommendationsViewed } from './track';
|
||||
import { DEFAULT_REDIRECT_URL } from '../data/constants';
|
||||
|
||||
const RecommendationsPage = (props) => {
|
||||
const { location } = props;
|
||||
const RecommendationsPage = ({ countryCode }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const location = useLocation();
|
||||
const registrationResponse = location.state?.registrationResult;
|
||||
const userId = location.state?.userId;
|
||||
|
||||
const { popularProducts, trendingProducts, isLoading } = useProducts(countryCode);
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
|
||||
const { formatMessage } = useIntl();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [recommendations, setRecommendations] = useState([]);
|
||||
const [algoliaRecommendations, setAlgoliaRecommendations] = useState([]);
|
||||
const educationLevel = EDUCATION_LEVEL_MAPPING[location.state?.educationLevel];
|
||||
|
||||
useEffect(() => {
|
||||
if (registrationResponse) {
|
||||
const generalRecommendations = JSON.parse(getConfig().GENERAL_RECOMMENDATIONS);
|
||||
let coursesWithKeys = [];
|
||||
getPersonalizedRecommendations(educationLevel).then((response) => {
|
||||
coursesWithKeys = response.map(course => ({
|
||||
...course,
|
||||
courseKey: convertCourseRunKeytoCourseKey(course.activeRunKey),
|
||||
}));
|
||||
setAlgoliaRecommendations(coursesWithKeys.slice(0, RECOMMENDATIONS_COUNT));
|
||||
|
||||
if (coursesWithKeys.length >= RECOMMENDATIONS_COUNT) {
|
||||
setRecommendations(coursesWithKeys.slice(0, RECOMMENDATIONS_COUNT));
|
||||
} else {
|
||||
const courseRecommendations = coursesWithKeys.concat(generalRecommendations);
|
||||
// Remove duplicate recommendations
|
||||
const uniqueRecommendations = courseRecommendations.filter(
|
||||
(recommendation, index, self) => index === self.findIndex((existingRecommendation) => (
|
||||
existingRecommendation.courseKey === recommendation.courseKey
|
||||
)),
|
||||
);
|
||||
setRecommendations(uniqueRecommendations.slice(0, RECOMMENDATIONS_COUNT));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setRecommendations(generalRecommendations.slice(0, RECOMMENDATIONS_COUNT));
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [registrationResponse, DASHBOARD_URL, educationLevel, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
// We only want to track the recommendations returned by Algolia
|
||||
const courseKeys = algoliaRecommendations.map(course => course.courseKey);
|
||||
trackRecommendationsViewed(courseKeys, false, userId);
|
||||
}
|
||||
}, [isLoading, algoliaRecommendations, userId]);
|
||||
|
||||
if (!registrationResponse) {
|
||||
global.location.assign(DASHBOARD_URL);
|
||||
return null;
|
||||
}
|
||||
trackRecommendationsViewed(popularProducts, POPULAR, false, userId);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleRedirection = () => {
|
||||
window.history.replaceState(location.state, null, '');
|
||||
@@ -83,15 +39,25 @@ const RecommendationsPage = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoading && recommendations.length < RECOMMENDATIONS_COUNT) {
|
||||
handleRedirection();
|
||||
}
|
||||
|
||||
const handleSkip = (e) => {
|
||||
e.preventDefault();
|
||||
handleRedirection();
|
||||
};
|
||||
|
||||
if (!registrationResponse) {
|
||||
window.location.href = DASHBOARD_URL;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isLoading && (!popularProducts.length || !trendingProducts.length)) {
|
||||
handleRedirection();
|
||||
}
|
||||
|
||||
const handleOnSelect = (tabKey) => {
|
||||
const recommendations = tabKey === POPULAR ? popularProducts : trendingProducts;
|
||||
trackRecommendationsViewed(recommendations, tabKey, false, userId);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -106,49 +72,57 @@ const RecommendationsPage = (props) => {
|
||||
<Image className="logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
|
||||
</Hyperlink>
|
||||
</div>
|
||||
{(!isLoading && recommendations.length === RECOMMENDATIONS_COUNT) ? (
|
||||
<div className="d-flex flex-column align-items-center justify-content-center flex-grow-1 p-1">
|
||||
<RecommendationsList
|
||||
title={formatMessage(messages['recommendation.page.heading'])}
|
||||
recommendations={recommendations}
|
||||
userId={userId}
|
||||
<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}
|
||||
/>
|
||||
</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 className="text-center">
|
||||
<StatefulButton
|
||||
className="font-weight-500"
|
||||
type="submit"
|
||||
variant="brand"
|
||||
labels={{
|
||||
default: formatMessage(messages['recommendation.skip.button']),
|
||||
}}
|
||||
onClick={handleSkip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Spinner animation="border" variant="primary" className="spinner--position-centered" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
RecommendationsPage.propTypes = {
|
||||
location: PropTypes.shape({
|
||||
state: PropTypes.shape({
|
||||
registrationResult: PropTypes.shape({
|
||||
redirectUrl: PropTypes.string,
|
||||
}),
|
||||
userId: PropTypes.number,
|
||||
educationLevel: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
|
||||
countryCode: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
RecommendationsPage.defaultProps = {
|
||||
location: { state: {} },
|
||||
};
|
||||
const mapStateToProps = state => ({
|
||||
countryCode: state.register.backendCountryCode,
|
||||
});
|
||||
|
||||
export default RecommendationsPage;
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
null,
|
||||
)(RecommendationsPage);
|
||||
|
||||
@@ -9,3 +9,6 @@ export const EDUCATION_LEVEL_MAPPING = {
|
||||
hs: 'Introductory',
|
||||
jhs: 'Introductory',
|
||||
};
|
||||
|
||||
export const POPULAR = 'popular';
|
||||
export const TRENDING = 'trending';
|
||||
|
||||
22
src/recommendations/data/hooks/useProducts.jsx
Normal file
22
src/recommendations/data/hooks/useProducts.jsx
Normal 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 };
|
||||
}
|
||||
21
src/recommendations/data/tests/utils.test.jsx
Normal file
21
src/recommendations/data/tests/utils.test.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import mockedProductData from '../../tests/mockedData';
|
||||
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');
|
||||
expect(courseKey).toEqual('Demox+Test101');
|
||||
});
|
||||
it('should filter courses on the basis of country code', async () => {
|
||||
const products = filterLocationRestriction(mockedProductData, 'PK');
|
||||
expect(products.length).toEqual(1);
|
||||
});
|
||||
it('should return courseType and programType', async () => {
|
||||
const programType = useProductType(undefined, 'Professional Certificate');
|
||||
expect(programType).toEqual('Professional Certificate');
|
||||
const courseType = useProductType('verified-audit', undefined);
|
||||
expect(courseType).toEqual('Course');
|
||||
const noCourseType = useProductType(undefined, undefined);
|
||||
expect(noCourseType).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,54 @@ export const convertCourseRunKeytoCourseKey = (courseRunKey) => {
|
||||
return `${splitCourseKey[0]}+${splitCourseKey[1]}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
convertCourseRunKeytoCourseKey,
|
||||
const courseTypeToProductTypeMap = {
|
||||
course: 'Course',
|
||||
'verified-audit': 'Course',
|
||||
verified: 'Course',
|
||||
audit: 'Course',
|
||||
'credit-verified-audit': 'Course',
|
||||
'spoc-verified-audit': 'Course',
|
||||
professional: 'Professional Certificate',
|
||||
};
|
||||
|
||||
const programTypeToProductTypeMap = {
|
||||
'professional certificate': 'Professional Certificate',
|
||||
certificate: 'Certificate',
|
||||
};
|
||||
|
||||
export const useProductType = (courseType, programType) => {
|
||||
const courseTypeLowerCase = courseType?.toLowerCase();
|
||||
if (courseTypeToProductTypeMap[courseTypeLowerCase]) {
|
||||
return courseTypeToProductTypeMap[courseTypeLowerCase];
|
||||
}
|
||||
|
||||
const programTypeLowerCase = programType?.toLowerCase();
|
||||
if (programTypeToProductTypeMap[programTypeLowerCase]) {
|
||||
return programTypeToProductTypeMap[programTypeLowerCase];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getVariant = (productType) => (
|
||||
['Boot Camp', 'Executive Education', 'Course'].includes(productType) ? 'light' : 'dark'
|
||||
);
|
||||
|
||||
export const createCodeFriendlyProduct = (type) => type?.replace(/\s+/g, '-').replace(/'/g, '').toLowerCase();
|
||||
|
||||
export const truncateText = (input) => (input?.length > 50 ? `${input.substring(0, 50)}...` : input);
|
||||
|
||||
export const filterLocationRestriction = (products, countryCode) => products.filter((product) => {
|
||||
if (product.locationRestriction) {
|
||||
if (product.locationRestriction.restrictionType === 'allowlist') {
|
||||
return product.locationRestriction.countries.includes(countryCode);
|
||||
}
|
||||
if (product.locationRestriction.restrictionType === 'blocklist') {
|
||||
return !product.locationRestriction.countries.includes(countryCode);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
export default convertCourseRunKeytoCourseKey;
|
||||
|
||||
@@ -16,6 +16,60 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Skip for now',
|
||||
description: 'Skip button text',
|
||||
},
|
||||
|
||||
'recommendation.option.trending': {
|
||||
id: 'recommendation.option.trending',
|
||||
defaultMessage: 'Trending',
|
||||
description: 'Title for trending products',
|
||||
},
|
||||
'recommendation.option.popular': {
|
||||
id: 'recommendation.option.popular',
|
||||
defaultMessage: 'Most Popular',
|
||||
description: 'Title for popular products',
|
||||
},
|
||||
});
|
||||
|
||||
export const cardBadgesMessages = defineMessages({
|
||||
'recommendation.product-card.pill-text.course': {
|
||||
id: 'recommendation.product-card.pill-text.course',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Course',
|
||||
},
|
||||
'recommendation.product-card.pill-text.professional-certificate': {
|
||||
id: 'recommendation.product-card.pill-text.professional-certificate',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Professional Certificate',
|
||||
},
|
||||
});
|
||||
|
||||
export const cardFooterMessages = defineMessages({
|
||||
'recommendation.product-card.footer-text.emeritus': {
|
||||
id: 'recommendation.product-card.pill-text.emeritus',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Offered on Emeritus',
|
||||
},
|
||||
'recommendation.product-card.footer-text.shorelight': {
|
||||
id: 'recommendation.product-card.pill-text.shorelight',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Offered through Shorelight',
|
||||
},
|
||||
'recommendation.product-card.footer-text.number-of-courses': {
|
||||
id: 'recommendation.product-card.footer-text.number-of-courses',
|
||||
description: 'Label in card footer that shows how many courses are in a program',
|
||||
defaultMessage: '{length} {label}',
|
||||
},
|
||||
'recommendation.product-card.footer-text.subscription': {
|
||||
id: 'recommendation.product-card.footer-text.subscription',
|
||||
description: 'Label in card footer that describes that it is a subscription program',
|
||||
defaultMessage: 'Subscription',
|
||||
},
|
||||
});
|
||||
|
||||
export const externalLinkIconMessages = defineMessages({
|
||||
'recommendation.product-card.launch-icon.sr-text': {
|
||||
id: 'recommendation.product-card.launch-icon.sr-text',
|
||||
description: 'Screen reader text for the launch icon on the cards',
|
||||
defaultMessage: 'Opens a link in a new tab',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import optimizelyInstance from '../data/optimizely';
|
||||
|
||||
const RECOMMENDATIONS_EXP_KEY = 'welcome_page_recommendations_exp';
|
||||
const RECOMMENDATIONS_EXP_VARIATION = 'welcome_page_recommendations_enabled';
|
||||
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',
|
||||
|
||||
42
src/recommendations/tests/RecommendationsList.test.jsx
Normal file
42
src/recommendations/tests/RecommendationsList.test.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import mockedProductData from './mockedData';
|
||||
import RecommendationList from '../RecommendationsList';
|
||||
|
||||
const IntlRecommendationList = injectIntl(RecommendationList);
|
||||
const mockStore = configureStore();
|
||||
|
||||
describe('RecommendationsListTests', () => {
|
||||
const store = mockStore({});
|
||||
const reduxWrapper = children => (
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
it('should render the product card', () => {
|
||||
const props = {
|
||||
recommendations: mockedProductData,
|
||||
userId: 1234567,
|
||||
};
|
||||
|
||||
const recommendationsList = mount(reduxWrapper(<IntlRecommendationList {...props} />));
|
||||
expect(recommendationsList.find('.recommendation-card').length).toEqual(mockedProductData.length);
|
||||
});
|
||||
|
||||
it('should render the recommendations card with footer content', () => {
|
||||
const props = {
|
||||
recommendations: mockedProductData,
|
||||
userId: 1234567,
|
||||
};
|
||||
|
||||
const recommendationsList = mount(reduxWrapper(<IntlRecommendationList {...props} />));
|
||||
expect(recommendationsList.find('.x-small').at(0).text()).toEqual('1 Course');
|
||||
expect(recommendationsList.find('.x-small').at(1).text()).toEqual('2 Courses');
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,12 @@ import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { getConfig, mergeConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import { mockedGeneralRecommendations, mockedResponse } from './mockedData';
|
||||
import { DEFAULT_REDIRECT_URL } from '../../data/constants';
|
||||
import getPersonalizedRecommendations from '../data/service';
|
||||
import { trackRecommendationCardClickOptimizely } from '../optimizelyExperiment';
|
||||
import RecommendationsPage from '../RecommendationsPage';
|
||||
|
||||
const IntlRecommendationsPage = injectIntl(RecommendationsPage);
|
||||
@@ -24,20 +20,25 @@ jest.mock('../data/service', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../optimizelyExperiment', () => ({
|
||||
trackRecommendationCardClickOptimizely: jest.fn(),
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('RecommendationsPageTests', () => {
|
||||
mergeConfig({
|
||||
GENERAL_RECOMMENDATIONS: '[]',
|
||||
POPULAR_PRODUCTS: '[]',
|
||||
TRENDING_PRODUCTS: '[]',
|
||||
});
|
||||
|
||||
let defaultProps = {};
|
||||
let store = {};
|
||||
|
||||
let registrationResult = {
|
||||
redirectUrl: getConfig().LMS_BASE_URL.concat('/course-about-page-url'),
|
||||
const dashboardUrl = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
const redirectUrl = getConfig().LMS_BASE_URL.concat('/course-about-page-url');
|
||||
|
||||
const registrationResult = {
|
||||
redirectUrl,
|
||||
success: true,
|
||||
};
|
||||
const reduxWrapper = children => (
|
||||
@@ -46,136 +47,48 @@ describe('RecommendationsPageTests', () => {
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const getRecommendationsPage = async (props = defaultProps) => {
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage {...props} />));
|
||||
await act(async () => {
|
||||
await Promise.resolve(recommendationsPage);
|
||||
recommendationsPage.update();
|
||||
});
|
||||
|
||||
return recommendationsPage;
|
||||
};
|
||||
const mockUseLocation = () => (
|
||||
useLocation.mockReturnValue({
|
||||
state: {
|
||||
registrationResult,
|
||||
userId: 111,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({});
|
||||
defaultProps = {
|
||||
location: {
|
||||
state: {
|
||||
registrationResult,
|
||||
userId: 111,
|
||||
},
|
||||
store = mockStore({
|
||||
register: {
|
||||
backendCountryCode: 'PK',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('redirects to dashboard if user tries to access the page directly', async () => {
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
delete window.location;
|
||||
window.location = {
|
||||
href: getConfig().BASE_URL,
|
||||
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
|
||||
};
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([]));
|
||||
await getRecommendationsPage({});
|
||||
|
||||
expect(getPersonalizedRecommendations).toHaveBeenCalledTimes(0);
|
||||
expect(window.location.href).toEqual(DASHBOARD_URL);
|
||||
});
|
||||
it('redirects to dashboard if user click on skip button', async () => {
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
registrationResult = {
|
||||
...registrationResult,
|
||||
redirectUrl: getConfig().LMS_BASE_URL.concat('/dashboard'),
|
||||
};
|
||||
const props = {
|
||||
location: {
|
||||
state: {
|
||||
registrationResult,
|
||||
userId: 111,
|
||||
},
|
||||
},
|
||||
};
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
|
||||
const recommendationsPage = await getRecommendationsPage(props);
|
||||
recommendationsPage.find('button').simulate('click');
|
||||
expect(window.location.href).toEqual(DASHBOARD_URL);
|
||||
});
|
||||
|
||||
it('should call trackRecommendationCardClickOptimizely when card is clicked', async () => {
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
recommendationsPage.find('.card-box').first().simulate('click');
|
||||
expect(trackRecommendationCardClickOptimizely).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show loading state to user', async () => {
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
|
||||
await act(async () => {
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage {...defaultProps} />));
|
||||
expect(recommendationsPage.find('.spinner--position-centered').exists()).toBeTruthy();
|
||||
});
|
||||
useLocation.mockReturnValue({
|
||||
state: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call getPersonalizedRecommendations', async () => {
|
||||
delete window.location;
|
||||
window.location = { assign: jest.fn() };
|
||||
getPersonalizedRecommendations.mockClear();
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([]));
|
||||
await getRecommendationsPage();
|
||||
|
||||
expect(getPersonalizedRecommendations).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
||||
'edx.bi.user.recommendations.viewed',
|
||||
{
|
||||
page: 'authn_recommendations',
|
||||
course_key_array: [],
|
||||
amplitude_recommendations: false,
|
||||
is_control: false,
|
||||
user_id: 111,
|
||||
},
|
||||
);
|
||||
it('should redirect to dashboard if user is not coming from registration workflow', () => {
|
||||
mount(reduxWrapper(<IntlRecommendationsPage />));
|
||||
expect(window.location.href).toEqual(dashboardUrl);
|
||||
});
|
||||
|
||||
it('should display recommendations returned by Algolia', async () => {
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
|
||||
expect(recommendationsPage.find('#course-recommendations').exists()).toBeTruthy();
|
||||
it('should redirect if either popular or trending recommendations are not configured', () => {
|
||||
mockUseLocation();
|
||||
mount(reduxWrapper(<IntlRecommendationsPage />));
|
||||
expect(window.location.href).toEqual(redirectUrl);
|
||||
});
|
||||
|
||||
it('should not display recommendations if error comes in while fetching the recommendations', async () => {
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.reject(mockedResponse));
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
it('should redirect user if they click "Skip for now" button', () => {
|
||||
mockUseLocation();
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
|
||||
recommendationsPage.find('.pgn__stateful-btn-state-default').first().simulate('click');
|
||||
|
||||
expect(recommendationsPage.find('#recommendation-card').exists()).toBeFalsy();
|
||||
expect(window.location.href).toEqual(redirectUrl);
|
||||
});
|
||||
|
||||
it('should redirect if recommended courses count is less than RECOMMENDATIONS_COUNT', async () => {
|
||||
delete window.location;
|
||||
window.location = { assign: jest.fn() };
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([mockedResponse[0]]));
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
|
||||
expect(recommendationsPage.find('#course-recommendations').exists()).toBeFalsy();
|
||||
expect(window.location.href).toEqual(registrationResult.redirectUrl);
|
||||
});
|
||||
|
||||
it('should not redirect if fallback recommendations are enabled', async () => {
|
||||
mergeConfig({
|
||||
GENERAL_RECOMMENDATIONS: mockedGeneralRecommendations,
|
||||
});
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([]));
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
|
||||
expect(recommendationsPage.find('#course-recommendations').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display all owners for a course', async () => {
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
|
||||
expect(
|
||||
recommendationsPage.find('.pgn__card-header-subtitle-md').getElements()[0].props.children,
|
||||
).toEqual('firstOwnerX, secondOwnerX');
|
||||
it('displays popular products as default recommendations', () => {
|
||||
mockUseLocation();
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
|
||||
expect(recommendationsPage.find('.nav-link .active a').text()).toEqual('Most Popular');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,79 +1,80 @@
|
||||
export const mockedResponse = [
|
||||
const mockedProductData = [
|
||||
{
|
||||
uuid: 'test-uuid-1',
|
||||
title: 'How to Learn Online 1',
|
||||
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-1',
|
||||
subtitle: 'Org 1',
|
||||
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-1.png',
|
||||
activeRunKey: 'course-v1:test+testX+2018',
|
||||
owners: [
|
||||
authoringOrganizations: [
|
||||
{
|
||||
key: 'firstOwnerX',
|
||||
key: 'org-1',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-1.png',
|
||||
name: 'first owner',
|
||||
},
|
||||
{
|
||||
key: 'secondOwnerX',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-1.png',
|
||||
name: 'second owner',
|
||||
name: 'Org 1',
|
||||
},
|
||||
],
|
||||
objectId: 'course-how-to-learn-online-key-1',
|
||||
courses: [
|
||||
{
|
||||
course: {
|
||||
title: 'How to learn online course 1',
|
||||
topics: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'Professional Certificate',
|
||||
url: '/test-professional-certificate/how-to-learn-online-1',
|
||||
organizationLogoOverrideUrl: null,
|
||||
organizationShortCodeOverride: '',
|
||||
productSource: {
|
||||
name: 'company X',
|
||||
slug: 'companyX',
|
||||
},
|
||||
status: 'active',
|
||||
hidden: false,
|
||||
degree: null,
|
||||
locationRestriction: null,
|
||||
cardType: 'program',
|
||||
cardIndex: 0,
|
||||
},
|
||||
{
|
||||
uuid: 'test-uuid-2',
|
||||
title: 'How to Learn Online 2',
|
||||
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-2',
|
||||
subtitle: 'Org 2',
|
||||
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-2.png',
|
||||
activeRunKey: 'course-v1:test+testX+2019',
|
||||
owners: [
|
||||
authoringOrganizations: [
|
||||
{
|
||||
key: 'testX',
|
||||
key: 'org-2',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-2.png',
|
||||
name: 'test',
|
||||
name: 'Org 2',
|
||||
},
|
||||
],
|
||||
objectId: 'course-how-to-learn-online-key-2',
|
||||
},
|
||||
{
|
||||
title: 'How to Learn Online 3',
|
||||
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-3',
|
||||
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-3.png',
|
||||
activeRunKey: 'course-v1:test+testX+2020',
|
||||
owners: [
|
||||
courses: [
|
||||
{
|
||||
key: 'testX',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-3.png',
|
||||
name: 'test',
|
||||
course: {
|
||||
title: 'How to learn online course 1',
|
||||
topics: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
objectId: 'course-how-to-learn-online-key-3',
|
||||
},
|
||||
{
|
||||
title: 'How to Learn Online 4',
|
||||
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-4',
|
||||
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-4.png',
|
||||
activeRunKey: 'course-v1:test+testX+2021',
|
||||
owners: [
|
||||
{
|
||||
key: 'testX',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-4.png',
|
||||
name: 'test',
|
||||
course: {
|
||||
title: 'How to learn online course 2',
|
||||
topics: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
objectId: 'course-how-to-learn-online-key-4',
|
||||
},
|
||||
{
|
||||
title: 'How to Learn Online 5',
|
||||
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-5',
|
||||
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-5.png',
|
||||
activeRunKey: 'course-v1:test+testX+2022',
|
||||
owners: [
|
||||
{
|
||||
key: 'testX',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-5.png',
|
||||
name: 'test',
|
||||
},
|
||||
],
|
||||
objectId: 'course-how-to-learn-online-key-5',
|
||||
type: 'Professional Certificate',
|
||||
url: '/test-professional-certificate/how-to-learn-online-2',
|
||||
organizationLogoOverrideUrl: null,
|
||||
organizationShortCodeOverride: '',
|
||||
productSource: {
|
||||
name: 'company X',
|
||||
slug: 'companyX',
|
||||
},
|
||||
status: 'active',
|
||||
hidden: false,
|
||||
degree: null,
|
||||
cardType: 'program',
|
||||
cardIndex: 1,
|
||||
locationRestriction: { restrictionType: 'blocklist', countries: ['PK'] },
|
||||
},
|
||||
];
|
||||
|
||||
export const mockedGeneralRecommendations = '[{"courseKey":"test+text1","activeRunKey":"course-v1:test+test1+2018","cardImageUrl":"https://test-recommendations.com/text-1.jpg","marketingUrl":"https://test-recommendations.com/test-1","objectId":"test-1","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-1.png","name":"General recommendation org 1"}],"title":"General recommendation 1","recommendationType":"general"},{"courseKey":"test+text2","activeRunKey":"course-v1:test+test2+2018","cardImageUrl":"https://test-recommendations.com/text-2.jpg","marketingUrl":"https://test-recommendations.com/test-2","objectId":"test-2","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-2.png","name":"General recommendation org 2"}],"title":"General recommendation 2","recommendationType":"general"},{"courseKey":"test+text3","activeRunKey":"course-v1:test+test3+2018","cardImageUrl":"https://test-recommendations.com/text-3.jpg","marketingUrl":"https://test-recommendations.com/test-3","objectId":"test-3","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-3.png","name":"General recommendation org 3"}],"title":"General recommendation 3","recommendationType":"general"},{"courseKey":"test+text4","activeRunKey":"course-v1:test+test4+2018","cardImageUrl":"https://test-recommendations.com/text-4.jpg","marketingUrl":"https://test-recommendations.com/test-4","objectId":"test-4","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-4.png","name":"General recommendation org 4"}],"title":"General recommendation 4","recommendationType":"general"},{"courseKey":"test+text5","activeRunKey":"course-v1:test+test5+2018","cardImageUrl":"https://test-recommendations.com/text-5.jpg","marketingUrl":"https://test-recommendations.com/test-5","objectId":"test-5","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-5.png","name":"General recommendation org 5"}],"title":"General recommendation 5","recommendationType":"general"}]';
|
||||
export default mockedProductData;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
export const LINK_TIMEOUT = 300;
|
||||
|
||||
export const eventNames = {
|
||||
recommendedCourseClicked: 'edx.bi.user.recommended.course.click',
|
||||
recommendedProductClicked: 'edx.bi.user.recommended.product.clicked',
|
||||
recommendationsGroup: 'edx.bi.user.recommendations.group',
|
||||
recommendationsViewed: 'edx.bi.user.recommendations.viewed',
|
||||
};
|
||||
@@ -17,27 +17,36 @@ export const createLinkTracker = (tracker, href, openInNewTab = false) => (e) =>
|
||||
return setTimeout(() => { global.location.href = href; }, LINK_TIMEOUT);
|
||||
};
|
||||
|
||||
export const trackRecommendationsClicked = (courseKey, isControl, position, userId, href, recommendationType) => {
|
||||
createLinkTracker(
|
||||
sendTrackEvent(eventNames.recommendedCourseClicked, {
|
||||
page: 'authn_recommendations',
|
||||
position,
|
||||
recommendation_type: recommendationType,
|
||||
course_key: courseKey,
|
||||
is_control: isControl,
|
||||
user_id: userId,
|
||||
}),
|
||||
href,
|
||||
true,
|
||||
);
|
||||
const generateProductKey = (product) => {
|
||||
const productKey = product.cardType === 'program' ? `${product.title} [${product.uuid}]` : product.activeRunKey;
|
||||
return productKey;
|
||||
};
|
||||
|
||||
export const trackRecommendationsViewed = (recommendedCourseKeys, isControl, userId) => {
|
||||
export const trackRecommendationClick = (product, position, isControl, userId) => {
|
||||
sendTrackEvent(eventNames.recommendedProductClicked, {
|
||||
page: 'authn_recommendations',
|
||||
position,
|
||||
recommendation_type: product.recommendationType,
|
||||
product_key: generateProductKey(product),
|
||||
product_line: product.cardType,
|
||||
product_source: product.productSource.name,
|
||||
is_control: isControl,
|
||||
user_id: userId,
|
||||
});
|
||||
return setTimeout(() => { global.open(product.url, '_blank'); }, LINK_TIMEOUT);
|
||||
};
|
||||
|
||||
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.recommendationsViewed, {
|
||||
page: 'authn_recommendations',
|
||||
course_key_array: recommendedCourseKeys,
|
||||
amplitude_recommendations: false,
|
||||
recommendation_type: type,
|
||||
products: viewedProductsList,
|
||||
is_control: isControl,
|
||||
user_id: userId,
|
||||
},
|
||||
@@ -55,7 +64,7 @@ export const trackRecommendationsGroup = (variation, userId) => {
|
||||
};
|
||||
|
||||
export default {
|
||||
trackRecommendationsClicked,
|
||||
trackRecommendationClick,
|
||||
trackRecommendationsGroup,
|
||||
trackRecommendationsViewed,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FIELDS } from './data/constants';
|
||||
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';
|
||||
@@ -34,7 +35,9 @@ const ConfigurableRegistrationForm = (props) => {
|
||||
setFieldErrors,
|
||||
setFocusedField,
|
||||
setFormFields,
|
||||
registrationEmbedded,
|
||||
} = props;
|
||||
const backendCountryCode = useSelector(state => state.register.backendCountryCode);
|
||||
|
||||
let showTermsOfServiceAndHonorCode = false;
|
||||
let showCountryField = false;
|
||||
@@ -53,6 +56,29 @@ const ConfigurableRegistrationForm = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (backendCountryCode && backendCountryCode !== formFields?.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];
|
||||
}
|
||||
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;
|
||||
let value;
|
||||
@@ -82,6 +108,9 @@ const ConfigurableRegistrationForm = (props) => {
|
||||
} else if (name === 'confirm_email' && value !== email) {
|
||||
error = formatMessage(messages['email.do.not.match']);
|
||||
}
|
||||
if (registrationEmbedded) {
|
||||
return;
|
||||
}
|
||||
setFocusedField(null);
|
||||
setFieldErrors(prevErrors => ({ ...prevErrors, [name]: error }));
|
||||
};
|
||||
@@ -214,12 +243,15 @@ ConfigurableRegistrationForm.propTypes = {
|
||||
marketingEmailsOptIn: PropTypes.bool,
|
||||
}).isRequired,
|
||||
setFieldErrors: PropTypes.func.isRequired,
|
||||
setFocusedField: PropTypes.func.isRequired,
|
||||
setFocusedField: PropTypes.func,
|
||||
setFormFields: PropTypes.func.isRequired,
|
||||
registrationEmbedded: PropTypes.bool,
|
||||
};
|
||||
|
||||
ConfigurableRegistrationForm.defaultProps = {
|
||||
fieldDescriptions: {},
|
||||
registrationEmbedded: false,
|
||||
setFocusedField: () => {},
|
||||
};
|
||||
|
||||
export default ConfigurableRegistrationForm;
|
||||
|
||||
555
src/register/EmbeddableRegistrationPage.jsx
Normal file
555
src/register/EmbeddableRegistrationPage.jsx
Normal 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);
|
||||
@@ -23,15 +23,13 @@ import {
|
||||
setUserPipelineDataLoaded,
|
||||
} from './data/actions';
|
||||
import {
|
||||
COUNTRY_CODE_KEY,
|
||||
COUNTRY_DISPLAY_KEY,
|
||||
FIELDS,
|
||||
FORM_SUBMISSION_ERROR,
|
||||
TPA_AUTHENTICATION_FAILURE,
|
||||
} from './data/constants';
|
||||
import { registrationErrorSelector, validationsSelector } from './data/selectors';
|
||||
import {
|
||||
getSuggestionForInvalidEmail, validateCountryField, validateEmailAddress,
|
||||
emailRegex, getSuggestionForInvalidEmail, urlRegex, validateCountryField, validateEmailAddress,
|
||||
} from './data/utils';
|
||||
import messages from './messages';
|
||||
import RegistrationFailure from './RegistrationFailure';
|
||||
@@ -46,20 +44,15 @@ import {
|
||||
} from '../common-components/data/selectors';
|
||||
import EnterpriseSSO from '../common-components/EnterpriseSSO';
|
||||
import {
|
||||
COMPLETE_STATE,
|
||||
DEFAULT_STATE, INVALID_NAME_REGEX, LETTER_REGEX, NUMBER_REGEX, PENDING_STATE, REGISTER_PAGE, VALID_EMAIL_REGEX,
|
||||
COMPLETE_STATE, DEFAULT_STATE, LETTER_REGEX, NUMBER_REGEX, PENDING_STATE, REGISTER_PAGE,
|
||||
} from '../data/constants';
|
||||
import {
|
||||
getAllPossibleQueryParams, getTpaHint, getTpaProvider, setCookie, setSurveyCookie,
|
||||
getAllPossibleQueryParams, getTpaHint, getTpaProvider, setCookie,
|
||||
} from '../data/utils';
|
||||
|
||||
const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i');
|
||||
const urlRegex = new RegExp(INVALID_NAME_REGEX);
|
||||
|
||||
const RegistrationPage = (props) => {
|
||||
const {
|
||||
backedUpFormData,
|
||||
backendCountryCode,
|
||||
backendValidations,
|
||||
fieldDescriptions,
|
||||
handleInstitutionLogin,
|
||||
@@ -72,7 +65,6 @@ const RegistrationPage = (props) => {
|
||||
submitState,
|
||||
thirdPartyAuthApiStatus,
|
||||
thirdPartyAuthContext,
|
||||
usernameSuggestions,
|
||||
validationApiRateLimited,
|
||||
// Actions
|
||||
backupFormState,
|
||||
@@ -189,44 +181,19 @@ const RegistrationPage = (props) => {
|
||||
}
|
||||
}, [registrationErrorCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (backendCountryCode && backendCountryCode !== configurableFormFields?.country?.countryCode) {
|
||||
let countryCode = '';
|
||||
let countryDisplayValue = '';
|
||||
|
||||
const selectedCountry = countryList.find(
|
||||
(country) => (country[COUNTRY_CODE_KEY].toLowerCase() === backendCountryCode.toLowerCase()),
|
||||
);
|
||||
if (selectedCountry) {
|
||||
countryCode = selectedCountry[COUNTRY_CODE_KEY];
|
||||
countryDisplayValue = selectedCountry[COUNTRY_DISPLAY_KEY];
|
||||
}
|
||||
setConfigurableFormFields(prevState => (
|
||||
{
|
||||
...prevState,
|
||||
country: {
|
||||
countryCode, displayValue: countryDisplayValue,
|
||||
},
|
||||
}
|
||||
));
|
||||
}
|
||||
}, [backendCountryCode, countryList]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/**
|
||||
* We need to remove the placeholder from the field, adding a space will do that.
|
||||
* This is needed because we are placing the username suggestions on top of the field.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (usernameSuggestions.length && !formFields.username) {
|
||||
setFormFields(prevState => ({ ...prevState, username: ' ' }));
|
||||
}
|
||||
}, [usernameSuggestions, formFields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registrationResult.success) {
|
||||
// TODO: Do we still need this cookie?
|
||||
setSurveyCookie('register');
|
||||
// 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
|
||||
@@ -311,7 +278,7 @@ const RegistrationPage = (props) => {
|
||||
break;
|
||||
default:
|
||||
if (flags.showConfigurableRegistrationFields) {
|
||||
if (!value && fieldDescriptions[fieldName].error_message) {
|
||||
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']);
|
||||
@@ -389,29 +356,21 @@ const RegistrationPage = (props) => {
|
||||
|
||||
const handleEmailSuggestionClosed = () => setEmailSuggestion({ suggestion: '', type: '' });
|
||||
|
||||
const handleUsernameSuggestionClosed = () => props.resetUsernameSuggestions();
|
||||
|
||||
const handleOnChange = (event) => {
|
||||
console.log('test handleOnChange', event.target);
|
||||
const { name } = event.target;
|
||||
let value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
|
||||
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
|
||||
if (registrationError[name]) {
|
||||
clearBackendError(name);
|
||||
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
|
||||
}
|
||||
if (name === 'username') {
|
||||
if (value.length > 30) {
|
||||
return;
|
||||
}
|
||||
if (value.startsWith(' ')) {
|
||||
value = value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
setFormFields(prevState => ({ ...prevState, [name]: value }));
|
||||
};
|
||||
|
||||
const handleOnBlur = (event) => {
|
||||
const { name, value } = event.target;
|
||||
|
||||
const payload = {
|
||||
name: formFields.name,
|
||||
email: formFields.email,
|
||||
@@ -425,22 +384,12 @@ const RegistrationPage = (props) => {
|
||||
};
|
||||
|
||||
const handleOnFocus = (event) => {
|
||||
const { name, value } = event.target;
|
||||
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);
|
||||
|
||||
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 registerUser = () => {
|
||||
@@ -455,7 +404,7 @@ const RegistrationPage = (props) => {
|
||||
const { fieldError: focusedFieldError, countryFieldCode } = focusedField ? (
|
||||
validateInput(
|
||||
focusedField,
|
||||
(focusedField in fieldDescriptions || focusedField === 'country') ? (
|
||||
(focusedField in fieldDescriptions || ['country', 'marketingEmailsOptIn'].includes(focusedField)) ? (
|
||||
configurableFormFields[focusedField]
|
||||
) : formFields[focusedField],
|
||||
payload,
|
||||
@@ -529,7 +478,9 @@ const RegistrationPage = (props) => {
|
||||
<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}
|
||||
@@ -572,8 +523,6 @@ const RegistrationPage = (props) => {
|
||||
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'])}
|
||||
@@ -672,7 +621,6 @@ RegistrationPage.propTypes = {
|
||||
errors: PropTypes.shape({}),
|
||||
emailSuggestion: PropTypes.shape({}),
|
||||
}),
|
||||
backendCountryCode: PropTypes.string,
|
||||
backendValidations: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
@@ -680,7 +628,7 @@ RegistrationPage.propTypes = {
|
||||
password: PropTypes.string,
|
||||
}),
|
||||
fieldDescriptions: PropTypes.shape({}),
|
||||
institutionLogin: PropTypes.bool.isRequired,
|
||||
institutionLogin: PropTypes.bool,
|
||||
optionalFields: PropTypes.shape({}),
|
||||
registrationError: PropTypes.shape({}),
|
||||
registrationErrorCode: PropTypes.string,
|
||||
@@ -712,16 +660,14 @@ RegistrationPage.propTypes = {
|
||||
PropTypes.shape({}),
|
||||
),
|
||||
}),
|
||||
usernameSuggestions: PropTypes.arrayOf(PropTypes.string),
|
||||
userPipelineDataLoaded: PropTypes.bool,
|
||||
validationApiRateLimited: PropTypes.bool,
|
||||
// Actions
|
||||
backupFormState: PropTypes.func.isRequired,
|
||||
clearBackendError: PropTypes.func.isRequired,
|
||||
getRegistrationDataFromBackend: PropTypes.func.isRequired,
|
||||
handleInstitutionLogin: PropTypes.func.isRequired,
|
||||
handleInstitutionLogin: PropTypes.func,
|
||||
registerNewUser: PropTypes.func.isRequired,
|
||||
resetUsernameSuggestions: PropTypes.func.isRequired,
|
||||
setUserPipelineDetailsLoaded: PropTypes.func.isRequired,
|
||||
validateFromBackend: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -741,9 +687,10 @@ RegistrationPage.defaultProps = {
|
||||
suggestion: '', type: '',
|
||||
},
|
||||
},
|
||||
backendCountryCode: '',
|
||||
backendValidations: null,
|
||||
fieldDescriptions: {},
|
||||
handleInstitutionLogin: null,
|
||||
institutionLogin: false,
|
||||
optionalFields: {},
|
||||
registrationError: {},
|
||||
registrationErrorCode: '',
|
||||
@@ -761,7 +708,6 @@ RegistrationPage.defaultProps = {
|
||||
providers: [],
|
||||
secondaryProviders: [],
|
||||
},
|
||||
usernameSuggestions: [],
|
||||
userPipelineDataLoaded: false,
|
||||
validationApiRateLimited: false,
|
||||
};
|
||||
@@ -772,7 +718,6 @@ export default connect(
|
||||
backupFormState: backupRegistrationFormBegin,
|
||||
clearBackendError: clearRegistertionBackendError,
|
||||
getRegistrationDataFromBackend: getThirdPartyAuthContext,
|
||||
resetUsernameSuggestions: clearUsernameSuggestions,
|
||||
validateFromBackend: fetchRealtimeValidations,
|
||||
registerNewUser,
|
||||
setUserPipelineDetailsLoaded: setUserPipelineDataLoaded,
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
DEFAULT_SERVICE_PROVIDER_DOMAINS,
|
||||
DEFAULT_TOP_LEVEL_DOMAINS,
|
||||
} from './constants';
|
||||
import {
|
||||
INVALID_NAME_REGEX, VALID_EMAIL_REGEX,
|
||||
} from '../../data/constants';
|
||||
|
||||
function getLevenshteinSuggestion(word, knownWords, similarityThreshold = 4) {
|
||||
if (!word) {
|
||||
@@ -110,3 +113,6 @@ export function validateCountryField(value, countryList, errorMessage) {
|
||||
}
|
||||
return { error, countryCode, displayValue };
|
||||
}
|
||||
|
||||
export const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i');
|
||||
export const urlRegex = new RegExp(INVALID_NAME_REGEX);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as RegistrationPage } from './RegistrationPage';
|
||||
export { default as EmbeddableRegistrationPage } from './EmbeddableRegistrationPage';
|
||||
export { default as reducer } from './data/reducers';
|
||||
export { default as saga } from './data/sagas';
|
||||
export { storeName } from './data/selectors';
|
||||
|
||||
@@ -69,6 +69,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Or register with:',
|
||||
description: 'A message that appears above third party auth providers i.e saml, google, facebook etc',
|
||||
},
|
||||
'create.account.cta.button': {
|
||||
id: 'create.account.cta.button',
|
||||
defaultMessage: '{label}',
|
||||
description: 'Label text for registration form submission button for those users who are landing through redirections',
|
||||
},
|
||||
// Institution login
|
||||
'register.institution.login.button': {
|
||||
id: 'register.institution.login.button',
|
||||
|
||||
@@ -30,12 +30,22 @@ const HonorCode = (props) => {
|
||||
values={{
|
||||
platformName: getConfig().SITE_NAME,
|
||||
tosAndHonorCode: (
|
||||
<Hyperlink variant="muted" destination={getConfig().TOS_AND_HONOR_CODE || '#'} target="_blank">
|
||||
<Hyperlink
|
||||
className="inline-link"
|
||||
destination={getConfig().TOS_AND_HONOR_CODE || '#'}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{formatMessage(messages['terms.of.service.and.honor.code'])}
|
||||
</Hyperlink>
|
||||
),
|
||||
privacyPolicy: (
|
||||
<Hyperlink variant="muted" destination={getConfig().PRIVACY_POLICY || '#'} target="_blank">
|
||||
<Hyperlink
|
||||
className="inline-link"
|
||||
destination={getConfig().PRIVACY_POLICY || '#'}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
>
|
||||
{formatMessage(messages['privacy.policy'])}
|
||||
</Hyperlink>
|
||||
),
|
||||
|
||||
@@ -1,21 +1,72 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon, IconButton } from '@edx/paragon';
|
||||
import { Close } from '@edx/paragon/icons';
|
||||
import PropTypes, { string } from 'prop-types';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormGroup } from '../../common-components';
|
||||
import { clearUsernameSuggestions } from '../data/actions';
|
||||
import messages from '../messages';
|
||||
|
||||
const UsernameField = (props) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
handleSuggestionClick, handleUsernameSuggestionClose, usernameSuggestions, errorMessage,
|
||||
value,
|
||||
errorMessage,
|
||||
handleChange,
|
||||
handleFocus,
|
||||
} = props;
|
||||
let className = '';
|
||||
let suggestedUsernameDiv = null;
|
||||
let iconButton = null;
|
||||
const usernameSuggestions = useSelector(state => state.register.usernameSuggestions);
|
||||
|
||||
/**
|
||||
* We need to remove the placeholder from the field, adding a space will do that.
|
||||
* This is needed because we are placing the username suggestions on top of the field.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (usernameSuggestions.length && !value) {
|
||||
handleChange({ target: { name: 'username', value: ' ' } });
|
||||
}
|
||||
}, [handleChange, usernameSuggestions, value]);
|
||||
|
||||
const handleOnChange = (event) => {
|
||||
let username = event.target.value;
|
||||
if (username.length > 30) {
|
||||
return;
|
||||
}
|
||||
if (event.target.value.startsWith(' ')) {
|
||||
username = username.trim();
|
||||
}
|
||||
handleChange({ target: { name: 'username', value: username } });
|
||||
};
|
||||
|
||||
const handleOnFocus = (event) => {
|
||||
const username = event.target.value;
|
||||
dispatch(clearUsernameSuggestions());
|
||||
// If we added a space character to username field to display the suggestion
|
||||
// remove it before user enters the input. This is to ensure user doesn't
|
||||
// have a space prefixed to the username.
|
||||
if (username === ' ') {
|
||||
handleChange({ target: { name: 'username', value: '' } });
|
||||
}
|
||||
handleFocus({ target: { name: 'username', value: username } });
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (event, fieldName, suggestion = '') => {
|
||||
event.preventDefault();
|
||||
handleFocus({ target: { name: 'username', value: suggestion } }); // to clear the error if any
|
||||
handleChange({ target: { name: 'username', value: suggestion } }); // to set suggestion as value
|
||||
dispatch(clearUsernameSuggestions());
|
||||
};
|
||||
|
||||
const handleUsernameSuggestionClose = () => dispatch(clearUsernameSuggestions());
|
||||
|
||||
const suggestedUsernames = () => (
|
||||
<div className={className}>
|
||||
<span className="text-gray username-suggestion--label">{formatMessage(messages['registration.username.suggestion.label'])}</span>
|
||||
@@ -37,34 +88,33 @@ const UsernameField = (props) => {
|
||||
{iconButton}
|
||||
</div>
|
||||
);
|
||||
if (usernameSuggestions.length > 0 && errorMessage && props.value === ' ') {
|
||||
|
||||
if (usernameSuggestions.length > 0 && errorMessage && value === ' ') {
|
||||
className = 'username-suggestions__error';
|
||||
iconButton = <IconButton src={Close} iconAs={Icon} alt="Close" onClick={() => handleUsernameSuggestionClose()} variant="black" size="sm" className="username-suggestions__close__button" />;
|
||||
suggestedUsernameDiv = suggestedUsernames();
|
||||
} else if (usernameSuggestions.length > 0 && props.value === ' ') {
|
||||
className = 'username-suggestions';
|
||||
} else if (usernameSuggestions.length > 0 && value === ' ') {
|
||||
className = 'username-suggestions d-flex align-items-center';
|
||||
iconButton = <IconButton src={Close} iconAs={Icon} alt="Close" onClick={() => handleUsernameSuggestionClose()} variant="black" size="sm" className="username-suggestions__close__button" />;
|
||||
suggestedUsernameDiv = suggestedUsernames();
|
||||
} else if (usernameSuggestions.length > 0 && errorMessage) {
|
||||
suggestedUsernameDiv = suggestedUsernames();
|
||||
}
|
||||
return (
|
||||
<FormGroup {...props}>
|
||||
<FormGroup {...props} handleChange={handleOnChange} handleFocus={handleOnFocus}>
|
||||
{suggestedUsernameDiv}
|
||||
</FormGroup>
|
||||
);
|
||||
};
|
||||
|
||||
UsernameField.defaultProps = {
|
||||
usernameSuggestions: [],
|
||||
errorMessage: '',
|
||||
autoComplete: null,
|
||||
};
|
||||
|
||||
UsernameField.propTypes = {
|
||||
usernameSuggestions: PropTypes.arrayOf(string),
|
||||
handleSuggestionClick: PropTypes.func.isRequired,
|
||||
handleUsernameSuggestionClose: PropTypes.func.isRequired,
|
||||
handleChange: PropTypes.func.isRequired,
|
||||
handleFocus: PropTypes.func.isRequired,
|
||||
errorMessage: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
|
||||
707
src/register/tests/EmbeddableRegistrationPage.test.jsx
Normal file
707
src/register/tests/EmbeddableRegistrationPage.test.jsx
Normal file
@@ -0,0 +1,707 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { getConfig, mergeConfig } from '@edx/frontend-platform';
|
||||
import { sendPageEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
configure, getLocale, injectIntl, IntlProvider,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import renderer from 'react-test-renderer';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import {
|
||||
PENDING_STATE,
|
||||
} from '../../data/constants';
|
||||
import {
|
||||
clearUsernameSuggestions,
|
||||
registerNewUser,
|
||||
} from '../data/actions';
|
||||
import {
|
||||
FIELDS,
|
||||
} from '../data/constants';
|
||||
import EmbeddableRegistrationPage from '../EmbeddableRegistrationPage';
|
||||
|
||||
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 IntlEmbedableRegistrationForm = injectIntl(EmbeddableRegistrationPage);
|
||||
const mockStore = configureStore();
|
||||
|
||||
describe('RegistrationPage', () => {
|
||||
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 initialState = {
|
||||
register: {
|
||||
registrationResult: { success: false, redirectUrl: '' },
|
||||
registrationError: {},
|
||||
registrationFormData,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore(initialState);
|
||||
window.parent.postMessage = jest.fn();
|
||||
configure({
|
||||
loggingService: { logError: jest.fn() },
|
||||
config: {
|
||||
ENVIRONMENT: 'production',
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
|
||||
},
|
||||
messages: { 'es-419': {}, de: {}, 'en-us': {} },
|
||||
});
|
||||
props = {
|
||||
registrationResult: jest.fn(),
|
||||
handleInstitutionLogin: jest.fn(),
|
||||
institutionLogin: false,
|
||||
};
|
||||
window.location = { search: '' };
|
||||
});
|
||||
|
||||
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 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(reduxWrapper(<IntlEmbedableRegistrationForm {...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(reduxWrapper(<IntlEmbedableRegistrationForm {...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(reduxWrapper(<IntlEmbedableRegistrationForm {...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(reduxWrapper(<IntlEmbedableRegistrationForm {...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 run validations for focused field on form submission', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('input[name="country"]').simulate('focus');
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(emptyFieldValidation.country);
|
||||
});
|
||||
|
||||
it('should update props with validations returned by registration api', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
register: {
|
||||
...initialState.register,
|
||||
registrationError: {
|
||||
username: [{ userMessage: 'It looks like this username is already taken' }],
|
||||
email: [{ userMessage: `This email is already associated with an existing or previous ${ getConfig().SITE_NAME } account` }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />)).find('EmbeddableRegistrationPage');
|
||||
expect(registrationPage.prop('backendValidations')).toEqual({
|
||||
email: `This email is already associated with an existing or previous ${ getConfig().SITE_NAME } account`,
|
||||
username: 'It looks like this username is already taken',
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove space from the start of username', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('input#username').simulate('change', { target: { value: ' test-user', name: 'username' } });
|
||||
|
||||
expect(registrationPage.find('input#username').prop('value')).toEqual('test-user');
|
||||
});
|
||||
it('should run username and email frontend validations', () => {
|
||||
const payload = {
|
||||
name: 'John Doe',
|
||||
username: 'test@2u.com',
|
||||
email: 'test@yopmail.test',
|
||||
password: 'password1',
|
||||
country: 'Pakistan',
|
||||
honor_code: true,
|
||||
totalRegistrationTime: 0,
|
||||
marketing_emails_opt_in: true,
|
||||
};
|
||||
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
populateRequiredFields(registrationPage, payload);
|
||||
registrationPage.find('input[name="email"]').simulate('focus');
|
||||
registrationPage.find('input[name="email"]').simulate('blur', { target: { value: 'test@yopmail.test', name: 'email' } });
|
||||
expect(registrationPage.find('.email-suggestion__text').exists()).toBeTruthy();
|
||||
|
||||
registrationPage.find('input[name="email"]').simulate('focus');
|
||||
registrationPage.find('input[name="email"]').simulate('blur', { target: { value: 'asasasasas', name: 'email' } });
|
||||
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeTruthy();
|
||||
expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeTruthy();
|
||||
});
|
||||
it('should run email frontend validations when random string is input', () => {
|
||||
const payload = {
|
||||
name: 'John Doe',
|
||||
username: 'testh@2u.com',
|
||||
email: 'as',
|
||||
password: 'password1',
|
||||
country: 'Pakistan',
|
||||
honor_code: true,
|
||||
totalRegistrationTime: 0,
|
||||
marketing_emails_opt_in: true,
|
||||
};
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
populateRequiredFields(registrationPage, payload);
|
||||
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeTruthy();
|
||||
});
|
||||
it('should run frontend validations for name field', () => {
|
||||
const payload = {
|
||||
name: 'https://localhost.com',
|
||||
username: 'test@2u.com',
|
||||
email: 'as',
|
||||
password: 'password1',
|
||||
country: 'Pakistan',
|
||||
honor_code: true,
|
||||
totalRegistrationTime: 0,
|
||||
marketing_emails_opt_in: true,
|
||||
};
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
populateRequiredFields(registrationPage, payload);
|
||||
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
expect(registrationPage.find('div[feedback-for="name"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should run frontend validations for password field', () => {
|
||||
const payload = {
|
||||
name: 'https://localhost.com',
|
||||
username: 'test@2u.com',
|
||||
email: 'as',
|
||||
password: 'as',
|
||||
country: 'Pakistan',
|
||||
honor_code: true,
|
||||
totalRegistrationTime: 0,
|
||||
marketing_emails_opt_in: true,
|
||||
};
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
populateRequiredFields(registrationPage, payload);
|
||||
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
expect(registrationPage.find('div[feedback-for="password"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should click on email suggestion in case suggestion is avialable', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('input[name="email"]').simulate('focus');
|
||||
registrationPage.find('input[name="email"]').simulate('blur', { target: { value: 'test@gmail.co', name: 'email' } });
|
||||
|
||||
registrationPage.find('a.email-suggestion-alert-warning').simulate('click');
|
||||
expect(registrationPage.find('input#email').prop('value')).toEqual('test@gmail.com');
|
||||
});
|
||||
|
||||
it('should remove extra character if username is more than 30 character long', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('input#username').simulate('change', { target: { value: 'why_this_is_not_valid_username_', name: 'username' } });
|
||||
|
||||
expect(registrationPage.find('input#username').prop('value')).toEqual('');
|
||||
});
|
||||
|
||||
// // ******** test field focus in functionality ********
|
||||
|
||||
it('should clear field related error messages on input field Focus', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
expect(registrationPage.find('div[feedback-for="name"]').text()).toEqual(emptyFieldValidation.name);
|
||||
registrationPage.find('input#name').simulate('focus');
|
||||
expect(registrationPage.find('div[feedback-for="name"]').exists()).toBeFalsy();
|
||||
|
||||
expect(registrationPage.find('div[feedback-for="username"]').text()).toEqual(emptyFieldValidation.username);
|
||||
registrationPage.find('input#username').simulate('focus');
|
||||
expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy();
|
||||
|
||||
expect(registrationPage.find('div[feedback-for="email"]').text()).toEqual(emptyFieldValidation.email);
|
||||
registrationPage.find('input#email').simulate('focus');
|
||||
expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeFalsy();
|
||||
|
||||
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();
|
||||
|
||||
expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(emptyFieldValidation.country);
|
||||
registrationPage.find('input[name="country"]').simulate('focus');
|
||||
expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should clear username suggestions when username field is focused in', () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('input#username').simulate('focus');
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
|
||||
});
|
||||
|
||||
it('should call backend api for username suggestions when input the name field', () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
|
||||
registrationPage.find('input#name').simulate('focus');
|
||||
registrationPage.find('input#name').simulate('change', { target: { value: 'ahtesham', name: 'name' } });
|
||||
registrationPage.find('input#name').simulate('blur');
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
// // ******** test form buttons and fields ********
|
||||
|
||||
it('should match default button state', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...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(reduxWrapper(<IntlEmbedableRegistrationForm {...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(reduxWrapper(<IntlEmbedableRegistrationForm {...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(<IntlEmbedableRegistrationForm {...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(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
expect(document.cookie).toMatch(`${getConfig().REGISTER_CONVERSION_COOKIE_NAME}=true`);
|
||||
});
|
||||
|
||||
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'],
|
||||
registrationFormData: {
|
||||
...registrationFormData,
|
||||
errors: {
|
||||
...registrationFormData.errors,
|
||||
username: 'It looks like this username is already taken',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
expect(registrationPage.find('button.username-suggestions--chip').length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should show username suggestions when full name is populated', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
register: {
|
||||
...initialState.register,
|
||||
usernameSuggestions: ['test_1', 'test_12', 'test_123'],
|
||||
registrationFormData: {
|
||||
...registrationFormData,
|
||||
username: ' ',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } });
|
||||
|
||||
expect(registrationPage.find('button.username-suggestions--chip').length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should remove empty space from username field when it is focused', async () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
register: {
|
||||
...initialState.register,
|
||||
usernameSuggestions: ['test_1', 'test_12', 'test_123'],
|
||||
registrationFormData: {
|
||||
...registrationFormData,
|
||||
username: ' ',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } });
|
||||
registrationPage.find('input#username').simulate('focus');
|
||||
await act(async () => { await expect(registrationPage.find('input#username').text()).toEqual(''); });
|
||||
});
|
||||
|
||||
it('should click on username suggestions when full name is populated', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
register: {
|
||||
...initialState.register,
|
||||
usernameSuggestions: ['test_1', 'test_12', 'test_123'],
|
||||
registrationFormData: {
|
||||
...registrationFormData,
|
||||
username: ' ',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } });
|
||||
registrationPage.find('.username-suggestions--chip').first().simulate('click');
|
||||
expect(registrationPage.find('input#username').props().value).toEqual('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'],
|
||||
registrationFormData: {
|
||||
...registrationFormData,
|
||||
username: ' ',
|
||||
},
|
||||
},
|
||||
});
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } });
|
||||
registrationPage.find('button.username-suggestions__close__button').at(0).simulate('click');
|
||||
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
|
||||
});
|
||||
|
||||
// // ******** miscellaneous tests ********
|
||||
|
||||
it('should send page event when register page is rendered', () => {
|
||||
mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register');
|
||||
});
|
||||
|
||||
it('should update state from country code present in redux store', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
register: {
|
||||
...initialState.register,
|
||||
backendCountryCode: 'PK',
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
expect(registrationPage.find('input[name="country"]').props().value).toEqual('Pakistan');
|
||||
});
|
||||
|
||||
it('should set country in component state when form is translated used i18n', () => {
|
||||
getLocale.mockImplementation(() => ('ar-ae'));
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('input[name="country"]').simulate('click');
|
||||
registrationPage.find('button.dropdown-item').at(0).simulate('click', { target: { value: 'أفغانستان ', name: 'countryItem' } });
|
||||
expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should clear the registation validation error on change event on field focused', () => {
|
||||
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 clearBackendError = jest.fn();
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} {...clearBackendError} />));
|
||||
registrationPage.find('input#email').simulate('change', { target: { value: 'a@gmail.com', name: 'email' } });
|
||||
expect(registrationPage.find('div[feedback-for="email"]').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(reduxWrapper(<IntlEmbedableRegistrationForm {...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(reduxWrapper(<IntlEmbedableRegistrationForm {...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(reduxWrapper(<IntlEmbedableRegistrationForm {...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 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(reduxWrapper(<IntlEmbedableRegistrationForm {...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);
|
||||
});
|
||||
|
||||
it('should not run country field validation when onBlur is fired by drop-down arrow icon click', () => {
|
||||
getLocale.mockImplementation(() => ('en-us'));
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlEmbedableRegistrationForm {...props} />));
|
||||
registrationPage.find('input[name="country"]').simulate('blur', {
|
||||
target: { value: '', name: 'country' },
|
||||
relatedTarget: { type: 'button', className: 'btn-icon pgn__form-autosuggest__icon-button' },
|
||||
});
|
||||
expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,9 +55,9 @@ describe('HonorCodeTest', () => {
|
||||
<IntlHonorCode fieldType="tos_and_honor_code" onChangeHandler={changeHandler} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
const expectedMsg = 'By creating an account, you agree to the Terms of Service and Honor Codein a new tab and you '
|
||||
const expectedMsg = 'By creating an account, you agree to the Terms of Service and Honor Code and you '
|
||||
+ 'acknowledge that Your Platform Name Here and each Member process your personal data in '
|
||||
+ 'accordance with the Privacy Policyin a new tab.';
|
||||
+ 'accordance with the Privacy Policy.';
|
||||
const field = HonorCodeProps.find('#honor-code');
|
||||
expect(field.text()).toEqual(expectedMsg);
|
||||
});
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
configure, getLocale, injectIntl, IntlProvider,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { mockNavigate, BrowserRouter as Router } from 'react-router-dom';
|
||||
import renderer from 'react-test-renderer';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
@@ -40,13 +39,27 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
const IntlRegistrationPage = injectIntl(RegistrationPage);
|
||||
const IntlRegistrationFailure = injectIntl(RegistrationFailureMessage);
|
||||
const mockStore = configureStore();
|
||||
const history = createMemoryHistory();
|
||||
|
||||
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('RegistrationPage', () => {
|
||||
mergeConfig({
|
||||
PRIVACY_POLICY: 'https://privacy-policy.com',
|
||||
TOS_AND_HONOR_CODE: 'https://tos-and-honot-code.com',
|
||||
USER_SURVEY_COOKIE_NAME: process.env.USER_SURVEY_COOKIE_NAME,
|
||||
REGISTER_CONVERSION_COOKIE_NAME: process.env.REGISTER_CONVERSION_COOKIE_NAME,
|
||||
});
|
||||
|
||||
@@ -73,6 +86,12 @@ describe('RegistrationPage', () => {
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const routerWrapper = children => (
|
||||
<Router>
|
||||
{children}
|
||||
</Router>
|
||||
);
|
||||
|
||||
const thirdPartyAuthContext = {
|
||||
currentProvider: null,
|
||||
finishAuthUrl: null,
|
||||
@@ -175,7 +194,7 @@ describe('RegistrationPage', () => {
|
||||
};
|
||||
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
populateRequiredFields(registrationPage, payload);
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
|
||||
@@ -205,7 +224,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
|
||||
populateRequiredFields(registrationPage, formPayload, true);
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
@@ -231,7 +250,7 @@ describe('RegistrationPage', () => {
|
||||
};
|
||||
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
populateRequiredFields(registrationPage, payload);
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
|
||||
@@ -244,7 +263,7 @@ describe('RegistrationPage', () => {
|
||||
it('should not dispatch registerNewUser on empty form Submission', () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
expect(store.dispatch).not.toHaveBeenCalledWith(registerNewUser({}));
|
||||
});
|
||||
@@ -252,7 +271,7 @@ describe('RegistrationPage', () => {
|
||||
// ******** test registration form validations ********
|
||||
|
||||
it('should show error messages for required fields on empty form submission', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
expect(registrationPage.find('div[feedback-for="name"]').text()).toEqual(emptyFieldValidation.name);
|
||||
@@ -266,7 +285,7 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
|
||||
it('should update errors for frontend validations', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
|
||||
registrationPage.find('input#name').simulate('blur', { target: { value: 'http://test.com', name: 'name' } });
|
||||
expect(
|
||||
@@ -283,7 +302,7 @@ describe('RegistrationPage', () => {
|
||||
registrationPage.find('div[feedback-for="username"]').text(),
|
||||
).toContain(
|
||||
'Usernames can only contain letters (A-Z, a-z), numerals (0-9),'
|
||||
+ ' underscores (_), and hyphens (-). Usernames cannot contain spaces',
|
||||
+ ' underscores (_), and hyphens (-). Usernames cannot contain spaces',
|
||||
);
|
||||
|
||||
registrationPage.find('input#email').simulate('blur', { target: { value: 'ab', name: 'email' } });
|
||||
@@ -293,7 +312,7 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
|
||||
it('should validate fields on blur event', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
|
||||
registrationPage.find('input#username').simulate('blur', { target: { value: '', name: 'username' } });
|
||||
expect(registrationPage.find('div[feedback-for="username"]').text()).toEqual(emptyFieldValidation.username);
|
||||
@@ -313,7 +332,7 @@ describe('RegistrationPage', () => {
|
||||
|
||||
it('should call validation api on blur event, if frontend validations have passed', () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
|
||||
// Enter a valid name so that frontend validations are passed
|
||||
registrationPage.find('input#name').simulate('change', { target: { value: 'John Doe', name: 'name' } });
|
||||
@@ -331,7 +350,7 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
|
||||
it('should run validations for focused field on form submission', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input[name="country"]').simulate('focus');
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
@@ -340,7 +359,7 @@ describe('RegistrationPage', () => {
|
||||
|
||||
it('should give email suggestions for common service provider domain typos', () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
|
||||
registrationPage.find('input#email').simulate('change', { target: { value: 'john@yopmail.com', name: 'email' } });
|
||||
registrationPage.find('input#email').simulate('blur');
|
||||
@@ -349,7 +368,7 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
it('should click on email suggestions for common service provider domain typos', () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
|
||||
registrationPage.find('input#email').simulate('change', { target: { value: 'john@yopmail.com', name: 'email' } });
|
||||
registrationPage.find('input#email').simulate('blur');
|
||||
@@ -359,7 +378,7 @@ describe('RegistrationPage', () => {
|
||||
|
||||
it('should give error for common top level domain mistakes', () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
|
||||
registrationPage
|
||||
.find('input#email')
|
||||
@@ -381,7 +400,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />)).find('RegistrationPage');
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />))).find('RegistrationPage');
|
||||
expect(registrationPage.prop('backendValidations')).toEqual({
|
||||
email: `This email is already associated with an existing or previous ${ getConfig().SITE_NAME } account`,
|
||||
username: 'It looks like this username is already taken',
|
||||
@@ -389,20 +408,20 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
|
||||
it('should remove space from the start of username', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input#username').simulate('change', { target: { value: ' test-user', name: 'username' } });
|
||||
|
||||
expect(registrationPage.find('input#username').prop('value')).toEqual('test-user');
|
||||
});
|
||||
it('should remove extra character if username is more than 30 character long', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input#username').simulate('change', { target: { value: 'why_this_is_not_valid_username_', name: 'username' } });
|
||||
|
||||
expect(registrationPage.find('input#username').prop('value')).toEqual('');
|
||||
});
|
||||
|
||||
it('should give error with suggestion for common top level domain mistakes', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input#email').simulate('change', { target: { value: 'ahtesham@hotmail', name: 'email' } });
|
||||
registrationPage.find('input#email').simulate('blur');
|
||||
|
||||
@@ -412,7 +431,7 @@ describe('RegistrationPage', () => {
|
||||
|
||||
it('should call backend validation api for password validation', () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input#password').simulate('change', { target: { value: 'aziz194@', name: 'password' } });
|
||||
registrationPage.find('input#password').simulate('blur');
|
||||
|
||||
@@ -423,7 +442,7 @@ describe('RegistrationPage', () => {
|
||||
// ******** test field focus in functionality ********
|
||||
|
||||
it('should clear field related error messages on input field Focus', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
expect(registrationPage.find('div[feedback-for="name"]').text()).toEqual(emptyFieldValidation.name);
|
||||
@@ -450,7 +469,7 @@ describe('RegistrationPage', () => {
|
||||
it('should clear username suggestions when username field is focused in', () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input#username').simulate('focus');
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
|
||||
@@ -473,7 +492,7 @@ describe('RegistrationPage', () => {
|
||||
const expectedMessage = `${'You\'ve successfully signed into Apple! We just need a little more information before '
|
||||
+ 'you start learning with '}${ getConfig().SITE_NAME }.`;
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find('#tpa-alert').find('p').text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
@@ -534,7 +553,7 @@ describe('RegistrationPage', () => {
|
||||
// ******** test form buttons and fields ********
|
||||
|
||||
it('should match default button state', () => {
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find('button[type="submit"] span').first().text()).toEqual('Create an account for free');
|
||||
});
|
||||
|
||||
@@ -547,7 +566,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
const button = registrationPage.find('button[type="submit"] span').first();
|
||||
|
||||
expect(button.find('.sr-only').text()).toEqual('pending');
|
||||
@@ -558,7 +577,7 @@ describe('RegistrationPage', () => {
|
||||
MARKETING_EMAILS_OPT_IN: 'true',
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find('div.form-field--checkbox').length).toEqual(1);
|
||||
|
||||
mergeConfig({
|
||||
@@ -578,7 +597,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find(`button#${ssoProvider.id}`).length).toEqual(1);
|
||||
});
|
||||
|
||||
@@ -600,7 +619,7 @@ describe('RegistrationPage', () => {
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL };
|
||||
|
||||
const root = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const root = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(root.text().includes('Institution/campus credentials')).toBe(true);
|
||||
|
||||
mergeConfig({
|
||||
@@ -620,11 +639,11 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find('input#password').length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should set registration survey cookie', () => {
|
||||
it('should check registration conversion cookie', () => {
|
||||
store = mockStore({
|
||||
...initialState,
|
||||
register: {
|
||||
@@ -635,8 +654,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
renderer.create(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
expect(document.cookie).toMatch(`${getConfig().USER_SURVEY_COOKIE_NAME}=register`);
|
||||
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(document.cookie).toMatch(`${getConfig().REGISTER_CONVERSION_COOKIE_NAME}=true`);
|
||||
});
|
||||
|
||||
@@ -656,7 +674,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find('button.username-suggestions--chip').length).toEqual(3);
|
||||
});
|
||||
|
||||
@@ -673,7 +691,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } });
|
||||
|
||||
expect(registrationPage.find('button.username-suggestions--chip').length).toEqual(3);
|
||||
@@ -692,7 +710,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } });
|
||||
registrationPage.find('.username-suggestions--chip').first().simulate('click');
|
||||
expect(registrationPage.find('input#username').props().value).toEqual('test_1');
|
||||
@@ -712,7 +730,7 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input#name').simulate('change', { target: { value: 'test name', name: 'name' } });
|
||||
registrationPage.find('button.username-suggestions__close__button').at(0).simulate('click');
|
||||
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
|
||||
@@ -732,7 +750,7 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL };
|
||||
renderer.create(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(window.location.href).toBe(dashboardURL);
|
||||
});
|
||||
|
||||
@@ -755,7 +773,7 @@ describe('RegistrationPage', () => {
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL };
|
||||
|
||||
const loginPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const loginPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
|
||||
loginPage.find('button#oa2-apple-id').simulate('click');
|
||||
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + registerUrl);
|
||||
@@ -783,7 +801,7 @@ describe('RegistrationPage', () => {
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL };
|
||||
|
||||
renderer.create(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + authCompleteUrl);
|
||||
});
|
||||
|
||||
@@ -804,7 +822,7 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL };
|
||||
renderer.create(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(window.location.href).toBe(dashboardUrl);
|
||||
});
|
||||
|
||||
@@ -824,7 +842,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
commonComponents: {
|
||||
optionalFields: {
|
||||
extended_profile: {},
|
||||
extended_profile: [],
|
||||
fields: {
|
||||
level_of_education: { name: 'level_of_education', error_message: false },
|
||||
},
|
||||
@@ -833,12 +851,12 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
|
||||
const progressiveProfilingPage = mount(reduxWrapper(
|
||||
<Router history={history}>
|
||||
<Router>
|
||||
<IntlRegistrationPage {...props} />
|
||||
</Router>,
|
||||
));
|
||||
progressiveProfilingPage.update();
|
||||
expect(history.location.pathname).toEqual(AUTHN_PROGRESSIVE_PROFILING);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(AUTHN_PROGRESSIVE_PROFILING);
|
||||
});
|
||||
|
||||
// ******** test hinted third party auth ********
|
||||
@@ -859,7 +877,7 @@ describe('RegistrationPage', () => {
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` };
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find(`button#${ssoProvider.id}`).find('span').text()).toEqual(ssoProvider.name);
|
||||
expect(registrationPage.find(`button#${ssoProvider.id}`).hasClass(`btn-tpa btn-${ssoProvider.id}`)).toEqual(true);
|
||||
});
|
||||
@@ -882,7 +900,7 @@ describe('RegistrationPage', () => {
|
||||
window.location = { href: getConfig().BASE_URL.concat(REGISTER_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` };
|
||||
ssoProvider.iconImage = null;
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find(`button#${ssoProvider.id}`).find('div').find('span').hasClass('pgn__icon')).toEqual(true);
|
||||
});
|
||||
|
||||
@@ -903,7 +921,7 @@ describe('RegistrationPage', () => {
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL.concat(REGISTER_PAGE), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` };
|
||||
|
||||
mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(window.location.href).toEqual(getConfig().LMS_BASE_URL + secondaryProviders.registerUrl);
|
||||
});
|
||||
|
||||
@@ -924,7 +942,7 @@ describe('RegistrationPage', () => {
|
||||
delete window.location;
|
||||
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: '?next=/dashboard&tpa_hint=invalid' };
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find(`button#${ssoProvider.id}`).find('span#provider-name').text()).toEqual(expectedMessage);
|
||||
});
|
||||
|
||||
@@ -940,12 +958,12 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(store.dispatch).toHaveBeenCalledWith(backupRegistrationFormBegin({ ...registrationFormData }));
|
||||
});
|
||||
|
||||
it('should send page event when register page is rendered', () => {
|
||||
mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register');
|
||||
});
|
||||
|
||||
@@ -970,7 +988,7 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />)).find('RegistrationPage');
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />))).find('RegistrationPage');
|
||||
expect(registrationPage.find('input#email').props().value).toEqual('test@example.com');
|
||||
expect(registrationPage.find('input#username').props().value).toEqual('test');
|
||||
expect(store.dispatch).toHaveBeenCalledWith(setUserPipelineDataLoaded(true));
|
||||
@@ -985,7 +1003,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find('input[name="country"]').props().value).toEqual('Pakistan');
|
||||
});
|
||||
|
||||
@@ -1000,7 +1018,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />)).find('RegistrationPage');
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />))).find('RegistrationPage');
|
||||
expect(registrationPage.find('div#validation-errors').first().text()).toContain(
|
||||
'An error has occurred. Try refreshing the page, or check your internet connection.',
|
||||
);
|
||||
@@ -1026,7 +1044,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />)).find('RegistrationPage');
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />))).find('RegistrationPage');
|
||||
|
||||
expect(registrationPage.find('input#name').props().value).toEqual('John Doe');
|
||||
expect(registrationPage.find('input#username').props().value).toEqual('john_doe');
|
||||
@@ -1038,7 +1056,7 @@ describe('RegistrationPage', () => {
|
||||
it('should set country in component state when form is translated used i18n', () => {
|
||||
getLocale.mockImplementation(() => ('ar-ae'));
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input[name="country"]').simulate('click');
|
||||
registrationPage.find('button.dropdown-item').at(0).simulate('click', { target: { value: 'أفغانستان ', name: 'countryItem' } });
|
||||
expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy();
|
||||
@@ -1058,7 +1076,9 @@ describe('RegistrationPage', () => {
|
||||
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const clearBackendError = jest.fn();
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} {...clearBackendError} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(
|
||||
<IntlRegistrationPage {...props} {...clearBackendError} />,
|
||||
)));
|
||||
registrationPage.find('input#email').simulate('change', { target: { value: 'a@gmail.com', name: 'email' } });
|
||||
expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeFalsy();
|
||||
});
|
||||
@@ -1084,7 +1104,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find('#profession').exists()).toBeTruthy();
|
||||
expect(registrationPage.find('#tos').exists()).toBeTruthy();
|
||||
});
|
||||
@@ -1115,7 +1135,7 @@ describe('RegistrationPage', () => {
|
||||
};
|
||||
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
|
||||
populateRequiredFields(registrationPage, payload);
|
||||
registrationPage.find('input#profession').simulate('change', { target: { value: 'Engineer', name: 'profession' } });
|
||||
@@ -1144,7 +1164,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
expect(registrationPage.find('#profession-error').last().text()).toEqual(professionError);
|
||||
@@ -1164,7 +1184,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input#email').simulate('change', { target: { value: 'test1@gmail.com', name: 'email' } });
|
||||
registrationPage.find('input#confirm_email').simulate('blur', { target: { value: 'test2@gmail.com', name: 'confirm_email' } });
|
||||
expect(registrationPage.find('div#confirm_email-error').text()).toEqual('The email addresses do not match.');
|
||||
@@ -1184,7 +1204,7 @@ describe('RegistrationPage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input#profession').simulate('focus', { target: { value: '', name: 'profession' } });
|
||||
registrationPage.find('button.btn-brand').simulate('click');
|
||||
|
||||
@@ -1226,7 +1246,7 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
|
||||
expect(registrationPage.find('input#tos').props().value).toEqual(true);
|
||||
expect(registrationPage.find('input#honor-code').props().value).toEqual(true);
|
||||
@@ -1260,7 +1280,7 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find('#tpa-spinner').exists()).toBeTruthy();
|
||||
expect(registrationPage.find('#registration-form').exists()).toBeFalsy();
|
||||
});
|
||||
@@ -1290,7 +1310,7 @@ describe('RegistrationPage', () => {
|
||||
});
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find('#tpa-spinner').exists()).toBeFalsy();
|
||||
expect(registrationPage.find('#registration-form').exists()).toBeTruthy();
|
||||
});
|
||||
@@ -1320,7 +1340,7 @@ describe('RegistrationPage', () => {
|
||||
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
|
||||
expect(registrationPage.find('div.alert').first().text()).toContain('An error occured');
|
||||
});
|
||||
@@ -1328,7 +1348,7 @@ describe('RegistrationPage', () => {
|
||||
it('should not run country field validation when onBlur is fired by drop-down arrow icon click', () => {
|
||||
getLocale.mockImplementation(() => ('en-us'));
|
||||
|
||||
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
|
||||
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
|
||||
registrationPage.find('input[name="country"]').simulate('blur', {
|
||||
target: { value: '', name: 'country' },
|
||||
relatedTarget: { type: 'button', className: 'btn-icon pgn__form-autosuggest__icon-button' },
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { ChevronLeft } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { resetPassword, validateToken } from './data/actions';
|
||||
import {
|
||||
@@ -24,7 +24,7 @@ import { resetPasswordResultSelector } from './data/selectors';
|
||||
import { validatePassword } from './data/service';
|
||||
import messages from './messages';
|
||||
import ResetPasswordFailure from './ResetPasswordFailure';
|
||||
import { BaseComponent } from '../base-component';
|
||||
import BaseContainer from '../base-container';
|
||||
import { PasswordField } from '../common-components';
|
||||
import {
|
||||
LETTER_REGEX, LOGIN_PAGE, NUMBER_REGEX, RESET_PAGE,
|
||||
@@ -39,7 +39,8 @@ const ResetPasswordPage = (props) => {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [formErrors, setFormErrors] = useState({});
|
||||
const [errorCode, setErrorCode] = useState(null);
|
||||
const [key, setKey] = useState('');
|
||||
const { token } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.status !== TOKEN_STATE.PENDING && props.status !== PASSWORD_RESET_ERROR) {
|
||||
@@ -145,30 +146,26 @@ const ResetPasswordPage = (props) => {
|
||||
);
|
||||
|
||||
if (props.status === TOKEN_STATE.PENDING) {
|
||||
const { token } = props.match.params;
|
||||
if (token) {
|
||||
props.validateToken(token);
|
||||
return <Spinner animation="border" variant="primary" className="spinner--position-centered" />;
|
||||
}
|
||||
} else if (props.status === PASSWORD_RESET_ERROR) {
|
||||
return <Redirect to={updatePathWithQueryParams(RESET_PAGE)} />;
|
||||
navigate(updatePathWithQueryParams(RESET_PAGE));
|
||||
} else if (props.status === 'success') {
|
||||
return <Redirect to={updatePathWithQueryParams(LOGIN_PAGE)} />;
|
||||
navigate(updatePathWithQueryParams(LOGIN_PAGE));
|
||||
} else {
|
||||
return (
|
||||
<BaseComponent>
|
||||
<BaseContainer>
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>
|
||||
{formatMessage(messages['reset.password.page.title'], { siteName: getConfig().SITE_NAME })}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Tabs activeKey="" id="controlled-tab" onSelect={(k) => setKey(k)}>
|
||||
<Tabs activeKey="" id="controlled-tab" onSelect={(key) => navigate(updatePathWithQueryParams(key))}>
|
||||
<Tab title={tabTitle} eventKey={LOGIN_PAGE} />
|
||||
</Tabs>
|
||||
{ key && (
|
||||
<Redirect to={updatePathWithQueryParams(key)} />
|
||||
)}
|
||||
<div id="main-content" className="main-content">
|
||||
<div className="mw-xs">
|
||||
<ResetPasswordFailure errorCode={errorCode} errorMsg={props.errorMsg} />
|
||||
@@ -211,7 +208,7 @@ const ResetPasswordPage = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseComponent>
|
||||
</BaseContainer>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -220,7 +217,6 @@ const ResetPasswordPage = (props) => {
|
||||
ResetPasswordPage.defaultProps = {
|
||||
status: null,
|
||||
token: null,
|
||||
match: null,
|
||||
errorMsg: null,
|
||||
};
|
||||
|
||||
@@ -228,11 +224,6 @@ ResetPasswordPage.propTypes = {
|
||||
resetPassword: PropTypes.func.isRequired,
|
||||
validateToken: PropTypes.func.isRequired,
|
||||
token: PropTypes.string,
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
token: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
status: PropTypes.string,
|
||||
errorMsg: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -3,23 +3,29 @@ import { Provider } from 'react-redux';
|
||||
|
||||
import { configure, injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter, Router } from 'react-router-dom';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import { LOGIN_PAGE } from '../../data/constants';
|
||||
import { LOGIN_PAGE, RESET_PAGE } from '../../data/constants';
|
||||
import { resetPassword, validateToken } from '../data/actions';
|
||||
import {
|
||||
PASSWORD_RESET, PASSWORD_RESET_ERROR, SUCCESS, TOKEN_STATE,
|
||||
} from '../data/constants';
|
||||
import ResetPasswordPage from '../ResetPasswordPage';
|
||||
|
||||
const mockedNavigator = jest.fn();
|
||||
const token = '1c-bmjdkc-5e60e084cf8113048ca7';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...(jest.requireActual('react-router-dom')),
|
||||
useNavigate: () => mockedNavigator,
|
||||
useParams: jest.fn().mockReturnValue({ token }),
|
||||
}));
|
||||
|
||||
const IntlResetPasswordPage = injectIntl(ResetPasswordPage);
|
||||
const mockStore = configureStore();
|
||||
const history = createMemoryHistory();
|
||||
|
||||
describe('ResetPasswordPage', () => {
|
||||
let props = {};
|
||||
@@ -188,36 +194,24 @@ describe('ResetPasswordPage', () => {
|
||||
props = {
|
||||
status:
|
||||
TOKEN_STATE.PENDING,
|
||||
match: {
|
||||
params: { token: '1c-bmjdkc-5e60e084cf8113048ca7' },
|
||||
},
|
||||
};
|
||||
mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
|
||||
expect(store.dispatch).toHaveBeenCalledWith(validateToken(props.match.params.token));
|
||||
expect(store.dispatch).toHaveBeenCalledWith(validateToken(token));
|
||||
});
|
||||
it('should redirect the user to Reset password email screen ', async () => {
|
||||
props = {
|
||||
status:
|
||||
PASSWORD_RESET_ERROR,
|
||||
};
|
||||
mount(reduxWrapper(
|
||||
<Router history={history}>
|
||||
<IntlResetPasswordPage {...props} />
|
||||
</Router>,
|
||||
|
||||
));
|
||||
expect(history.location.pathname).toEqual('/reset');
|
||||
mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
|
||||
expect(mockedNavigator).toHaveBeenCalledWith(RESET_PAGE);
|
||||
});
|
||||
it('should redirect the user to root url of the application ', async () => {
|
||||
props = {
|
||||
status: SUCCESS,
|
||||
};
|
||||
mount(reduxWrapper(
|
||||
<Router history={history}>
|
||||
<IntlResetPasswordPage {...props} />
|
||||
</Router>,
|
||||
));
|
||||
expect(history.location.pathname).toEqual('/login');
|
||||
mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
|
||||
expect(mockedNavigator).toHaveBeenCalledWith(LOGIN_PAGE);
|
||||
});
|
||||
|
||||
it('show spinner during token validation', () => {
|
||||
@@ -228,15 +222,11 @@ describe('ResetPasswordPage', () => {
|
||||
// ******** redirection tests ********
|
||||
|
||||
it('by clicking on sign in tab should redirect onto login page', async () => {
|
||||
const resetPasswordPage = mount(reduxWrapper(
|
||||
<Router history={history}>
|
||||
<IntlResetPasswordPage {...props} />
|
||||
</Router>,
|
||||
));
|
||||
const resetPasswordPage = mount(reduxWrapper(<IntlResetPasswordPage {...props} />));
|
||||
|
||||
await act(async () => { await resetPasswordPage.find('nav').find('a').first().simulate('click'); });
|
||||
|
||||
resetPasswordPage.update();
|
||||
expect(history.location.pathname).toEqual(LOGIN_PAGE);
|
||||
expect(mockedNavigator).toHaveBeenCalledWith(LOGIN_PAGE);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user