Compare commits

...

81 Commits

Author SHA1 Message Date
Syed Sajjad Hussain Shah
e57994bd85 refactor: move fields specific code to their wrappers 2023-08-25 09:48:21 +05:00
Blue
aeec576d8c fix: apply frontend validations (#1041)
Description:
Applied frontend validations on all the fields
VAN-1614
2023-08-24 15:24:00 +05:00
Blue
90db7ba1d8 fix: embedded registration form (#1026)
Description:
Adding embedded registration form
VAN-1574
2023-08-21 12:08:06 +05:00
renovate[bot]
d8b5653224 chore(deps): update dependency @edx/frontend-build to v12.9.8 2023-08-15 22:16:13 +00:00
renovate[bot]
4cc4ff6c4b fix(deps): update react-router monorepo to v6.15.0 2023-08-15 20:09:53 +00:00
renovate[bot]
48a3c57e5f fix(deps): update dependency @edx/paragon to v20.46.2 2023-08-15 15:29:28 +00:00
Syed Ali Abbas Zaidi
efdefc300e feat: upgrade react router to v6 (#936) 2023-08-15 17:23:34 +05:00
Syed Sajjad Hussain Shah
9730a4f55d Revert "fix: special characters in redirect url getting decoded to space (#1029)" (#1030)
This reverts commit fc62241332.
2023-08-15 15:45:31 +05:00
Syed Sajjad Hussain Shah
fc62241332 fix: special characters in redirect url getting decoded to space (#1029) 2023-08-15 13:24:27 +05:00
renovate[bot]
0846001b6d fix(deps): update dependency regenerator-runtime to v0.14.0 2023-08-08 01:57:57 +00:00
renovate[bot]
90658722e1 fix(deps): update dependency @edx/paragon to v20.46.0 2023-08-04 23:08:50 +00:00
renovate[bot]
240752c6cd fix(deps): update dependency @edx/paragon to v20.45.7 2023-08-04 04:27:28 +00:00
renovate[bot]
429d4547e4 fix(deps): update font awesome to v6.4.2 2023-08-03 08:52:23 +00:00
renovate[bot]
e278b5f74a chore(deps): update dependency @edx/frontend-build to v12.9.4 2023-08-02 19:32:04 +00:00
renovate[bot]
a723058bc1 fix(deps): update dependency @edx/paragon to v20.45.6 2023-08-01 16:20:29 +00:00
Shahbaz Shabbir
59fa7d5de3 fix: update username suggestions placement 2023-08-01 19:37:07 +05:00
renovate[bot]
60578189bd fix(deps): update dependency @edx/frontend-platform to v4.6.1 2023-08-01 14:15:25 +00:00
Syed Sajjad Hussain Shah
82cd11e01e refactor: refactor recs dir (#1012) 2023-07-31 16:36:47 +05:00
Blue
4a10540d4a fix: redirection issue fix (#1011)
Description:
Fixed redirection issue
VAN-1573
2023-07-31 15:29:05 +05:00
Blue
aeda262fb0 fix: add location resitriction for popular and trending courses (#1010)
Description:
Filter courses on the basis of location

VAN-1573
2023-07-31 14:47:22 +05:00
Mubbshar Anwar
dff3903617 fix: track event fire multiple times (#1009)
The recommendations group event was firing multiple times due to useEffect dependency because registrationResult is setting in above two useEffects.

VAN-1569
2023-07-31 13:00:53 +05:00
renovate[bot]
1399caf003 chore(deps): update dependency eslint-plugin-import to v2.28.0 2023-07-28 10:31:50 +00:00
renovate[bot]
2dfb6bc528 fix(deps): update dependency core-js to v3.32.0 2023-07-28 01:38:08 +00:00
Mubbshar Anwar
a392395876 feat: Add segments events (#1001)
Add segments events to track popular and trending recommendations stats.

VAN-1569

Co-authored-by: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com>
2023-07-27 17:04:08 +05:00
renovate[bot]
5542311c95 chore(deps): update dependency jest to v29.6.2 (#1004)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-27 15:13:30 +05:00
Zainab Amir
21e6bb6eec feat: update static recommendation placement (#1002)
* feat: update static recommendation placement
* refactor: clean recommendation code (#1003)

---------

Co-authored-by: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com>
2023-07-27 15:04:56 +05:00
Shahbaz Shabbir
bfa7874108 fix: add popular and trending products lists (#1000) 2023-07-27 10:54:57 +05:00
mubbsharanwar
423958c899 feat: setup optimizely experiment
Setup optimizely experiment for popular and trending recommendations for post registration experience

VAN-1566
2023-07-26 18:30:38 +05:00
Blue
cb380a2031 feat: design change for recommendations (#997)
Description:

Design change for recommendations in which covered all the card type

VAN-1564
2023-07-26 10:49:47 +05:00
renovate[bot]
f4e89efdb4 chore(deps): update dependency @edx/frontend-build to v12.9.3 2023-07-25 12:57:49 +00:00
Mubbshar Anwar
f5cb7a1dbd feat: param based redirection (#993)
On welcome page CTA click redirection would be based on next query param.

VAN-1535
2023-07-25 16:03:41 +05:00
Syed Sajjad Hussain Shah
72e601948c fix: fix accessibility issues in register embedded experience (#996)
VAN-1541, VAN-1551, VAN-1543
2023-07-24 16:18:30 +05:00
renovate[bot]
29e30981ae fix(deps): update dependency @edx/paragon to v20.45.5 2023-07-21 17:03:18 +00:00
Zainab Amir
06a61e6a22 feat: add user retention cookie (#992) 2023-07-21 11:16:14 +05:00
renovate[bot]
1c83020b43 fix(deps): update dependency algoliasearch to v4.19.1 2023-07-20 16:50:53 +00:00
Mubbshar Anwar
fa4a0ac2d5 feat: param based cta and redirection (#984)
Register CTA label and authn redirection would be based on query params.

VAN-1535
2023-07-20 11:43:32 +05:00
renovate[bot]
2addf57cbd chore(deps): update dependency @edx/frontend-build to v12.9.2 2023-07-19 21:50:55 +00:00
renovate[bot]
d521fd20ec fix(deps): update dependency @edx/paragon to v20.45.4 2023-07-19 15:32:23 +00:00
renovate[bot]
38d44ac586 fix(deps): update dependency algoliasearch to v4.19.0 2023-07-18 22:47:36 +00:00
renovate[bot]
4768306f53 chore(deps): update dependency @edx/frontend-build to v12.9.1 2023-07-18 18:39:05 +00:00
renovate[bot]
6c6b527dfc fix(deps): update dependency @edx/paragon to v20.45.3 2023-07-18 12:39:08 +00:00
renovate[bot]
e14c9bd1b7 chore(deps): update dependency @edx/frontend-build to v12.8.67 2023-07-18 07:02:32 +00:00
dependabot[bot]
a2bdc4031b chore(deps): bump semver from 5.7.1 to 5.7.2 (#981)
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-17 19:04:57 +05:00
renovate[bot]
8f38eb9e3a fix(deps): update dependency @edx/paragon to v20.45.2 2023-07-17 13:38:21 +00:00
renovate[bot]
c22aa58904 chore(deps): update dependency @edx/frontend-build to v12.8.66 2023-07-17 10:59:27 +00:00
edx-transifex-bot
067bddf892 chore(i18n): update translations (#978)
* chore(i18n): update translations
* feat: add base container tests (#979)

---------

Co-authored-by: Jenkins <sre+jenkins@edx.org>
Co-authored-by: Zainab Amir <zainab.amir@arbisoft.com>
2023-07-17 13:50:14 +05:00
Syed Sajjad Hussain Shah
7e4bccbc29 fix: fix rebrand experiment issue (#976) 2023-07-14 19:14:58 +05:00
renovate[bot]
f39bb35dc8 chore(deps): update dependency @edx/frontend-build to v12.8.64 2023-07-14 03:27:26 +00:00
renovate[bot]
0d760c04b7 chore(deps): update dependency @edx/frontend-build to v12.8.63 2023-07-13 00:22:45 +00:00
renovate[bot]
6f113542f5 chore(deps): update dependency @edx/frontend-build to v12.8.62 2023-07-12 11:26:38 +00:00
renovate[bot]
1e4c342703 chore(deps): update dependency @edx/frontend-build to v12.8.61 2023-07-11 15:33:06 +00:00
renovate[bot]
3ce0585d7e fix(deps): update dependency core-js to v3.31.1 2023-07-11 06:59:31 +00:00
renovate[bot]
5bf6dd6361 fix(deps): update dependency algoliasearch to v4.18.0 2023-07-11 03:33:41 +00:00
renovate[bot]
929abdff69 chore(deps): update dependency jest to v29.6.1 2023-07-11 00:57:30 +00:00
renovate[bot]
f295d69e76 fix(deps): update dependency react-loading-skeleton to v3.3.1 2023-07-10 21:54:41 +00:00
renovate[bot]
32a4c55e4a chore(deps): update dependency babel-plugin-formatjs to v10.5.3 2023-07-10 20:32:20 +00:00
renovate[bot]
615ba91bdb chore(deps): update dependency @edx/frontend-build to v12.8.60 2023-07-10 16:39:31 +00:00
renovate[bot]
dfb2f89a36 fix(deps): update dependency @edx/paragon to v20.45.0 2023-07-10 13:44:30 +00:00
Mashal Malik
c9783234cc feat: update react & react-dom to v17 (#938)
* feat: update react & react-dom to v17

* chore: add enzyme adapter pkg to devDep

* refactor: updated paragon & snapshots

* build: update frontend-platform

---------

Co-authored-by: Bilal Qamar <59555732+BilalQamar95@users.noreply.github.com>
2023-07-10 15:48:14 +05:00
Zainab Amir
0513e6c2de chore: add configs to env file (#962) 2023-06-28 23:40:41 +05:00
Zainab Amir
3cdc0234ef fix: height of image for larger layout (#961) 2023-06-28 20:06:57 +05:00
Zainab Amir
e06d12be07 feat: add optimizely web to authn (#960) 2023-06-28 16:52:25 +05:00
Zainab Amir
56394881fc feat: Add image layout for base container (#959)
- Added image layout base container
- Added config for optimizely experiment
2023-06-28 16:05:26 +05:00
Zainab Amir
61056240c4 refactor: base component name and folder structure (#958) 2023-06-27 14:17:04 +05:00
Zainab Amir
a59e7c548c Revert "feat: hide privacy banner for embedded form (#954)" (#955)
This reverts commit f63d7674e2.
2023-06-23 10:20:44 +05:00
Zainab Amir
f63d7674e2 feat: hide privacy banner for embedded form (#954) 2023-06-22 15:14:18 +05:00
Zainab Amir
fa3a70e9a9 Revert "feat: fire identify call and register event" (#953)
* Revert "feat: fire identify call and register event (#951)
2023-06-22 11:59:22 +05:00
Syed Sajjad Hussain Shah
b817a8d122 fix: hide zendesk widget for embedded experience (#952) 2023-06-21 17:39:07 +05:00
Syed Sajjad Hussain Shah
2a6668cef3 feat: fire identify call and register event (#951)
VAN-1499
2023-06-21 15:36:58 +05:00
Zainab Amir
a802821ae9 feat: keep username suggestion for embedded experience (#950)
* feat: keep username suggestion for embedded experience
2023-06-21 10:30:26 +05:00
Syed Sajjad Hussain Shah
9e13141f6b fix: render embedded experience through pathname (#949) 2023-06-19 13:44:30 +05:00
Blue
4b64ce2534 fix: make host param compulsory (#948)
Description

Make host param compulsory and host should grab from query params when calling postMessage api

VAN-1485
2023-06-16 14:44:34 +05:00
Blue
c550069e11 fix: avoid validations on blur event (#946)
Description:

When authn is embedded in iframe then validations should not runs on blur event it should only run when button is clicked.

VAN-1481
2023-06-16 10:33:59 +05:00
Mubbshar Anwar
1e10e9c89c feat: registration conversion events (#942)
Passing variant property to registration and progressive profiling events to calculate on ramp conversion rate.

VAN-1478
2023-06-16 09:42:04 +05:00
Zainab Amir
cd6c1c0e42 feat: update welcome page logic (#947) 2023-06-15 17:09:52 +05:00
Zainab Amir
5edcee9eb9 feat: allow welcome page to load for embedded experience (#944) 2023-06-15 15:46:34 +05:00
Syed Sajjad Hussain Shah
d41c06b1fd feat: allow embedded experience to open register page for authenticated users (#945)
VAN-1482
2023-06-15 14:42:09 +05:00
Blue
2a2c5abc81 fix: redirect user to welcome page (#939) (#941)
Description
Redirect the user to welcome page after registration, as Authn is embedded in iframe which is located in Hubspot so we call window.postMessage function from Authn which let the Hubspot knows about redirect URL and then reload the welcome page in parent window.

VAN-1474
2023-06-15 12:29:04 +05:00
Syed Sajjad Hussain Shah
ccdd648603 feat: add iframe resizer script (#943)
VAN-1475
2023-06-14 14:43:09 +05:00
Blue
5c1ea04970 fix: change registration form for onramp (#939)
Description:
In this we have changed the regirstation form on the basis of embed query param and hide the multiple things

VAN-1476
2023-06-13 16:10:39 +05:00
Attiya Ishaque
5ebd22f088 fix: remove survery cookie (#937) 2023-06-08 18:56:00 +05:00
105 changed files with 7430 additions and 7288 deletions

10
.env
View File

@@ -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=''

View File

@@ -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=''

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -1,2 +0,0 @@
/* eslint-disable import/prefer-default-export */
export { default as BaseComponent } from './BaseComponent';

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
export { default as DefaultLargeLayout } from './LargeLayout';
export { default as DefaultMediumLayout } from './MediumLayout';
export { default as DefaultSmallLayout } from './SmallLayout';

View 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;

View 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 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;

View 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;

View 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;

View 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;

View 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';

View 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;
}
}

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
export { default as AuthLargeLayout } from './LargeLayout';
export { default as AuthMediumLayout } from './MediumLayout';
export { default as AuthSmallLayout } from './SmallLayout';

View File

@@ -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;

View File

@@ -0,0 +1,4 @@
const IMAGE_LAYOUT = 'image-layout';
const DEFAULT_LAYOUT = 'default-layout';
export { DEFAULT_LAYOUT, IMAGE_LAYOUT };

View 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;

View 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();
});
});

View 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;

View File

@@ -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
/>
);
}

View File

@@ -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;

View File

@@ -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} />
);

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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');
});
});

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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,

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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;
};

View File

@@ -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';

View File

@@ -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>
);
};

View File

@@ -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);
});
});

View File

@@ -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": "التسجيل",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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&#39;elenco sottostante",
"logistration.sign.in": "Accedi",
"logistration.register": "Registrazione",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "由于请求过多而发生错误。请稍后重试。"
}

View File

@@ -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"

View File

@@ -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 (
<>

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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>
);
};

View File

@@ -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', () => ({

View File

@@ -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);

View File

@@ -6,7 +6,6 @@ import {
export const defaultState = {
extendedProfile: [],
fieldDescriptions: {},
formRenderState: DEFAULT_STATE,
success: false,
submitState: DEFAULT_STATE,
showError: false,

View File

@@ -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,
}),
);

View File

@@ -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';

View File

@@ -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);
});
});
});

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -9,3 +9,6 @@ export const EDUCATION_LEVEL_MAPPING = {
hs: 'Introductory',
jhs: 'Introductory',
};
export const POPULAR = 'popular';
export const TRENDING = 'trending';

View File

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

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',

View 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');
});
});

View File

@@ -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');
});
});

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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;

View File

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

View File

@@ -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,

View File

@@ -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);

View File

@@ -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';

View File

@@ -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',

View File

@@ -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>
),

View File

@@ -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,

View 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();
});
});
});

View File

@@ -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);
});

View File

@@ -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' },

View File

@@ -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,
};

View File

@@ -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