Compare commits

..

62 Commits

Author SHA1 Message Date
Attiya Ishaque
e29a461a34 fix: change for testing sandbox purpose 2024-03-26 19:00:40 +05:00
Attiya Ishaque
9276f25200 feat: remove username from the registration from (#1201) 2024-03-20 15:21:25 +05:00
Dmytro
dc90cf9ce5 fix: Registration with password that doesn't meet the requirements (#1184)
* fix: Registration with password that doesn't meet the requirements
---------

Co-authored-by: Dima Alipov <dimaalipov@MacBook-Pro-Dima.local>
Co-authored-by: Syed Sajjad  Hussain Shah <ssajjad@2u.com>
2024-03-15 11:53:42 +05:00
Attiya Ishaque
36354761cc feat: Remove simplify registration experiment code (#1194) 2024-03-15 10:58:54 +05:00
renovate[bot]
f53add81f3 fix(deps): update react-router monorepo to v6.22.3 2024-03-08 12:18:55 +00:00
renovate[bot]
bca59ebd40 fix(deps): update dependency algoliasearch-helper to v3.16.3 2024-03-08 10:45:07 +00:00
renovate[bot]
dcb5da42b0 fix(deps): update dependency @edx/frontend-platform to v7.1.1 2024-03-08 06:51:41 +00:00
renovate[bot]
b346c22b57 chore(deps): update codecov/codecov-action action to v4 (#1172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-07 19:28:01 -08:00
Zainab Amir
8be350e35f Fix TPA skeleton loader (#1189)
* feat: update TPA skeleton
* fix: Prevent wrong appearance of skeleton after second tab click

---------

Co-authored-by: Stanislav Lunyachek <lunyachek@MacBook-Pro-M1.local>
2024-03-06 06:11:54 -08:00
Syed Sajjad Hussain Shah
6695fb6f61 fix: move country field to simplify registration second step (#1195) 2024-03-06 12:21:56 +05:00
Syed Sajjad Hussain Shah
be5b0bb461 fix: invalid form submit btn event error (#1191) 2024-03-05 14:57:10 +05:00
Syed Sajjad Hussain Shah
5f93278326 feat: add invalid form submit click event: (#1190) 2024-03-05 13:13:30 +05:00
Blue
488644f50d fix: remove rebrand experiment from authn (#1187)
Description:
Remove rebrand experiment code from Authn
VAN-1858
2024-03-04 16:14:41 +05:00
Hina Khadim
56bab26018 fix: browser header showing null and replace authn with Authentication (#1185) 2024-03-04 00:53:26 -08:00
Attiya Ishaque
ca42f3851d fix: Fix post registration recommendations card issue (#1183) 2024-03-01 15:25:56 +05:00
Syed Sajjad Hussain Shah
e4bddc2db0 fix: remove password field duplicate validation (#1181) 2024-02-29 17:47:13 +05:00
Syed Sajjad Hussain Shah
8aeacaa001 fix: fix error banner alert (#1180) 2024-02-29 12:14:52 +05:00
Syed Sajjad Hussain Shah
80435d3e5b fix: change submit cta text for experiment (#1179) 2024-02-29 11:25:43 +05:00
Syed Sajjad Hussain Shah
d6c5415c9a feat: add submit btn click event (#1178) 2024-02-28 11:38:07 +05:00
Zainab Amir
0306763eeb feat: update code owner information (#1177) 2024-02-27 06:40:48 -08:00
Zainab Amir
e4ac1288a9 feat: add submit btn click event for default register page (#1176)
Co-authored-by: Syed Sajjad  Hussain Shah <ssajjad@2u.com>
2024-02-27 01:24:52 -08:00
Attiya Ishaque
e1f489838c fix: browser header showing null (#1168) 2024-02-27 12:52:25 +05:00
Syed Sajjad Hussain Shah
9b046146a0 fix: fix simplify registration experiment bugs (#1175) 2024-02-27 10:12:16 +05:00
Syed Sajjad Hussain Shah
e0d605582e fix: fix simplify registration experiment bugs (#1174) 2024-02-26 18:54:16 +05:00
Blue
21b5a01cab fix: Update custom SSO buttons to use brand colors (#1165)
Description: Update custom SSO buttons to use brand colors
VAN-1838
2024-02-26 18:51:12 +05:00
Syed Sajjad Hussain Shah
955ea6485f fix: update comment (#1173) 2024-02-26 17:50:19 +05:00
Syed Sajjad Hussain Shah
9f8a1af7e3 feat: simplify registration experiment (#1164) 2024-02-26 17:22:24 +05:00
renovate[bot]
e617a3ba40 fix(deps): update react-router monorepo to v6.22.1 2024-02-26 12:16:52 +00:00
renovate[bot]
fb3f962039 fix(deps): update dependency react-loading-skeleton to v3.4.0 2024-02-26 11:25:17 +00:00
renovate[bot]
64da54f17c fix(deps): update dependency core-js to v3.36.0 2024-02-26 11:15:39 +00:00
renovate[bot]
74741a1be6 fix(deps): update dependency @edx/frontend-platform to v7.1.0 2024-02-26 10:46:33 +00:00
Stanislav
e9aaf7024a fix: Enabling the ability to log in with a username consisting of 2 characters (#1073)
Co-authored-by: Stanislav Lunyachek <lunyachek@MacBook-Pro-M1.local>
2024-02-15 15:07:24 +05:00
Blue
e3d96385ee fix: remove username and bring in name field for embedded case (#1163)
Description:
Remove username and bring in name field in case embedded registration
VAN-1824
2024-02-14 16:39:31 +05:00
Blue
dc266a613e fix: replace username with name in base component and on welcome page (#1161)
* fix: replace username with fullName in base component and on welcome page
Description: Replace username with name in welcome page component
VAN-1824
2024-02-14 12:12:43 +05:00
Syed Sajjad Hussain Shah
1b5755664c chore: upgrade paragon to v22.1.1 (#1162) 2024-02-14 11:15:26 +05:00
Zainab Amir
02bd8abcd1 feat: update default authn settings (#1160)
At present, Authn MFE doesn't work out of the box with
devstack settings. These changes allow authn to work with
default devstack settings.
2024-02-13 00:54:26 -08:00
Blue
a6e96f5ed1 fix: update paragone and country field (#1157)
Description: Update Paragon version and changed the country field implementation as per documentation
VAN-1814
2024-02-09 16:40:41 +05:00
Attiya Ishaque
872aa48675 feat: modifying the base container in authn (#1153) 2024-02-09 16:15:22 +05:00
Blue
60efe3cbb7 feat: removal of country code selection event (#1129)
Revert country code selection event

VAN-1793
Co-authored-by: Zainab Amir <zainab.amir@arbisoft.com>
2024-02-07 06:09:48 -08:00
Muhammad Abdullah Waheed
167f86c283 fix: updated frontend-build to latest version to fix sharp issue (#1154) 2024-02-06 14:50:41 +05:00
Blue
02d14a6359 fix: country field is not populated on page load (#1151)
Description: Country field is not populated when page load
VAN-1795
2024-02-01 11:20:34 +05:00
Omar Al-Ithawi
a6a473ee5c feat: tutor-mfe compatiblilty for atlas pull (#1152)
- install atlas
 - remove `--filter` to pull all languages by default
 - use ATLAS_OPTIONS to allow custom `--filter`
 - include frontend-platform in `atlas pull`

Refs: FC-0012 OEP-58
2024-01-30 13:05:02 -05:00
renovate[bot]
8be469680d fix(deps): update react-router monorepo to v6.21.3 2024-01-26 11:41:55 +00:00
renovate[bot]
45e84d3f9c fix(deps): update font awesome to v6.5.1 2024-01-26 09:39:53 +00:00
renovate[bot]
d6d71587c7 chore(deps): update dependency babel-plugin-formatjs to v10.5.13 2024-01-26 08:46:38 +00:00
renovate[bot]
6b70692dd4 fix(deps): update dependency redux-saga to v1.3.0 2024-01-26 04:43:47 +00:00
renovate[bot]
a056f241b5 fix(deps): update dependency core-js to v3.35.1 2024-01-26 00:44:08 +00:00
renovate[bot]
115ce8d7c6 fix(deps): update dependency classnames to v2.5.1 2024-01-25 22:09:32 +00:00
renovate[bot]
6e58c13ef5 fix(deps): update dependency @redux-devtools/extension to v3.3.0 2024-01-25 18:00:44 +00:00
renovate[bot]
65e29a021b chore(deps): update dependency jest to v29.7.0 2024-01-25 14:48:39 +00:00
renovate[bot]
6c91f01226 chore(deps): update dependency eslint-plugin-import to v2.29.1 2024-01-25 10:34:20 +00:00
renovate[bot]
36a9ebef8c fix(deps): update dependency regenerator-runtime to v0.14.1 2024-01-25 06:02:01 +00:00
renovate[bot]
5c921fb983 fix(deps): update dependency form-urlencoded to v6.1.4 2024-01-25 04:14:27 +00:00
renovate[bot]
98699b08ad chore(deps): update dependency babel-plugin-formatjs to v10.5.12 2024-01-25 00:39:42 +00:00
renovate[bot]
1f3d1d1aee fix(deps): update dependency algoliasearch-helper to v3.16.2 2024-01-24 22:48:51 +00:00
Brian Smith
fc60d9f7d1 chore(deps): update paragon and frontend-build to openedx scope (#1132) 2024-01-24 10:10:13 -05:00
Zainab Amir
ad7099ad38 Refactor login page to use functional component (#899)
* refactor: zamir/van 1390/add basic login form (#894)
Description: login refactor

* feat: add basic login form
* feat: remove cookie logic from login page

* refactor: refactor social auth, tpahint and institution login (#895)

* feat: add basic login form

* feat: add error handling

* refactor: refactor social auth, tpahint and institution login

Description:

Refactor the following flows:
1 - Institution login
2 - SSO login
3 - Tpahint

VAN-1391

---------

Co-authored-by: Zainab Amir <zainab.amir@arbisoft.com>

* fix: tests and lint issues (#905)

* feat: add tests

* fix: rebase this branch with master
Description:
Rebase with master branch
VAN-1413

* chore: update variable name

* fix: fix When using tpa-hint don't show the login form
Description: When using tpa-hint don't show the login form
VAN-1801

---------

Co-authored-by: Blue <ahtesham-quraish@users.noreply.github.com>
Co-authored-by: ahtesham-quraish <ahtesham.quraish@gmail.com>
2024-01-24 10:44:31 +05:00
mubbsharanwar
2ea9301c5e refactor: depreciate enzyme
move unit test from enzyme to RTL

VAN-1792
2024-01-17 12:37:01 +05:00
Blue
b9b4492de9 feat: check how many users update country on registration form after we auto populate it with IP (#1127)
Description: Check how many users update country on registration form after we auto populate it with IP.
VAN-1793
2024-01-16 12:35:54 +05:00
Attiya Ishaque
d74b5c49d9 feat: add work experience property to the welcome page event (#1125) 2024-01-11 16:00:43 +05:00
Syed Sajjad Hussain Shah
27545ea4b6 refactor: move login unit tests to RTL (#1123)
fix: migrate login tests to RTL
Description:
Migrate login unit tests to RTL
VAN-1770

Co-authored-by: ahtesham-quraish <ahtesham.quraish@gmail.com>
2024-01-05 17:16:31 +05:00
Attiya Ishaque
db3655c843 fix: Migrate Registration tests to RTL (#1122) 2024-01-04 14:33:48 +05:00
86 changed files with 7211 additions and 9130 deletions

2
.env
View File

@@ -23,11 +23,13 @@ POST_REGISTRATION_REDIRECT_URL=''
SEARCH_CATALOG_URL=''
# ***** Features flags *****
DISABLE_ENTERPRISE_LOGIN=''
ENABLE_AUTO_GENERATED_USERNAME=''
ENABLE_DYNAMIC_REGISTRATION_FIELDS=''
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN=''
ENABLE_POST_REGISTRATION_RECOMMENDATIONS=''
MARKETING_EMAILS_OPT_IN=''
SHOW_CONFIGURABLE_EDX_FIELDS=''
ENABLE_IMAGE_LAYOUT=''
# ***** Zendesk related keys *****
ZENDESK_KEY=''
ZENDESK_LOGO_URL=''

View File

@@ -19,6 +19,9 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME='Your Platform Name Here'
INFO_EMAIL='info@example.com'
# ***** Features *****
ENABLE_DYNAMIC_REGISTRATION_FIELDS='true'
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN='true'
# ***** Cookies *****
SESSION_COOKIE_DOMAIN='localhost'
USER_INFO_COOKIE_NAME='edx-user-info'

View File

@@ -25,5 +25,5 @@ Include a link to the sandbox for design changes or screenshot for before and af
#### Post-merge Checklist
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/vanguards** to do it.
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/2u-vanguards** to do it.
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.

View File

@@ -41,4 +41,4 @@ jobs:
run: npm run build
- name: Run Code Coverage
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4

View File

@@ -1,2 +1,2 @@
# The following users are the owners of all frontend-app-authn files
* @openedx/vanguards
* @openedx/2u-vanguards

View File

@@ -53,11 +53,12 @@ pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
&& atlas pull $(ATLAS_OPTIONS) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-platform/src/i18n/messages:frontend-platform \
translations/frontend-app-authn/src/i18n/messages:frontend-app-authn
$(intl_imports) paragon frontend-app-authn
$(intl_imports) paragon frontend-platform frontend-app-authn
endif
# This target is used by Travis.

View File

@@ -115,6 +115,10 @@ The authentication micro-frontend also requires the following additional variabl
- Name of MFE, this will be used by the API to get runtime configurations for the specific micro frontend. For a frontend repo `frontend-app-appName`, use `appName` as APP_ID.
- ``authn`` | ``''``
* - ``ENABLE_IMAGE_LAYOUT``
- Enables the image layout feature within the authn. When set to True, this feature allows the inclusion of images in the base container layout. For more details on configuring this feature, please refer to the `Modifying base container <docs/how_tos/modifying_base_container.rst>`_.
- ``true`` | ``''`` (empty strings are falsy)
edX-specific Environment Variables
==================================
@@ -183,7 +187,7 @@ All community members are expected to follow the `Open edX Code of Conduct <http
People
======
The assigned maintainers for this component and other project details may be
found in `Backstage <https://backstage.openedx.org/catalog/default/group/vanguards>`_. Backstage pulls this data from the ``catalog-info.yaml``
found in `Backstage <https://backstage.openedx.org/catalog/default/group/2u-vanguards>`_. Backstage pulls this data from the ``catalog-info.yaml``
file in this repo.
Reporting Security Issues

View File

@@ -13,6 +13,6 @@ metadata:
annotations:
openedx.org/arch-interest-groups: ""
spec:
owner: group:vanguards
owner: group:2u-vanguards
type: 'service'
lifecycle: 'production'

View File

@@ -0,0 +1,39 @@
========================================
Modifying the Base Container in Authn
========================================
The base container in Authn serves as the fundamental layout structure for rendering different components based on configurations. This document outlines the process for modifying the base container to accommodate changes or customize layouts as needed.
Understanding Base Container Versions
--------------------------------------
The base container supports two main versions:
- **Default Layout:** The default layout is the standard layout used when specific configurations do not dictate otherwise.
.. image:: ../images/default_layout.png
- **Image Layout:** The image layout is an alternative layout option that can be enabled based on configurations.
.. image:: ../images/image_layout.png
Enabling the Image Layout
---------------------------
To activate the image layout feature, navigate to your .env file and update the configurations:
**Update Configuration**
Locate the ``ENABLE_IMAGE_LAYOUT`` parameter and set its value to ``true``. Additionally, ensure that the Image configuration settings are provided. Your overall configurations should resemble the following:
.. code-block::
# ***** Image Layout Configuration *****
ENABLE_IMAGE_LAYOUT = True # Set to True to enable image layout feature
# ***** Base Container Images *****
BANNER_IMAGE_LARGE='' # Path to the large banner image
BANNER_IMAGE_MEDIUM='' # Path to the medium-sized banner image
BANNER_IMAGE_SMALL='' # Path to the small banner image
BANNER_IMAGE_EXTRA_SMALL='' # Path to the extra-small banner image
This allows for the customization and adaptation of the base container layout according to specific requirements.

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -3,6 +3,6 @@
nick: Authn MFE
oeps: {}
owner: openedx/vanguards
owner: openedx/2u-vanguards
openedx-release:
ref: master

12539
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,54 +33,53 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-platform": "^5.5.4",
"@openedx/paragon": "21.11.3",
"@fortawesome/fontawesome-svg-core": "6.4.2",
"@fortawesome/free-brands-svg-icons": "6.4.2",
"@fortawesome/free-solid-svg-icons": "6.4.2",
"@edx/frontend-platform": "7.1.1",
"@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "6.5.1",
"@fortawesome/free-brands-svg-icons": "6.5.1",
"@fortawesome/free-solid-svg-icons": "6.5.1",
"@fortawesome/react-fontawesome": "0.2.0",
"@openedx/paragon": "^22.1.1",
"@optimizely/react-sdk": "^2.9.1",
"@redux-devtools/extension": "3.2.5",
"@redux-devtools/extension": "3.3.0",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"algoliasearch": "^4.14.3",
"algoliasearch-helper": "^3.14.0",
"classnames": "2.3.2",
"core-js": "3.32.0",
"classnames": "2.5.1",
"core-js": "3.36.0",
"fastest-levenshtein": "1.0.16",
"form-urlencoded": "6.1.0",
"form-urlencoded": "6.1.4",
"prop-types": "15.8.1",
"query-string": "7.1.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-helmet": "6.1.0",
"react-loading-skeleton": "3.3.1",
"react-loading-skeleton": "3.4.0",
"react-redux": "7.2.9",
"react-responsive": "8.2.0",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-router": "6.22.3",
"react-router-dom": "6.22.3",
"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-saga": "1.3.0",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.14.0",
"regenerator-runtime": "0.14.1",
"reselect": "4.1.8",
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@edx/browserslist-config": "^1.1.1",
"@openedx/frontend-build": "13.0.19",
"@edx/reactifex": "1.1.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.8.0",
"babel-plugin-formatjs": "10.5.10",
"enzyme": "3.11.0",
"eslint-plugin-import": "2.28.0",
"@openedx/frontend-build": "13.0.28",
"babel-plugin-formatjs": "10.5.13",
"eslint-plugin-import": "2.29.1",
"glob": "7.2.3",
"history": "5.3.0",
"husky": "7.0.4",
"jest": "29.6.2",
"jest": "29.7.0",
"react-test-renderer": "^17.0.2"
}
}

View File

@@ -1,7 +1,7 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Authn | <%= process.env.SITE_NAME %></title>
<title><%= (process.env.SITE_NAME && process.env.SITE_NAME != 'null') ? 'Authentication | ' + process.env.SITE_NAME : 'Authentication' %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon"/>

View File

@@ -1,50 +1,50 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { render, screen } from '@testing-library/react';
import { DefaultLargeLayout, DefaultMediumLayout, DefaultSmallLayout } from './index';
describe('Default Layout tests', () => {
it('should display the form passed as a child in SmallScreenLayout', () => {
const smallScreen = mount(
render(
<IntlProvider locale="en">
<div>
<DefaultSmallLayout />
<form>
<form aria-label="form">
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(smallScreen.find('form').exists()).toEqual(true);
expect(screen.getByRole('form')).toBeDefined();
});
it('should display the form passed as a child in MediumScreenLayout', () => {
const mediumScreen = mount(
render(
<IntlProvider locale="en">
<div>
<DefaultMediumLayout />
<form>
<form aria-label="form">
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(mediumScreen.find('form').exists()).toEqual(true);
expect(screen.getByRole('form')).toBeDefined();
});
it('should display the form passed as a child in LargeScreenLayout', () => {
const largeScreen = mount(
render(
<IntlProvider locale="en">
<div>
<DefaultLargeLayout />
<form>
<form aria-label="form">
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(largeScreen.find('form').exists()).toEqual(true);
expect(screen.getByRole('form')).toBeDefined();
});
});

View File

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
import messages from './messages';
const LargeLayout = ({ username }) => {
const LargeLayout = ({ fullName }) => {
const { formatMessage } = useIntl();
return (
@@ -20,7 +20,7 @@ const LargeLayout = ({ username }) => {
<div className="large-screen-left-container mr-n4.5 large-yellow-line mt-5" />
<div>
<h1 className="welcome-to-platform data-hj-suppress">
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, username })}
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
</h1>
<h2 className="complete-your-profile">
{formatMessage(messages['complete.your.profile.1'])}
@@ -43,7 +43,7 @@ const LargeLayout = ({ username }) => {
};
LargeLayout.propTypes = {
username: PropTypes.string.isRequired,
fullName: PropTypes.string.isRequired,
};
export default LargeLayout;

View File

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
import messages from './messages';
const MediumLayout = ({ username }) => {
const MediumLayout = ({ fullName }) => {
const { formatMessage } = useIntl();
return (
@@ -22,7 +22,7 @@ const MediumLayout = ({ username }) => {
<div className="medium-yellow-line mt-5 mr-n2" />
<div>
<h1 className="h3 data-hj-suppress mw-320">
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, username })}
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
</h1>
<h2 className="display-1">
{formatMessage(messages['complete.your.profile.1'])}
@@ -46,7 +46,7 @@ const MediumLayout = ({ username }) => {
};
MediumLayout.propTypes = {
username: PropTypes.string.isRequired,
fullName: PropTypes.string.isRequired,
};
export default MediumLayout;

View File

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
import messages from './messages';
const SmallLayout = ({ username }) => {
const SmallLayout = ({ fullName }) => {
const { formatMessage } = useIntl();
return (
@@ -20,7 +20,7 @@ const SmallLayout = ({ username }) => {
<div className="small-yellow-line mt-4.5" />
<div>
<h1 className="h5 data-hj-suppress">
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, username })}
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
</h1>
<h2 className="h1">
{formatMessage(messages['complete.your.profile.1'])}
@@ -35,7 +35,7 @@ const SmallLayout = ({ username }) => {
};
SmallLayout.propTypes = {
username: PropTypes.string.isRequired,
fullName: PropTypes.string.isRequired,
};
export default SmallLayout;

View File

@@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'welcome.to.platform': {
id: 'welcome.to.platform',
defaultMessage: 'Welcome to {siteName}, {username}!',
defaultMessage: 'Welcome to {siteName}, {fullName}!',
description: 'Welcome message that appears on progressive profile page',
},
'complete.your.profile.1': {

View File

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

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { breakpoints } from '@openedx/paragon';
import classNames from 'classnames';
import PropTypes from 'prop-types';
@@ -10,40 +11,24 @@ 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, username }) => {
const [baseContainerVersion, setBaseContainerVersion] = useState(DEFAULT_LAYOUT);
const BaseContainer = ({ children, showWelcomeBanner, fullName }) => {
const enableImageLayout = getConfig().ENABLE_IMAGE_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) {
if (enableImageLayout) {
return (
<div className="layout">
<MediaQuery maxWidth={breakpoints.extraSmall.maxWidth - 1}>
{showWelcomeBanner ? <AuthSmallLayout username={username} /> : <ImageExtraSmallLayout />}
{showWelcomeBanner ? <AuthSmallLayout fullName={fullName} /> : <ImageExtraSmallLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.small.minWidth} maxWidth={breakpoints.small.maxWidth - 1}>
{showWelcomeBanner ? <AuthSmallLayout username={username} /> : <ImageSmallLayout />}
{showWelcomeBanner ? <AuthSmallLayout fullName={fullName} /> : <ImageSmallLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
{showWelcomeBanner ? <AuthMediumLayout username={username} /> : <ImageMediumLayout />}
{showWelcomeBanner ? <AuthMediumLayout fullName={fullName} /> : <ImageMediumLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.extraLarge.minWidth}>
{showWelcomeBanner ? <AuthLargeLayout username={username} /> : <ImageLargeLayout />}
{showWelcomeBanner ? <AuthLargeLayout fullName={fullName} /> : <ImageLargeLayout />}
</MediaQuery>
<div className={classNames('content', { 'align-items-center mt-0': showWelcomeBanner })}>
{children}
@@ -57,13 +42,13 @@ const BaseContainer = ({ children, showWelcomeBanner, username }) => {
<div className="col-md-12 extra-large-screen-top-stripe" />
<div className="layout">
<MediaQuery maxWidth={breakpoints.small.maxWidth - 1}>
{showWelcomeBanner ? <AuthSmallLayout username={username} /> : <DefaultSmallLayout />}
{showWelcomeBanner ? <AuthSmallLayout fullName={fullName} /> : <DefaultSmallLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.medium.minWidth} maxWidth={breakpoints.large.maxWidth - 1}>
{showWelcomeBanner ? <AuthMediumLayout username={username} /> : <DefaultMediumLayout />}
{showWelcomeBanner ? <AuthMediumLayout fullName={fullName} /> : <DefaultMediumLayout />}
</MediaQuery>
<MediaQuery minWidth={breakpoints.extraLarge.minWidth}>
{showWelcomeBanner ? <AuthLargeLayout username={username} /> : <DefaultLargeLayout />}
{showWelcomeBanner ? <AuthLargeLayout fullName={fullName} /> : <DefaultLargeLayout />}
</MediaQuery>
<div className={classNames('content', { 'align-items-center mt-0': showWelcomeBanner })}>
{children}
@@ -75,13 +60,13 @@ const BaseContainer = ({ children, showWelcomeBanner, username }) => {
BaseContainer.defaultProps = {
showWelcomeBanner: false,
username: null,
fullName: null,
};
BaseContainer.propTypes = {
children: PropTypes.node.isRequired,
showWelcomeBanner: PropTypes.bool,
username: PropTypes.string,
fullName: PropTypes.string,
};
export default BaseContainer;

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { mergeConfig } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { render } from '@testing-library/react';
import { Context as ResponsiveContext } from 'react-responsive';
import BaseContainer from '../index';
@@ -12,32 +13,30 @@ const LargeScreen = {
};
describe('Base component tests', () => {
it('should should default layout', () => {
const baseContainer = mount(
it('should show default layout', () => {
const { container } = render(
<IntlProvider locale="en">
<BaseContainer />
</IntlProvider>,
LargeScreen,
);
expect(baseContainer.find('.banner__image').exists()).toBeFalsy();
expect(baseContainer.find('.large-screen-svg-primary').exists()).toBeTruthy();
expect(container.querySelector('.banner__image')).toBeNull();
expect(container.querySelector('.large-screen-svg-primary')).toBeDefined();
});
it('[experiment] should show image layout for treatment group', () => {
window.experiments = {
rebrandExperiment: {
variation: 'image-layout',
},
};
it('renders Image layout when ENABLE_IMAGE_LAYOUT configuration is enabled', () => {
mergeConfig({
ENABLE_IMAGE_LAYOUT: true,
});
const baseContainer = mount(
const { container } = render(
<IntlProvider locale="en">
<BaseContainer />
<BaseContainer showWelcomeBanner={false} />
</IntlProvider>,
LargeScreen,
);
expect(baseContainer.find('.banner__image').exists()).toBeTruthy();
expect(container.querySelector('.banner__image')).toBeDefined();
});
});

View File

@@ -138,7 +138,7 @@ const PasswordField = (props) => {
{props.errorMessage !== '' && (
<Form.Control.Feedback key="error" className="form-text-size" hasIcon={false} feedback-for={props.name} type="invalid">
{props.errorMessage}
<span className="sr-only">{formatMessage(messages['password.sr.only.helping.text'])}</span>
{props.showScreenReaderText && <span className="sr-only">{formatMessage(messages['password.sr.only.helping.text'])}</span>}
</Form.Control.Feedback>
)}
</Form.Group>
@@ -153,6 +153,7 @@ PasswordField.defaultProps = {
handleChange: () => {},
handleErrorChange: null,
showRequirements: true,
showScreenReaderText: true,
autoComplete: null,
};
@@ -168,6 +169,7 @@ PasswordField.propTypes = {
showRequirements: PropTypes.bool,
value: PropTypes.string.isRequired,
autoComplete: PropTypes.string,
showScreenReaderText: PropTypes.bool,
};
export default PasswordField;

View File

@@ -2,17 +2,23 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Hyperlink, Icon,
} from '@openedx/paragon';
import { Institution } from '@openedx/paragon/icons';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import Skeleton from 'react-loading-skeleton';
import messages from './messages';
import {
ENTERPRISE_LOGIN_URL, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE,
} from '../data/constants';
import {
RenderInstitutionButton,
SocialAuthProviders,
} from '../../common-components';
import {
PENDING_STATE, REGISTER_PAGE,
} from '../../data/constants';
import messages from '../messages';
} from './index';
/**
* This component renders the Single sign-on (SSO) buttons for the providers passed.
@@ -20,33 +26,59 @@ import messages from '../messages';
const ThirdPartyAuth = (props) => {
const { formatMessage } = useIntl();
const {
providers, secondaryProviders, currentProvider, handleInstitutionLogin, thirdPartyAuthApiStatus,
providers,
secondaryProviders,
currentProvider,
handleInstitutionLogin,
thirdPartyAuthApiStatus,
isLoginPage,
} = props;
const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider;
const isSocialAuthActive = !!providers.length && !currentProvider;
const isEnterpriseLoginDisabled = getConfig().DISABLE_ENTERPRISE_LOGIN;
const enterpriseLoginURL = getConfig().LMS_BASE_URL + ENTERPRISE_LOGIN_URL;
return (
<>
{((isEnterpriseLoginDisabled && isInstitutionAuthActive) || isSocialAuthActive) && (
<div className="mt-4 mb-3 h4">
{formatMessage(messages['registration.other.options.heading'])}
{isLoginPage
? formatMessage(messages['login.other.options.heading'])
: formatMessage(messages['registration.other.options.heading'])}
</div>
)}
{(isLoginPage && !isEnterpriseLoginDisabled && isSocialAuthActive) && (
<Hyperlink
className={classNames(
'btn btn-link btn-sm text-body p-0',
{ 'mb-0': thirdPartyAuthApiStatus === PENDING_STATE },
{ 'mb-4': thirdPartyAuthApiStatus !== PENDING_STATE },
)}
destination={enterpriseLoginURL}
>
<Icon src={Institution} className="institute-icon" />
{formatMessage(messages['enterprise.login.btn.text'])}
</Hyperlink>
)}
{thirdPartyAuthApiStatus === PENDING_STATE ? (
<Skeleton className="tpa-skeleton" height={36} count={2} />
<div className="mt-4">
<Skeleton className="tpa-skeleton" height={36} count={2} />
</div>
) : (
<>
{(isEnterpriseLoginDisabled && isInstitutionAuthActive) && (
<RenderInstitutionButton
onSubmitHandler={handleInstitutionLogin}
buttonTitle={formatMessage(messages['register.institution.login.button'])}
buttonTitle={formatMessage(messages['institution.login.button'])}
/>
)}
{isSocialAuthActive && (
<div className="row m-0">
<SocialAuthProviders socialAuthProviders={providers} referrer={REGISTER_PAGE} />
<SocialAuthProviders
socialAuthProviders={providers}
referrer={isLoginPage ? LOGIN_PAGE : REGISTER_PAGE}
/>
</div>
)}
</>
@@ -59,7 +91,8 @@ ThirdPartyAuth.defaultProps = {
currentProvider: null,
providers: [],
secondaryProviders: [],
thirdPartyAuthApiStatus: 'pending',
thirdPartyAuthApiStatus: PENDING_STATE,
isLoginPage: false,
};
ThirdPartyAuth.propTypes = {
@@ -86,6 +119,7 @@ ThirdPartyAuth.propTypes = {
}),
),
thirdPartyAuthApiStatus: PropTypes.string,
isLoginPage: PropTypes.bool,
};
export default ThirdPartyAuth;

View File

@@ -112,6 +112,26 @@ const messages = defineMessages({
description: 'Select ticket form',
defaultMessage: 'Please choose your request type:',
},
'registration.other.options.heading': {
id: 'registration.other.options.heading',
defaultMessage: 'Or register with:',
description: 'A message that appears above third party auth providers i.e saml, google, facebook etc',
},
'institution.login.button': {
id: 'institution.login.button',
defaultMessage: 'Institution/campus credentials',
description: 'shows institutions list',
},
'login.other.options.heading': {
id: 'login.other.options.heading',
defaultMessage: 'Or sign in with:',
description: 'Text that appears above other sign in options like social auth buttons',
},
'enterprise.login.btn.text': {
id: 'enterprise.login.btn.text',
defaultMessage: 'Company or school credentials',
description: 'Company or school login link text.',
},
});
export default messages;

View File

@@ -4,12 +4,14 @@ const configuration = {
USER_RETENTION_COOKIE_NAME: process.env.USER_RETENTION_COOKIE_NAME || '',
// Features
DISABLE_ENTERPRISE_LOGIN: process.env.DISABLE_ENTERPRISE_LOGIN || '',
ENABLE_AUTO_GENERATED_USERNAME: process.env.ENABLE_AUTO_GENERATED_USERNAME || true,
ENABLE_DYNAMIC_REGISTRATION_FIELDS: process.env.ENABLE_DYNAMIC_REGISTRATION_FIELDS || false,
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: process.env.ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN || false,
ENABLE_POST_REGISTRATION_RECOMMENDATIONS: process.env.ENABLE_POST_REGISTRATION_RECOMMENDATIONS || false,
MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '',
SHOW_CONFIGURABLE_EDX_FIELDS: process.env.SHOW_CONFIGURABLE_EDX_FIELDS || false,
SHOW_REGISTRATION_LINKS: process.env.SHOW_REGISTRATION_LINKS !== 'false',
ENABLE_IMAGE_LAYOUT: process.env.ENABLE_IMAGE_LAYOUT || false,
// Links
ACTIVATION_EMAIL_SUPPORT_LINK: process.env.ACTIVATION_EMAIL_SUPPORT_LINK || null,
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: process.env.AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK || null,

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { mount } from 'enzyme';
import { fireEvent, render } from '@testing-library/react';
import FieldRenderer from '../FieldRenderer';
@@ -28,13 +28,14 @@ describe('FieldRendererTests', () => {
options: [['1997', '1997'], ['1998', '1998']],
};
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
const field = fieldRenderer.find('select#yob-field');
field.simulate('change', { target: { value: 1997 } });
const { container } = render(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
const input = container.querySelector('select#yob-field');
const label = container.querySelector('label');
fireEvent.change(input, { target: { value: 1997 } });
expect(field.type()).toEqual('select');
expect(fieldRenderer.find('label').text()).toEqual('Year of Birth');
expect(value).toEqual(1997);
expect(input.type).toEqual('select-one');
expect(label.textContent).toContain(fieldData.label);
expect(value).toEqual('1997');
});
it('should return null if no options are provided for select field', () => {
@@ -44,8 +45,8 @@ describe('FieldRendererTests', () => {
name: 'yob-field',
};
const fieldRenderer = mount(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
expect(fieldRenderer.html()).toBeNull();
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
expect(container.innerHTML).toEqual('');
});
it('should render textarea field', () => {
@@ -55,12 +56,13 @@ describe('FieldRendererTests', () => {
name: 'goals-field',
};
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
const field = fieldRenderer.find('#goals-field').last();
field.simulate('change', { target: { value: 'These are my goals.' } });
const { container } = render(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
const input = container.querySelector('#goals-field');
const label = container.querySelector('label');
fireEvent.change(input, { target: { value: 'These are my goals.' } });
expect(field.type()).toEqual('textarea');
expect(fieldRenderer.find('label').text()).toEqual('Why do you want to join this platform?');
expect(input.type).toEqual(fieldData.type);
expect(label.textContent).toContain('Why do you want to join this platform?');
expect(value).toEqual('These are my goals.');
});
@@ -71,12 +73,13 @@ describe('FieldRendererTests', () => {
name: 'company-field',
};
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
const field = fieldRenderer.find('#company-field').last();
field.simulate('change', { target: { value: 'ABC' } });
const { container } = render(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
const input = container.querySelector('input#company-field');
const label = container.querySelector('label');
fireEvent.change(input, { target: { value: 'ABC' } });
expect(field.type()).toEqual('input');
expect(fieldRenderer.find('label').text()).toEqual('Company');
expect(input.type).toEqual(fieldData.type);
expect(label.textContent).toContain(fieldData.label);
expect(value).toEqual('ABC');
});
@@ -87,12 +90,13 @@ describe('FieldRendererTests', () => {
name: 'marketing-emails-opt-in-field',
};
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
const field = fieldRenderer.find('input#marketing-emails-opt-in-field');
field.simulate('change', { target: { checked: true, type: 'checkbox' } });
const { container } = render(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
const input = container.querySelector('input#marketing-emails-opt-in-field');
const label = container.querySelector('label');
fireEvent.click(input);
expect(field.prop('type')).toEqual('checkbox');
expect(fieldRenderer.find('label').text()).toEqual(fieldData.label);
expect(input.type).toEqual(fieldData.type);
expect(label.textContent).toContain(fieldData.label);
expect(value).toEqual(true);
});
@@ -101,8 +105,8 @@ describe('FieldRendererTests', () => {
type: 'unknown',
};
const fieldRenderer = mount(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
expect(fieldRenderer.html()).toBeNull();
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
expect(container.innerHTML).toContain('');
});
it('should run onBlur and onFocus functions for a field if given', () => {
@@ -117,7 +121,7 @@ describe('FieldRendererTests', () => {
functionValue = `${e.target.name} focussed`;
};
const fieldRenderer = mount(
const { container } = render(
<FieldRenderer
handleFocus={onFocus}
handleBlur={onBlur}
@@ -126,19 +130,19 @@ describe('FieldRendererTests', () => {
onChangeHandler={changeHandler}
/>,
);
const field = fieldRenderer.find('#test-field').last();
const input = container.querySelector('#test-field');
field.simulate('focus');
fireEvent.focus(input);
expect(functionValue).toEqual('test-field focussed');
field.simulate('blur');
fireEvent.blur(input);
expect(functionValue).toEqual('test-field blurred');
});
it('should render error message for required text fields', () => {
const fieldData = { type: 'text', label: 'First Name', name: 'first-name-field' };
const fieldRenderer = mount(
const { container } = render(
<FieldRenderer
isRequired
fieldData={fieldData}
@@ -147,7 +151,7 @@ describe('FieldRendererTests', () => {
/>,
);
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Enter your first name');
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('Enter your first name');
});
it('should render error message for required select fields', () => {
@@ -155,7 +159,7 @@ describe('FieldRendererTests', () => {
type: 'select', label: 'Preference', name: 'preference-field', options: [['a', 'Opt 1'], ['b', 'Opt 2']],
};
const fieldRenderer = mount(
const { container } = render(
<FieldRenderer
isRequired
fieldData={fieldData}
@@ -164,13 +168,13 @@ describe('FieldRendererTests', () => {
/>,
);
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Select your preference');
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('Select your preference');
});
it('should render error message for required textarea fields', () => {
const fieldData = { type: 'textarea', label: 'Goals', name: 'goals-field' };
const fieldRenderer = mount(
const { container } = render(
<FieldRenderer
isRequired
fieldData={fieldData}
@@ -179,13 +183,13 @@ describe('FieldRendererTests', () => {
/>,
);
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Tell us your goals');
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('Tell us your goals');
});
it('should render error message for required checkbox fields', () => {
const fieldData = { type: 'checkbox', label: 'Honor Code', name: 'honor-code-field' };
const fieldRenderer = mount(
const { container } = render(
<FieldRenderer
isRequired
fieldData={fieldData}
@@ -194,6 +198,6 @@ describe('FieldRendererTests', () => {
/>,
);
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('You must agree to our Honor Code');
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('You must agree to our Honor Code');
});
});

View File

@@ -64,7 +64,7 @@
"non.compliant.password.message": "كلمة مرورك الحالية لا تستسجيب لمتطلبات الأمان الجديدة. لقد أرسلنا للتو رسالة لإعادة ضبط كلمة المرور إلى عنوان البريد الإلكتروني المرتبط بهذا الحساب. شكرًا لك على مساعدتنا في الحفاظ على سلامة بياناتك.",
"account.locked.out.message.1": "لحماية حسابك، تم إقفاله مؤقتًا. حاول مرة أخرى بعد 30 دقيقة.",
"enterprise.login.btn.text": "بيانات الشركة أو المدرسة",
"username.or.email.format.validation.less.chars.message": "يجب أن يحتوي اسم المستخدم أو البريد الإلكتروني على 3 أحرف على الأقل.",
"username.or.email.format.validation.less.chars.message": "يجب أن يحتوي اسم المستخدم أو البريد الإلكتروني على 2 أحرف على الأقل.",
"email.validation.message": "أدخل اسم المستخدم أو البريد الإلكتروني الخاص بك",
"password.validation.message": "لم يتم استيفاء معايير كلمة المرور",
"account.activation.success.message.title": "نجح الأمر! لقد قمت بتفعيل حسابك.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"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.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 2 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.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"non.compliant.password.message": "Ihr aktuelles Passwort entspricht nicht den neuen Sicherheitsanforderungen. Wir haben gerade eine Nachricht zum Zurücksetzen des Passworts an die mit diesem Konto verknüpfte E-Mail-Adresse gesendet. Vielen Dank, dass Sie uns helfen, Ihre Daten zu schützen.",
"account.locked.out.message.1": "Um Ihr Konto zu schützen, wurde es vorübergehend gesperrt. Versuchen Sie es in 30 Minuten erneut.",
"enterprise.login.btn.text": "Arbeits- oder Schulzeugnisse",
"username.or.email.format.validation.less.chars.message": "Benutzername oder E-Mail müssen mindestens 3 Zeichen lang sein.",
"username.or.email.format.validation.less.chars.message": "Benutzername oder E-Mail müssen mindestens 2 Zeichen lang sein.",
"email.validation.message": "Geben Sie Ihren Benutzernamen oder Ihre E-Mail-Adresse ein",
"password.validation.message": "Die Passwortkriterien wurden nicht erfüllt",
"account.activation.success.message.title": "Super! Sie haben Ihr Konto aktiviert.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"non.compliant.password.message": "Tu contraseña actual no cumple con los nuevos requisitos de seguridad. Acabamos de enviar un mensaje de restablecimiento de contraseña a la dirección de correo electrónico asociada a esta cuenta. Gracias por ayudarnos a mantener tus datos seguros.",
"account.locked.out.message.1": "Para proteger tu cuenta, se ha bloqueado temporalmente. Inténtalo de nuevo en 30 minutos.",
"enterprise.login.btn.text": "Credenciales de la empresa o de la institución ",
"username.or.email.format.validation.less.chars.message": "El nombre de usuario o el correo electrónico deben tener al menos 3 caracteres.",
"username.or.email.format.validation.less.chars.message": "El nombre de usuario o el correo electrónico deben tener al menos 2 caracteres.",
"email.validation.message": "Introduce tu nombre de usuario o correo electrónico",
"password.validation.message": "No se han cumplido los criterios de la contraseña",
"account.activation.success.message.title": "Ha sido un éxito. Has activado tu cuenta.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "Al crear una cuenta, acepta {tosAndHonorCode} y reconoce que {platformName} y cada miembro procesan sus datos personales de acuerdo con {privacyPolicy}.",
"register.page.honor.code": "Acepto las {platformName} {tosAndHonorCode}",
"register.page.terms.of.service": "Acepto las {platformName} {termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"non.compliant.password.message": "گذرواژه فعلی شما الزامات امنیتی جدید را برآورده نمی‌کند. ما فقط یک پیام بازتنظیم گذرواژه به نشانی رایانامه مرتبط با این حساب کاربری ارسال کردیم. از اینکه به ما کمک می‌کنید تا داده‌های شما را ایمن نگه دارید متشکریم.",
"account.locked.out.message.1": "حساب کاربری شما، به دلیل حفاظت، به‌طور موقت قفل شده است. 30 دقیقه دیگر دوباره امتحان کنید.",
"enterprise.login.btn.text": "اعتبار دانشکده یا شرکت",
"username.or.email.format.validation.less.chars.message": "نام کاربری یا نشانی رایانامه حداقل باید 3 نویسه داشته باشد",
"username.or.email.format.validation.less.chars.message": "نام کاربری یا نشانی رایانامه حداقل باید 2 نویسه داشته باشد",
"email.validation.message": "نام کاربری یا رایانامه خود را وارد کنید",
"password.validation.message": "معیارهای گذرواژه رعایت نشده است",
"account.activation.success.message.title": "موفق شدید! شما حساب کاربری خود را فعال کردید.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"non.compliant.password.message": "Votre mot de passe actuel ne répond pas aux nouvelles exigences de sécurité. Nous venons d'envoyer un message de réinitialisation de mot de passe à l'adresse courriel associée à ce compte. Merci de nous aider à protéger vos données.",
"account.locked.out.message.1": "Pour protéger votre compte, il a été temporairement verrouillé. Réessayez dans 30 minutes.",
"enterprise.login.btn.text": "Identifiants de la compagnie ou de l'école",
"username.or.email.format.validation.less.chars.message": "Le nom d'utilisateur ou l'adresse courriel doit comporter au moins 3 caractères.",
"username.or.email.format.validation.less.chars.message": "Le nom d'utilisateur ou l'adresse courriel doit comporter au moins 2 caractères.",
"email.validation.message": "Entrez votre nom d'utilisateur ou votre adresse courriel",
"password.validation.message": "Les critères de mot de passe n'ont pas été remplis",
"account.activation.success.message.title": "Succès! Vous avez activé votre compte.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"non.compliant.password.message": "Votre mot de passe actuel ne répond pas aux nouvelles exigences de sécurité. Nous venons d'envoyer un message de réinitialisation de mot de passe à l'adresse courriel associée à ce compte. Merci de nous aider à protéger vos données.",
"account.locked.out.message.1": "Pour protéger votre compte, il a été temporairement verrouillé. Réessayez dans 30 minutes.",
"enterprise.login.btn.text": "Informations d'identification de la compagnie ou de l'école",
"username.or.email.format.validation.less.chars.message": "Le nom d'utilisateur ou l'adresse courriel doit comporter au moins 3 caractères.",
"username.or.email.format.validation.less.chars.message": "Le nom d'utilisateur ou l'adresse courriel doit comporter au moins 2 caractères.",
"email.validation.message": "Entrez votre nom d'utilisateur ou votre adresse courriel",
"password.validation.message": "Les critères de mot de passe n'ont pas été remplis",
"account.activation.success.message.title": "Succès! Vous avez activé votre compte.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "En créant un compte, vous acceptez le {tosAndHonorCode} et vous reconnaissez que {platformName} et chaque membre traitent vos données personnelles conformément au {privacyPolicy}.",
"register.page.honor.code": "J'accepte le {tosAndHonorCode} {platformName}",
"register.page.terms.of.service": "J'accepte les {termsOfService} {platformName}"
}
}

View File

@@ -64,7 +64,7 @@
"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.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 2 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.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"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.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 2 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.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"non.compliant.password.message": "La tua password attuale non soddisfa i nuovi requisiti di sicurezza. Abbiamo appena inviato un messaggio di reimpostazione della password all&#39;indirizzo e-mail associato a questo account. Grazie per averci aiutato a mantenere i tuoi dati al sicuro.",
"account.locked.out.message.1": "Per proteggere il tuo account, è stato temporaneamente bloccato. Riprova tra 30 minuti.",
"enterprise.login.btn.text": "Credenziali aziendali o scolastiche",
"username.or.email.format.validation.less.chars.message": "Il nome utente o l&#39;e-mail deve contenere almeno 3 caratteri.",
"username.or.email.format.validation.less.chars.message": "Il nome utente o l&#39;e-mail deve contenere almeno 2 caratteri.",
"email.validation.message": "Inserisci il tuo nome utente o e-mail",
"password.validation.message": "I criteri della password non sono stati soddisfatti",
"account.activation.success.message.title": "Completato correttamente! Hai attivato il tuo account. ",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"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.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 2 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.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"non.compliant.password.message": "A sua palavra-passe atual não satisfaz os novos requisitos de segurança. Acabámos de enviar uma mensagem de redefinição da palavra-passe para o endereço de email associado a esta conta. Obrigado por nos ajudar a manter os seus dados em segurança.",
"account.locked.out.message.1": "Para proteger sua conta, esta foi temporariamente bloqueada. Tente novamente dentro de 30 minutos.",
"enterprise.login.btn.text": "Credenciais da empresa ou escola",
"username.or.email.format.validation.less.chars.message": "O nome de utilizador ou email deve ter pelo menos 3 carateres.",
"username.or.email.format.validation.less.chars.message": "O nome de utilizador ou email deve ter pelo menos 2 carateres.",
"email.validation.message": "Insira o seu nome de utilizador ou email",
"password.validation.message": "Os critérios de palavra-passe não foram cumpridos",
"account.activation.success.message.title": "Sucesso! Você ativou a sua conta.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"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.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 2 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.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"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.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 2 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.",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -64,7 +64,7 @@
"non.compliant.password.message": "您当前的密码不符合新的安全要求。我们刚刚向与此帐户关联的电子邮件地址发送了密码重置邮件。感谢您帮助我们保护您的数据安全。",
"account.locked.out.message.1": "为了保护您的帐户,它已被暂时锁定。请在 30 分钟后重试。",
"enterprise.login.btn.text": "单位或学校证书",
"username.or.email.format.validation.less.chars.message": "用户名或电子邮件必须至少包含 3 个字符。",
"username.or.email.format.validation.less.chars.message": "用户名或电子邮件必须至少包含 2 个字符。",
"email.validation.message": "输入您的用户名或电子邮件",
"password.validation.message": "未满足密码条件",
"account.activation.success.message.title": "成功!您已激活您的帐户。",
@@ -178,4 +178,4 @@
"register.page.terms.of.service.and.honor.code": "By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each Member process your personal data in accordance with the {privacyPolicy}.",
"register.page.honor.code": "I agree to the {platformName}&nbsp;{tosAndHonorCode}",
"register.page.terms.of.service": "I agree to the {platformName}&nbsp;{termsOfService}"
}
}

View File

@@ -9,29 +9,30 @@ import PropTypes from 'prop-types';
import { ACCOUNT_ACTIVATION_MESSAGE } from './data/constants';
import messages from './messages';
const AccountActivationMessage = (props) => {
const AccountActivationMessage = ({ messageType }) => {
const { formatMessage } = useIntl();
const { messageType } = props;
if (!messageType) {
return null;
}
const variant = messageType === ACCOUNT_ACTIVATION_MESSAGE.ERROR ? 'danger' : messageType;
const activationOrVerification = getConfig().MARKETING_EMAILS_OPT_IN ? 'confirmation' : 'activation';
let activationMessage;
let heading;
const activationOrConfirmation = getConfig().MARKETING_EMAILS_OPT_IN ? 'confirmation' : 'activation';
const iconMapping = {
[ACCOUNT_ACTIVATION_MESSAGE.SUCCESS]: CheckCircle,
[ACCOUNT_ACTIVATION_MESSAGE.ERROR]: Error,
};
let activationMessage;
let heading;
switch (messageType) {
case ACCOUNT_ACTIVATION_MESSAGE.SUCCESS: {
heading = formatMessage(messages[`account.${activationOrVerification}.success.message.title`]);
activationMessage = <span>{formatMessage(messages[`account.${activationOrVerification}.success.message`])}</span>;
heading = formatMessage(messages[`account.${activationOrConfirmation}.success.message.title`]);
activationMessage = <span>{formatMessage(messages[`account.${activationOrConfirmation}.success.message`])}</span>;
break;
}
case ACCOUNT_ACTIVATION_MESSAGE.INFO: {
activationMessage = formatMessage(messages[`account.${activationOrVerification}.info.message`]);
activationMessage = formatMessage(messages[`account.${activationOrConfirmation}.info.message`]);
break;
}
case ACCOUNT_ACTIVATION_MESSAGE.ERROR: {
@@ -41,7 +42,7 @@ const AccountActivationMessage = (props) => {
</Alert.Link>
);
heading = formatMessage(messages[`account.${activationOrVerification}.error.message.title`]);
heading = formatMessage(messages[`account.${activationOrConfirmation}.error.message.title`]);
activationMessage = (
<FormattedMessage
id="account.activation.error.message"
@@ -59,7 +60,7 @@ const AccountActivationMessage = (props) => {
return activationMessage ? (
<Alert
id="account-activation-message"
className="mb-4"
className="mb-5"
variant={variant}
icon={iconMapping[messageType]}
>
@@ -70,7 +71,11 @@ const AccountActivationMessage = (props) => {
};
AccountActivationMessage.propTypes = {
messageType: PropTypes.string.isRequired,
messageType: PropTypes.string,
};
AccountActivationMessage.defaultProps = {
messageType: null,
};
export default AccountActivationMessage;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getAuthService } from '@edx/frontend-platform/auth';
@@ -23,22 +23,35 @@ import {
TPA_AUTHENTICATION_FAILURE,
} from './data/constants';
import messages from './messages';
import { windowScrollTo } from '../data/utils';
const LoginFailureMessage = (props) => {
const { formatMessage } = useIntl();
const { context, errorCode } = props.loginError;
const authService = getAuthService();
let errorList;
const {
context,
errorCode,
errorCount, // This is used to trigger the useEffect, facilitating the scrolling to the top.
} = props;
useEffect(() => {
windowScrollTo({ left: 0, top: 0, behavior: 'smooth' });
}, [errorCode, errorCount]);
if (!errorCode) {
return null;
}
let resetLink = (
<Hyperlink destination="reset" isInline>
{formatMessage(messages['login.incorrect.credentials.error.reset.link.text'])}
</Hyperlink>
);
let errorMessage;
switch (errorCode) {
case NON_COMPLIANT_PASSWORD_EXCEPTION: {
errorList = (
errorMessage = (
<>
<strong>{formatMessage(messages['non.compliant.password.title'])}</strong>
<p>{formatMessage(messages['non.compliant.password.message'])}</p>
@@ -47,7 +60,7 @@ const LoginFailureMessage = (props) => {
break;
}
case FORBIDDEN_REQUEST:
errorList = <p>{formatMessage(messages['login.rate.limit.reached.message'])}</p>;
errorMessage = <p>{formatMessage(messages['login.rate.limit.reached.message'])}</p>;
break;
case INACTIVE_USER: {
const supportLink = (
@@ -55,7 +68,7 @@ const LoginFailureMessage = (props) => {
{formatMessage(messages['contact.support.link'], { platformName: context.platformName })}
</a>
);
errorList = (
errorMessage = (
<p>
<FormattedMessage
id="login.inactive.user.error"
@@ -64,7 +77,7 @@ const LoginFailureMessage = (props) => {
check your spam folders or {supportLink}."
values={{
lineBreak: <br />,
email: <strong className="data-hj-suppress">{props.loginError.email}</strong>,
email: <strong className="data-hj-suppress">{context.email}</strong>,
supportLink,
}}
/>
@@ -79,7 +92,7 @@ const LoginFailureMessage = (props) => {
{formatMessage(messages['tpa.account.link'], { provider: context.provider })}
</a>
);
errorList = (
errorMessage = (
<p>
<FormattedMessage
id="allowed.domain.login.error"
@@ -92,7 +105,7 @@ const LoginFailureMessage = (props) => {
break;
}
case INVALID_FORM:
errorList = <p>{formatMessage(messages['login.form.invalid.error.message'])}</p>;
errorMessage = <p>{formatMessage(messages['login.form.invalid.error.message'])}</p>;
break;
case FAILED_LOGIN_ATTEMPT: {
resetLink = (
@@ -100,7 +113,7 @@ const LoginFailureMessage = (props) => {
{formatMessage(messages['login.incorrect.credentials.error.before.account.blocked.text'])}
</Hyperlink>
);
errorList = (
errorMessage = (
<>
<p>
<FormattedMessage
@@ -124,7 +137,7 @@ const LoginFailureMessage = (props) => {
break;
}
case ACCOUNT_LOCKED_OUT: {
errorList = (
errorMessage = (
<>
<p>{formatMessage(messages['account.locked.out.message.1'])}</p>
<p>
@@ -141,9 +154,9 @@ const LoginFailureMessage = (props) => {
}
case INCORRECT_EMAIL_PASSWORD:
if (context.failureCount <= 1) {
errorList = <p>{formatMessage(messages['login.incorrect.credentials.error'])}</p>;
errorMessage = <p>{formatMessage(messages['login.incorrect.credentials.error'])}</p>;
} else if (context.failureCount === 2) {
errorList = (
errorMessage = (
<p>
<FormattedMessage
id="login.incorrect.credentials.error.with.reset.link"
@@ -161,60 +174,56 @@ const LoginFailureMessage = (props) => {
}
return (
<ChangePasswordPrompt
redirectUrl={props.loginError.redirectUrl}
redirectUrl={context.redirectUrl}
variant="nudge"
/>
);
case REQUIRE_PASSWORD_CHANGE:
return <ChangePasswordPrompt />;
case TPA_AUTHENTICATION_FAILURE:
errorList = (
<p>{formatMessage(messages['login.tpa.authentication.failure'], {
platform_name: getConfig().SITE_NAME,
lineBreak: <br />,
errorMessage: context.errorMessage,
})}
errorMessage = (
<p>
{formatMessage(messages['login.tpa.authentication.failure'], {
platform_name: getConfig().SITE_NAME,
lineBreak: <br />,
errorMessage: context.errorMessage,
})}
</p>
);
break;
case INTERNAL_SERVER_ERROR:
default:
errorList = <p>{formatMessage(messages['internal.server.error.message'])}</p>;
errorMessage = <p>{formatMessage(messages['internal.server.error.message'])}</p>;
break;
}
return (
<Alert id="login-failure-alert" className="mb-5" variant="danger" icon={Error}>
<Alert.Heading>{formatMessage(messages['login.failure.header.title'])}</Alert.Heading>
{ errorList }
{ errorMessage }
</Alert>
);
};
LoginFailureMessage.defaultProps = {
loginError: {
redirectUrl: null,
errorCode: null,
errorMessage: null,
},
context: {},
};
LoginFailureMessage.propTypes = {
loginError: PropTypes.shape({
context: PropTypes.shape({
supportLink: PropTypes.string,
platformName: PropTypes.string,
tpaHint: PropTypes.string,
provider: PropTypes.string,
allowedDomain: PropTypes.string,
remainingAttempts: PropTypes.number,
failureCount: PropTypes.number,
errorMessage: PropTypes.string,
}),
context: PropTypes.shape({
supportLink: PropTypes.string,
platformName: PropTypes.string,
tpaHint: PropTypes.string,
provider: PropTypes.string,
allowedDomain: PropTypes.string,
remainingAttempts: PropTypes.number,
failureCount: PropTypes.number,
errorMessage: PropTypes.string,
email: PropTypes.string,
errorCode: PropTypes.string,
redirectUrl: PropTypes.string,
}),
errorCode: PropTypes.string.isRequired,
errorCount: PropTypes.number.isRequired,
};
export default LoginFailureMessage;

View File

@@ -1,13 +1,12 @@
import React from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, useIntl } from '@edx/frontend-platform/i18n';
import {
Form, Hyperlink, Icon, StatefulButton,
Form, StatefulButton,
} from '@openedx/paragon';
import { Institution } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import Skeleton from 'react-loading-skeleton';
@@ -15,21 +14,26 @@ import { Link } from 'react-router-dom';
import AccountActivationMessage from './AccountActivationMessage';
import {
loginRemovePasswordResetBanner, loginRequest, loginRequestFailure, loginRequestReset, setLoginFormData,
backupLoginFormBegin,
dismissPasswordResetBanner,
loginRequest,
} from './data/actions';
import { INVALID_FORM, TPA_AUTHENTICATION_FAILURE } from './data/constants';
import { loginErrorSelector, loginFormDataSelector, loginRequestSelector } from './data/selectors';
import LoginFailureMessage from './LoginFailure';
import messages from './messages';
import {
FormGroup, InstitutionLogistration, PasswordField, RedirectLogistration,
RenderInstitutionButton, SocialAuthProviders, ThirdPartyAuthAlert,
FormGroup,
InstitutionLogistration,
PasswordField,
RedirectLogistration,
ThirdPartyAuthAlert,
} from '../common-components';
import { getThirdPartyAuthContext } from '../common-components/data/actions';
import { thirdPartyAuthContextSelector } from '../common-components/data/selectors';
import EnterpriseSSO from '../common-components/EnterpriseSSO';
import ThirdPartyAuth from '../common-components/ThirdPartyAuth';
import {
DEFAULT_STATE, ENTERPRISE_LOGIN_URL, PENDING_STATE, RESET_PAGE,
DEFAULT_STATE, PENDING_STATE, RESET_PAGE,
} from '../data/constants';
import {
getActivationStatus,
@@ -37,324 +41,313 @@ import {
getTpaHint,
getTpaProvider,
updatePathWithQueryParams,
windowScrollTo,
} from '../data/utils';
import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess';
class LoginPage extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
password: this.props.loginFormData.password,
emailOrUsername: this.props.loginFormData.emailOrUsername,
errors: {
emailOrUsername: this.props.loginFormData.errors.emailOrUsername,
password: this.props.loginFormData.errors.password,
},
isSubmitted: false,
};
this.queryParams = getAllPossibleQueryParams();
this.tpaHint = getTpaHint();
}
const LoginPage = (props) => {
const {
backedUpFormData,
loginErrorCode,
loginErrorContext,
loginResult,
shouldBackupState,
thirdPartyAuthContext: {
providers,
currentProvider,
secondaryProviders,
finishAuthUrl,
platformName,
errorMessage: thirdPartyErrorMessage,
},
thirdPartyAuthApiStatus,
institutionLogin,
showResetPasswordSuccessBanner,
submitState,
// Actions
backupFormState,
handleInstitutionLogin,
getTPADataFromBackend,
} = props;
const { formatMessage } = useIntl();
const activationMsgType = getActivationStatus();
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
componentDidMount() {
const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields });
const [errorCode, setErrorCode] = useState({ type: '', count: 0, context: {} });
const [errors, setErrors] = useState({ ...backedUpFormData.errors });
const tpaHint = getTpaHint();
useEffect(() => {
sendPageEvent('login_and_registration', 'login');
const payload = { ...this.queryParams };
}, []);
if (this.tpaHint) {
payload.tpa_hint = this.tpaHint;
useEffect(() => {
const payload = { ...queryParams };
if (tpaHint) {
payload.tpa_hint = tpaHint;
}
this.props.getThirdPartyAuthContext(payload);
this.props.loginRequestReset();
}
shouldComponentUpdate(nextProps) {
if (nextProps.loginFormData && this.props.loginFormData !== nextProps.loginFormData) {
// Ensuring browser's autofill user credentials get filled and their state persists in the redux store.
const nextState = {
emailOrUsername: nextProps.loginFormData.emailOrUsername || this.state.emailOrUsername,
password: nextProps.loginFormData.password || this.state.password,
};
this.setState({
...nextProps.loginFormData,
...nextState,
getTPADataFromBackend(payload);
}, [getTPADataFromBackend, queryParams, tpaHint]);
/**
* Backup the login form in redux when login page is toggled.
*/
useEffect(() => {
if (shouldBackupState) {
backupFormState({
formFields: { ...formFields },
errors: { ...errors },
});
return false;
}
return true;
}
}, [shouldBackupState, formFields, errors, backupFormState]);
componentWillUnmount() {
if (this.props.resetPassword) {
this.props.loginRemovePasswordResetBanner();
useEffect(() => {
if (loginErrorCode) {
setErrorCode(prevState => ({
type: loginErrorCode,
count: prevState.count + 1,
context: { ...loginErrorContext },
}));
}
}
}, [loginErrorCode, loginErrorContext]);
getEnterPriseLoginURL() {
return getConfig().LMS_BASE_URL + ENTERPRISE_LOGIN_URL;
}
handleSubmit = (e) => {
e.preventDefault();
if (this.props.resetPassword) {
this.props.loginRemovePasswordResetBanner();
}
this.setState({ isSubmitted: true });
const { emailOrUsername, password } = this.state;
const emailValidationError = this.validateEmail(emailOrUsername);
const passwordValidationError = this.validatePassword(password);
if (emailValidationError !== '' || passwordValidationError !== '') {
this.props.setLoginFormData({
errors: {
emailOrUsername: emailValidationError,
password: passwordValidationError,
useEffect(() => {
if (thirdPartyErrorMessage) {
setErrorCode((prevState) => ({
type: TPA_AUTHENTICATION_FAILURE,
count: prevState.count + 1,
context: {
errorMessage: thirdPartyErrorMessage,
},
});
this.props.loginRequestFailure({
errorCode: INVALID_FORM,
});
}));
}
}, [thirdPartyErrorMessage]);
const validateFormFields = (payload) => {
const { emailOrUsername, password } = payload;
const fieldErrors = { ...errors };
if (emailOrUsername === '') {
fieldErrors.emailOrUsername = formatMessage(messages['email.validation.message']);
} else if (emailOrUsername.length < 2) {
fieldErrors.emailOrUsername = formatMessage(messages['username.or.email.format.validation.less.chars.message']);
}
if (password === '') {
fieldErrors.password = formatMessage(messages['password.validation.message']);
}
return { ...fieldErrors };
};
const handleSubmit = (event) => {
event.preventDefault();
if (showResetPasswordSuccessBanner) {
props.dismissPasswordResetBanner();
}
const formData = { ...formFields };
const validationErrors = validateFormFields(formData);
if (validationErrors.emailOrUsername || validationErrors.password) {
setErrors({ ...validationErrors });
setErrorCode(prevState => ({ type: INVALID_FORM, count: prevState.count + 1, context: {} }));
return;
}
// add query params to the payload
const payload = {
email_or_username: emailOrUsername, password, ...this.queryParams,
email_or_username: formData.emailOrUsername,
password: formData.password,
...queryParams,
};
this.props.loginRequest(payload);
props.loginRequest(payload);
};
handleOnFocus = (e) => {
const { errors } = this.state;
errors[e.target.name] = '';
this.props.setLoginFormData({
errors,
});
const handleOnChange = (event) => {
const { name, value } = event.target;
setFormFields(prevState => ({ ...prevState, [name]: value }));
};
handleOnBlur = (e) => {
const payload = {
[e.target.name]: e.target.value,
};
this.props.setLoginFormData(payload);
const handleOnFocus = (event) => {
const { name } = event.target;
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
};
handleForgotPasswordLinkClickEvent = () => {
const trackForgotPasswordLinkClick = () => {
sendTrackEvent('edx.bi.password-reset_form.toggled', { category: 'user-engagement' });
};
validateEmail(email) {
const { errors } = this.state;
const { provider, skipHintedLogin } = getTpaProvider(tpaHint, providers, secondaryProviders);
if (email === '') {
errors.emailOrUsername = this.props.intl.formatMessage(messages['email.validation.message']);
} else if (email.length < 3) {
errors.emailOrUsername = this.props.intl.formatMessage(messages['username.or.email.format.validation.less.chars.message']);
} else {
errors.emailOrUsername = '';
if (tpaHint) {
if (thirdPartyAuthApiStatus === PENDING_STATE) {
return <Skeleton height={36} />;
}
if (skipHintedLogin) {
window.location.href = getConfig().LMS_BASE_URL + provider.loginUrl;
return null;
}
if (provider) {
return <EnterpriseSSO provider={provider} />;
}
return errors.emailOrUsername;
}
validatePassword(password) {
const { errors } = this.state;
errors.password = password.length > 0 ? '' : this.props.intl.formatMessage(messages['password.validation.message']);
return errors.password;
}
renderThirdPartyAuth(providers, secondaryProviders, currentProvider, thirdPartyAuthApiStatus, intl) {
const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider;
const isSocialAuthActive = !!providers.length && !currentProvider;
const isEnterpriseLoginDisabled = getConfig().DISABLE_ENTERPRISE_LOGIN;
if (institutionLogin) {
return (
<>
{(isSocialAuthActive || (isEnterpriseLoginDisabled && isInstitutionAuthActive))
&& (
<div className="mt-4 mb-3 h4">
{intl.formatMessage(messages['login.other.options.heading'])}
</div>
)}
{(!isEnterpriseLoginDisabled && isSocialAuthActive) && (
<Hyperlink className="btn btn-link btn-sm text-body p-0 mb-4" destination={this.getEnterPriseLoginURL()}>
<Icon src={Institution} className="institute-icon" />
{intl.formatMessage(messages['enterprise.login.btn.text'])}
</Hyperlink>
)}
{thirdPartyAuthApiStatus === PENDING_STATE ? (
<Skeleton className="tpa-skeleton mb-3" height={30} count={2} />
) : (
<>
{(isEnterpriseLoginDisabled && isInstitutionAuthActive) && (
<RenderInstitutionButton
onSubmitHandler={this.props.handleInstitutionLogin}
buttonTitle={intl.formatMessage(messages['institution.login.button'])}
/>
)}
{isSocialAuthActive && (
<div className="row m-0">
<SocialAuthProviders socialAuthProviders={providers} />
</div>
)}
</>
)}
</>
<InstitutionLogistration
secondaryProviders={secondaryProviders}
headingTitle={formatMessage(messages['institution.login.page.title'])}
/>
);
}
renderForm(
currentProvider,
providers,
secondaryProviders,
thirdPartyAuthContext,
thirdPartyAuthApiStatus,
submitState,
intl,
) {
const activationMsgType = getActivationStatus();
if (this.props.institutionLogin) {
return (
<InstitutionLogistration
secondaryProviders={thirdPartyAuthContext.secondaryProviders}
headingTitle={intl.formatMessage(messages['institution.login.page.title'])}
return (
<>
<Helmet>
<title>{formatMessage(messages['login.page.title'], { siteName: getConfig().SITE_NAME })}</title>
</Helmet>
<RedirectLogistration
success={loginResult.success}
redirectUrl={loginResult.redirectUrl}
finishAuthUrl={finishAuthUrl}
/>
<div className="mw-xs mt-3 mb-2">
<LoginFailureMessage
errorCode={errorCode.type}
errorCount={errorCode.count}
context={errorCode.context}
/>
);
}
const tpaAuthenticationError = {};
if (thirdPartyAuthContext.errorMessage) {
tpaAuthenticationError.context = {
errorMessage: thirdPartyAuthContext.errorMessage,
};
tpaAuthenticationError.errorCode = TPA_AUTHENTICATION_FAILURE;
}
return (
<>
<Helmet>
<title>{intl.formatMessage(messages['login.page.title'],
{ siteName: getConfig().SITE_NAME })}
</title>
</Helmet>
<RedirectLogistration
success={this.props.loginResult.success}
redirectUrl={this.props.loginResult.redirectUrl}
finishAuthUrl={thirdPartyAuthContext.finishAuthUrl}
<ThirdPartyAuthAlert
currentProvider={currentProvider}
platformName={platformName}
/>
<div className="mw-xs mt-3">
<ThirdPartyAuthAlert
currentProvider={thirdPartyAuthContext.currentProvider}
platformName={thirdPartyAuthContext.platformName}
<AccountActivationMessage
messageType={activationMsgType}
/>
{showResetPasswordSuccessBanner && <ResetPasswordSuccess />}
<Form id="sign-in-form" name="sign-in-form">
<FormGroup
name="emailOrUsername"
value={formFields.emailOrUsername}
autoComplete="on"
handleChange={handleOnChange}
handleFocus={handleOnFocus}
errorMessage={errors.emailOrUsername}
floatingLabel={formatMessage(messages['login.user.identity.label'])}
/>
{this.props.loginError ? <LoginFailureMessage loginError={this.props.loginError} /> : null}
{thirdPartyAuthContext.errorMessage ? <LoginFailureMessage loginError={tpaAuthenticationError} /> : null}
{submitState === DEFAULT_STATE && this.state.isSubmitted ? windowScrollTo({ left: 0, top: 0, behavior: 'smooth' }) : null}
{activationMsgType && <AccountActivationMessage messageType={activationMsgType} />}
{this.props.resetPassword && !this.props.loginError ? <ResetPasswordSuccess /> : null}
<Form name="sign-in-form" id="sign-in-form">
<FormGroup
name="emailOrUsername"
value={this.state.emailOrUsername}
autoComplete="on"
handleChange={(e) => this.setState({ emailOrUsername: e.target.value, isSubmitted: false })}
handleFocus={this.handleOnFocus}
handleBlur={this.handleOnBlur}
errorMessage={this.state.errors.emailOrUsername}
floatingLabel={intl.formatMessage(messages['login.user.identity.label'])}
/>
<PasswordField
name="password"
value={this.state.password}
autoComplete="off"
showRequirements={false}
handleChange={(e) => this.setState({ password: e.target.value, isSubmitted: false })}
handleFocus={this.handleOnFocus}
handleBlur={this.handleOnBlur}
errorMessage={this.state.errors.password}
floatingLabel={intl.formatMessage(messages['login.password.label'])}
/>
<StatefulButton
name="sign-in"
id="sign-in"
type="submit"
variant="brand"
className="login-button-width"
state={submitState}
labels={{
default: intl.formatMessage(messages['sign.in.button']),
pending: '',
}}
onClick={this.handleSubmit}
onMouseDown={(e) => e.preventDefault()}
/>
<Link
id="forgot-password"
name="forgot-password"
className="btn btn-link font-weight-500 text-body"
to={updatePathWithQueryParams(RESET_PAGE)}
onClick={this.handleForgotPasswordLinkClickEvent}
>
{intl.formatMessage(messages['forgot.password'])}
</Link>
{this.renderThirdPartyAuth(providers, secondaryProviders, currentProvider, thirdPartyAuthApiStatus, intl)}
</Form>
</div>
</>
);
}
<PasswordField
name="password"
value={formFields.password}
autoComplete="off"
showScreenReaderText={false}
showRequirements={false}
handleChange={handleOnChange}
handleFocus={handleOnFocus}
errorMessage={errors.password}
floatingLabel={formatMessage(messages['login.password.label'])}
/>
<StatefulButton
name="sign-in"
id="sign-in"
type="submit"
variant="brand"
className="login-button-width"
state={submitState}
labels={{
default: formatMessage(messages['sign.in.button']),
pending: '',
}}
onClick={handleSubmit}
onMouseDown={(event) => event.preventDefault()}
/>
<Link
id="forgot-password"
name="forgot-password"
className="btn btn-link font-weight-500 text-body"
to={updatePathWithQueryParams(RESET_PAGE)}
onClick={trackForgotPasswordLinkClick}
>
{formatMessage(messages['forgot.password'])}
</Link>
<ThirdPartyAuth
currentProvider={currentProvider}
providers={providers}
secondaryProviders={secondaryProviders}
handleInstitutionLogin={handleInstitutionLogin}
thirdPartyAuthApiStatus={thirdPartyAuthApiStatus}
isLoginPage
/>
</Form>
</div>
</>
);
};
render() {
const {
intl, submitState, thirdPartyAuthContext, thirdPartyAuthApiStatus,
} = this.props;
const { currentProvider, providers, secondaryProviders } = this.props.thirdPartyAuthContext;
const mapStateToProps = state => {
const loginPageState = state.login;
return {
backedUpFormData: loginPageState.loginFormData,
loginErrorCode: loginPageState.loginErrorCode,
loginErrorContext: loginPageState.loginErrorContext,
loginResult: loginPageState.loginResult,
shouldBackupState: loginPageState.shouldBackupState,
showResetPasswordSuccessBanner: loginPageState.showResetPasswordSuccessBanner,
submitState: loginPageState.submitState,
thirdPartyAuthContext: thirdPartyAuthContextSelector(state),
thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus,
};
};
if (this.tpaHint) {
if (thirdPartyAuthApiStatus === PENDING_STATE) {
return <Skeleton height={36} />;
}
const { provider, skipHintedLogin } = getTpaProvider(this.tpaHint, providers, secondaryProviders);
if (skipHintedLogin) {
window.location.href = getConfig().LMS_BASE_URL + provider.loginUrl;
return null;
}
return provider ? (<EnterpriseSSO provider={provider} intl={intl} />) : this.renderForm(
currentProvider,
providers,
secondaryProviders,
thirdPartyAuthContext,
thirdPartyAuthApiStatus,
submitState,
intl,
);
}
return this.renderForm(
currentProvider,
providers,
secondaryProviders,
thirdPartyAuthContext,
thirdPartyAuthApiStatus,
submitState,
intl,
);
}
}
LoginPage.propTypes = {
backedUpFormData: PropTypes.shape({
formFields: PropTypes.shape({}),
errors: PropTypes.shape({}),
}),
loginErrorCode: PropTypes.string,
loginErrorContext: PropTypes.shape({
email: PropTypes.string,
redirectUrl: PropTypes.string,
context: PropTypes.shape({}),
}),
loginResult: PropTypes.shape({
redirectUrl: PropTypes.string,
success: PropTypes.bool,
}),
shouldBackupState: PropTypes.bool,
showResetPasswordSuccessBanner: PropTypes.bool,
submitState: PropTypes.string,
thirdPartyAuthApiStatus: PropTypes.string,
institutionLogin: PropTypes.bool.isRequired,
thirdPartyAuthContext: PropTypes.shape({
currentProvider: PropTypes.string,
errorMessage: PropTypes.string,
platformName: PropTypes.string,
providers: PropTypes.arrayOf(PropTypes.shape({})),
secondaryProviders: PropTypes.arrayOf(PropTypes.shape({})),
finishAuthUrl: PropTypes.string,
}),
// Actions
backupFormState: PropTypes.func.isRequired,
dismissPasswordResetBanner: PropTypes.func.isRequired,
loginRequest: PropTypes.func.isRequired,
getTPADataFromBackend: PropTypes.func.isRequired,
handleInstitutionLogin: PropTypes.func.isRequired,
};
LoginPage.defaultProps = {
loginResult: null,
loginError: null,
loginFormData: {
emailOrUsername: '',
password: '',
backedUpFormData: {
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '',
password: '',
emailOrUsername: '', password: '',
},
},
resetPassword: false,
loginErrorCode: null,
loginErrorContext: {},
loginResult: {},
shouldBackupState: false,
showResetPasswordSuccessBanner: false,
submitState: DEFAULT_STATE,
thirdPartyAuthApiStatus: 'pending',
thirdPartyAuthApiStatus: PENDING_STATE,
thirdPartyAuthContext: {
currentProvider: null,
errorMessage: null,
@@ -364,68 +357,12 @@ LoginPage.defaultProps = {
},
};
LoginPage.propTypes = {
getThirdPartyAuthContext: PropTypes.func.isRequired,
intl: PropTypes.shape({
formatMessage: PropTypes.func,
}).isRequired,
loginError: PropTypes.shape({}),
loginRequest: PropTypes.func.isRequired,
loginRequestFailure: PropTypes.func.isRequired,
loginRequestReset: PropTypes.func.isRequired,
setLoginFormData: PropTypes.func.isRequired,
loginRemovePasswordResetBanner: PropTypes.func.isRequired,
loginResult: PropTypes.shape({
redirectUrl: PropTypes.string,
success: PropTypes.bool,
}),
loginFormData: PropTypes.shape({
emailOrUsername: PropTypes.string,
password: PropTypes.string,
errors: PropTypes.shape({
emailOrUsername: PropTypes.string,
password: PropTypes.string,
}),
}),
resetPassword: PropTypes.bool,
submitState: PropTypes.string,
thirdPartyAuthApiStatus: PropTypes.string,
thirdPartyAuthContext: PropTypes.shape({
currentProvider: PropTypes.string,
errorMessage: PropTypes.string,
platformName: PropTypes.string,
providers: PropTypes.arrayOf(PropTypes.shape({})),
secondaryProviders: PropTypes.arrayOf(PropTypes.shape({})),
finishAuthUrl: PropTypes.string,
}),
institutionLogin: PropTypes.bool.isRequired,
handleInstitutionLogin: PropTypes.func.isRequired,
};
const mapStateToProps = state => {
const loginResult = loginRequestSelector(state);
const thirdPartyAuthContext = thirdPartyAuthContextSelector(state);
const loginError = loginErrorSelector(state);
const loginFormData = loginFormDataSelector(state);
return {
submitState: state.login.submitState,
thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus,
loginError,
loginResult,
thirdPartyAuthContext,
loginFormData,
resetPassword: state.login.resetPassword,
};
};
export default connect(
mapStateToProps,
{
getThirdPartyAuthContext,
backupFormState: backupLoginFormBegin,
dismissPasswordResetBanner,
loginRequest,
loginRequestFailure,
loginRequestReset,
setLoginFormData,
loginRemovePasswordResetBanner,
getTPADataFromBackend: getThirdPartyAuthContext,
},
)(injectIntl(LoginPage));

View File

@@ -1,8 +1,18 @@
import { AsyncActionType } from '../../data/utils';
export const BACKUP_LOGIN_DATA = new AsyncActionType('LOGIN', 'BACKUP_LOGIN_DATA');
export const LOGIN_REQUEST = new AsyncActionType('LOGIN', 'REQUEST');
export const LOGIN_PERSIST_FORM_DATA = 'LOGIN_PERSIST_FORM_DATA';
export const LOGIN_REMOVE_PASSWORD_RESET_BANNER = 'LOGIN_REMOVE_PASSWORD_RESET_BANNER';
export const DISMISS_PASSWORD_RESET_BANNER = 'DISMISS_PASSWORD_RESET_BANNER';
// Backup login form data
export const backupLoginForm = () => ({
type: BACKUP_LOGIN_DATA.BASE,
});
export const backupLoginFormBegin = (data) => ({
type: BACKUP_LOGIN_DATA.BEGIN,
payload: { ...data },
});
// Login
export const loginRequest = creds => ({
@@ -24,15 +34,6 @@ export const loginRequestFailure = (loginError) => ({
payload: { loginError },
});
export const loginRequestReset = () => ({
type: LOGIN_REQUEST.RESET,
});
export const setLoginFormData = (formData) => ({
type: LOGIN_PERSIST_FORM_DATA,
payload: { formData },
});
export const loginRemovePasswordResetBanner = () => ({
type: LOGIN_REMOVE_PASSWORD_RESET_BANNER,
export const dismissPasswordResetBanner = () => ({
type: DISMISS_PASSWORD_RESET_BANNER,
});

View File

@@ -1,64 +1,69 @@
import { LOGIN_PERSIST_FORM_DATA, LOGIN_REMOVE_PASSWORD_RESET_BANNER, LOGIN_REQUEST } from './actions';
import {
BACKUP_LOGIN_DATA,
DISMISS_PASSWORD_RESET_BANNER,
LOGIN_REQUEST,
} from './actions';
import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants';
import { RESET_PASSWORD } from '../../reset-password';
export const defaultState = {
loginError: null,
loginErrorCode: '',
loginErrorContext: {},
loginResult: {},
resetPassword: false,
loginFormData: {
password: '',
emailOrUsername: '',
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '',
password: '',
emailOrUsername: '', password: '',
},
},
shouldBackupState: false,
showResetPasswordSuccessBanner: false,
submitState: DEFAULT_STATE,
};
const reducer = (state = defaultState, action = {}) => {
switch (action.type) {
case BACKUP_LOGIN_DATA.BASE:
return {
...state,
shouldBackupState: true,
};
case BACKUP_LOGIN_DATA.BEGIN:
return {
...defaultState,
loginFormData: { ...action.payload },
};
case LOGIN_REQUEST.BEGIN:
return {
...state,
showResetPasswordSuccessBanner: false,
submitState: PENDING_STATE,
resetPassword: false,
};
case LOGIN_REQUEST.SUCCESS:
return {
...state,
loginResult: action.payload,
};
case LOGIN_REQUEST.FAILURE:
case LOGIN_REQUEST.FAILURE: {
const { email, loginError, redirectUrl } = action.payload;
return {
...state,
loginError: action.payload.loginError,
loginErrorCode: loginError.errorCode,
loginErrorContext: { ...loginError.context, email, redirectUrl },
submitState: DEFAULT_STATE,
};
case LOGIN_REQUEST.RESET:
return {
...state,
loginError: null,
};
}
case RESET_PASSWORD.SUCCESS:
return {
...state,
resetPassword: true,
showResetPasswordSuccessBanner: true,
};
case LOGIN_PERSIST_FORM_DATA: {
const { formData } = action.payload;
case DISMISS_PASSWORD_RESET_BANNER: {
return {
...state,
loginFormData: {
...state.loginFormData,
...formData,
},
};
}
case LOGIN_REMOVE_PASSWORD_RESET_BANNER: {
return {
...state,
resetPassword: false,
showResetPasswordSuccessBanner: false,
};
}
default:

View File

@@ -1,20 +0,0 @@
import { createSelector } from 'reselect';
export const storeName = 'login';
export const loginSelector = state => ({ ...state[storeName] });
export const loginRequestSelector = createSelector(
loginSelector,
login => login.loginResult,
);
export const loginErrorSelector = createSelector(
loginSelector,
login => login.loginError,
);
export const loginFormDataSelector = createSelector(
loginSelector,
login => login.loginFormData,
);

View File

@@ -1,57 +1,154 @@
import {
LOGIN_PERSIST_FORM_DATA, LOGIN_REMOVE_PASSWORD_RESET_BANNER,
} from '../actions';
import { getConfig } from '@edx/frontend-platform';
import { DEFAULT_REDIRECT_URL, DEFAULT_STATE, PENDING_STATE } from '../../../data/constants';
import { RESET_PASSWORD } from '../../../reset-password';
import { BACKUP_LOGIN_DATA, DISMISS_PASSWORD_RESET_BANNER, LOGIN_REQUEST } from '../actions';
import reducer from '../reducers';
describe('login reducer', () => {
it('should set loginFormData', () => {
const state = {
loginFormData: {
password: '',
emailOrUsername: '',
errors: {
emailOrUsername: '',
password: '',
},
const defaultState = {
loginErrorCode: '',
loginErrorContext: {},
loginResult: {},
loginFormData: {
formFields: {
emailOrUsername: '', password: '',
},
resetPassword: false,
};
const formData = {
password: 'johndoe',
emailOrUsername: 'john@gmail.com',
};
errors: {
emailOrUsername: '', password: '',
},
},
shouldBackupState: false,
showResetPasswordSuccessBanner: false,
submitState: DEFAULT_STATE,
};
it('should update state to show reset password success banner', () => {
const action = {
type: LOGIN_PERSIST_FORM_DATA,
payload: { formData },
type: RESET_PASSWORD.SUCCESS,
};
expect(
reducer(state, action),
reducer(defaultState, action),
).toEqual(
{
loginFormData: {
...state.loginFormData,
password: 'johndoe',
emailOrUsername: 'john@gmail.com',
},
resetPassword: false,
...defaultState,
showResetPasswordSuccessBanner: true,
},
);
});
it('should set resetPassword', () => {
const state = {
resetPassword: true,
};
it('should set the flag which keeps the login form data in redux state', () => {
const action = {
type: LOGIN_REMOVE_PASSWORD_RESET_BANNER,
type: BACKUP_LOGIN_DATA.BASE,
};
expect(
reducer(state, action),
reducer(defaultState, action),
).toEqual(
{
resetPassword: false,
...defaultState,
shouldBackupState: true,
},
);
});
it('should backup the login form data', () => {
const payload = {
formFields: {
emailOrUsername: 'test@exmaple.com',
password: 'test1',
},
errors: {
emailOrUsername: '', password: '',
},
};
const action = {
type: BACKUP_LOGIN_DATA.BEGIN,
payload,
};
expect(
reducer(defaultState, action),
).toEqual(
{
...defaultState,
loginFormData: payload,
},
);
});
it('should update state to dismiss reset password banner', () => {
const action = {
type: DISMISS_PASSWORD_RESET_BANNER,
};
expect(
reducer(defaultState, action),
).toEqual(
{
...defaultState,
showResetPasswordSuccessBanner: false,
},
);
});
it('should start the login request', () => {
const action = {
type: LOGIN_REQUEST.BEGIN,
};
expect(reducer(defaultState, action)).toEqual(
{
...defaultState,
showResetPasswordSuccessBanner: false,
submitState: PENDING_STATE,
},
);
});
it('should set redirect url on login success action', () => {
const payload = {
redirectUrl: `${getConfig().BASE_URL}${DEFAULT_REDIRECT_URL}`,
success: true,
};
const action = {
type: LOGIN_REQUEST.SUCCESS,
payload,
};
expect(reducer(defaultState, action)).toEqual(
{
...defaultState,
loginResult: payload,
},
);
});
it('should set the error data on login request failure', () => {
const payload = {
loginError: {
success: false,
value: 'Email or password is incorrect.',
errorCode: 'incorrect-email-or-password',
context: {
failureCount: 0,
},
},
email: 'test@example.com',
redirectUrl: '',
};
const action = {
type: LOGIN_REQUEST.FAILURE,
payload,
};
expect(reducer(defaultState, action)).toEqual(
{
...defaultState,
loginErrorCode: payload.loginError.errorCode,
loginErrorContext: { ...payload.loginError.context, email: payload.email, redirectUrl: payload.redirectUrl },
submitState: DEFAULT_STATE,
},
);
});

View File

@@ -1,4 +1,5 @@
export const storeName = 'login';
export { default as LoginPage } from './LoginPage';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { storeName } from './data/selectors';

View File

@@ -42,11 +42,6 @@ const messages = defineMessages({
defaultMessage: 'Choose your institution from the list below',
description: 'Heading of the institutions list',
},
'login.other.options.heading': {
id: 'login.other.options.heading',
defaultMessage: 'Or sign in with:',
description: 'Text that appears above other sign in options like social auth buttons',
},
'non.compliant.password.title': {
id: 'non.compliant.password.title',
defaultMessage: 'We recently changed our password requirements',
@@ -64,15 +59,10 @@ const messages = defineMessages({
defaultMessage: 'To protect your account, it\'s been temporarily locked. Try again in 30 minutes.',
description: 'Part of message for when user account has been locked out after multiple failed login attempts',
},
'enterprise.login.btn.text': {
id: 'enterprise.login.btn.text',
defaultMessage: 'Company or school credentials',
description: 'Company or school login link text.',
},
'username.or.email.format.validation.less.chars.message': {
id: 'username.or.email.format.validation.less.chars.message',
defaultMessage: 'Username or email must have at least 3 characters.',
description: 'Validation message that appears when username or email address is less than 3 characters',
defaultMessage: 'Username or email must have at least 2 characters.',
description: 'Validation message that appears when username or email address is less than 2 characters',
},
'email.validation.message': {
id: 'email.validation.message',

View File

@@ -2,7 +2,9 @@ import React from 'react';
import { mergeConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import {
render, screen,
} from '@testing-library/react';
import AccountActivationMessage from '../AccountActivationMessage';
import { ACCOUNT_ACTIVATION_MESSAGE } from '../data/constants';
@@ -17,18 +19,22 @@ describe('AccountActivationMessage', () => {
});
it('should match account already activated message', () => {
const accountActivationMessage = mount(
render(
<IntlProvider locale="en">
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.INFO} />
</IntlProvider>,
);
const expectedMessage = 'This account has already been activated.';
expect(accountActivationMessage.find('#account-activation-message').find('div').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#account-activation-message' },
).textContent).toBe(expectedMessage);
});
it('should match account activated success message', () => {
const accountActivationMessage = mount(
render(
<IntlProvider locale="en">
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.SUCCESS} />
</IntlProvider>,
@@ -37,11 +43,15 @@ describe('AccountActivationMessage', () => {
const expectedMessage = 'Success! You have activated your account.'
+ 'You will now receive email updates and alerts from us related to '
+ 'the courses you are enrolled in. Sign in to continue.';
expect(accountActivationMessage.find('#account-activation-message').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#account-activation-message' },
).textContent).toBe(expectedMessage);
});
it('should match account activation error message', () => {
const accountActivationMessage = mount(
render(
<IntlProvider locale="en">
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.ERROR} />
</IntlProvider>,
@@ -49,17 +59,22 @@ describe('AccountActivationMessage', () => {
const expectedMessage = 'Your account could not be activated'
+ 'Something went wrong, please contact support to resolve this issue.';
expect(accountActivationMessage.find('#account-activation-message').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#account-activation-message' },
).textContent).toBe(expectedMessage);
});
it('should not display anything for invalid message type', () => {
const accountActivationMessage = mount(
const { container } = render(
<IntlProvider locale="en">
<IntlAccountActivationMessage messageType="invalid-message" />
</IntlProvider>,
);
expect(accountActivationMessage).toEqual({});
const accountActivationMessage = container.querySelectorAll('#account-activation-message');
expect(accountActivationMessage[0]).toBe(undefined);
});
});
@@ -71,36 +86,45 @@ describe('EmailConfirmationMessage', () => {
});
it('should match email already confirmed message', () => {
const accountVerificationMessage = mount(
render(
<IntlProvider locale="en">
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.INFO} />
</IntlProvider>,
);
const expectedMessage = 'This email has already been confirmed.';
expect(accountVerificationMessage.find('#account-activation-message').find('div').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#account-activation-message' },
).textContent).toBe(expectedMessage);
});
it('should match email confirmation success message', () => {
const accountVerificationMessage = mount(
render(
<IntlProvider locale="en">
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.SUCCESS} />
</IntlProvider>,
);
const expectedMessage = 'Success! You have confirmed your email.Sign in to continue.';
expect(accountVerificationMessage.find('#account-activation-message').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#account-activation-message' },
).textContent).toBe(expectedMessage);
});
it('should match email confirmation error message', () => {
const accountVerificationMessage = mount(
render(
<IntlProvider locale="en">
<IntlAccountActivationMessage messageType={ACCOUNT_ACTIVATION_MESSAGE.ERROR} />
</IntlProvider>,
);
const expectedMessage = 'Your email could not be confirmed'
+ 'Something went wrong, please contact support to resolve this issue.';
expect(accountVerificationMessage.find('#account-activation-message').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#account-activation-message' },
).textContent).toBe(expectedMessage);
});
});

View File

@@ -2,7 +2,9 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import {
fireEvent, render, screen,
} from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
@@ -39,7 +41,7 @@ describe('ChangePasswordPromptTests', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL };
const changePasswordPrompt = mount(
render(
<IntlProvider locale="en">
<MemoryRouter>
<IntlChangePasswordPrompt {...props} />
@@ -47,7 +49,7 @@ describe('ChangePasswordPromptTests', () => {
</IntlProvider>,
);
changePasswordPrompt.find('button#password-security-close').simulate('click');
fireEvent.click(screen.getByText('Close'));
expect(window.location.href).toBe(dashboardUrl);
});
@@ -56,7 +58,7 @@ describe('ChangePasswordPromptTests', () => {
variant: 'block',
};
const changePasswordPrompt = mount(
render(
<IntlProvider locale="en">
<MemoryRouter>
<IntlChangePasswordPrompt {...props} />
@@ -65,10 +67,12 @@ describe('ChangePasswordPromptTests', () => {
);
await act(async () => {
await changePasswordPrompt.find('div.pgn__modal-backdrop').first().simulate('click');
await fireEvent.click(screen.getByText(
'',
{ selector: '.pgn__modal-backdrop' },
));
});
changePasswordPrompt.update();
expect(mockedNavigator).toHaveBeenCalledWith(RESET_PAGE);
});
});

View File

@@ -1,10 +1,13 @@
import React from 'react';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import {
render, screen,
} from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import {
ACCOUNT_LOCKED_OUT,
ALLOWED_DOMAIN_LOGIN_ERROR,
FAILED_LOGIN_ATTEMPT,
FORBIDDEN_REQUEST,
@@ -39,12 +42,11 @@ describe('LoginFailureMessage', () => {
it('should match non compliant password error message', () => {
props = {
loginError: {
errorCode: NON_COMPLIANT_PASSWORD_EXCEPTION,
},
errorCode: NON_COMPLIANT_PASSWORD_EXCEPTION,
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
@@ -55,22 +57,24 @@ describe('LoginFailureMessage', () => {
+ 'password-reset message to the email address associated with this account. '
+ 'Thank you for helping us keep your data safe.';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toBe(expectedMessage);
});
it('should match inactive user error message', () => {
props = {
loginError: {
context: {
email: 'text@example.com',
errorCode: INACTIVE_USER,
context: {
platformName: 'openedX',
supportLink: 'http://support.openedx.test',
},
platformName: 'openedX',
supportLink: 'http://support.openedx.test',
},
errorCode: INACTIVE_USER,
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
@@ -80,157 +84,196 @@ describe('LoginFailureMessage', () => {
+ 'We just sent an activation link to text@example.com. If you do not receive an email, '
+ 'check your spam folders or contact openedX support.';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
expect(loginFailureMessage.find('#login-failure-alert').find('a').props().href).toEqual('http://support.openedx.test');
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toBe(expectedMessage);
expect(screen.getByRole('link', { name: 'contact openedX support' }).getAttribute('href')).toBe('http://support.openedx.test');
});
it('test match failed login attempt error', () => {
props = {
loginError: {
context: {
email: 'text@example.com',
errorCode: FAILED_LOGIN_ATTEMPT,
context: {
remainingAttempts: 3,
allowedFailureAttempts: 6,
resetLink: '/reset',
},
remainingAttempts: 3,
allowedFailureAttempts: 6,
resetLink: '/reset',
},
errorCode: FAILED_LOGIN_ATTEMPT,
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
const expectedMessage = 'We couldn\'t sign you in.The username, email or password you entered is incorrect. '
+ 'You have 3 more sign in attempts before your account is temporarily locked.If you\'ve forgotten your password, click here to reset it.';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toBe(expectedMessage);
});
it('test match failed login error first attempt', () => {
props = {
loginError: {
context: {
email: 'text@example.com',
errorCode: INCORRECT_EMAIL_PASSWORD,
context: {
failureCount: 1,
resetLink: '/reset',
},
failureCount: 1,
resetLink: '/reset',
},
errorCode: INCORRECT_EMAIL_PASSWORD,
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
const expectedMessage = 'We couldn\'t sign you in.The username, email, or password you entered is incorrect. Please try again.';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toBe(expectedMessage);
});
it('test match user account locked out', () => {
props = {
errorCode: ACCOUNT_LOCKED_OUT,
failureCount: 0,
};
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
const expectedMessage = 'We couldn\'t sign you in.To protect your account, it\'s been temporarily locked. Try again in 30 minutes.To be on the safe side, you can reset your password before trying again.';
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toBe(expectedMessage);
});
it('test match failed login error second attempt', () => {
props = {
loginError: {
context: {
email: 'text@example.com',
errorCode: INCORRECT_EMAIL_PASSWORD,
context: {
failureCount: 2,
resetLink: '/reset',
},
failureCount: 2,
resetLink: '/reset',
},
errorCode: INCORRECT_EMAIL_PASSWORD,
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
const expectedMessage = 'We couldn\'t sign you in.The username, email, or password you entered is incorrect. Please try again or reset your password.';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toBe(expectedMessage);
});
it('should match rate limit error message', () => {
props = {
loginError: {
errorCode: FORBIDDEN_REQUEST,
},
errorCode: FORBIDDEN_REQUEST,
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
const expectedMessage = 'We couldn\'t sign you in.Too many failed login attempts. Try again later.';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toBe(expectedMessage);
});
it('should match internal server error message', () => {
props = {
loginError: {
errorCode: INTERNAL_SERVER_ERROR,
},
errorCode: INTERNAL_SERVER_ERROR,
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
const expectedMessage = 'We couldn\'t sign you in.An error has occurred. Try refreshing the page, or check your internet connection.';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toBe(expectedMessage);
});
it('should match invalid form error message', () => {
props = {
loginError: {
errorCode: INVALID_FORM,
},
errorCode: INVALID_FORM,
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
const expectedMessage = 'We couldn\'t sign you in.Please fill in the fields below.';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toBe(expectedMessage);
});
it('should match internal server of error message', () => {
props = {
loginError: {
errorCode: 'invalid-error-code',
},
errorCode: 'invalid-error-code',
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
);
const expectedMessage = 'We couldn\'t sign you in.An error has occurred. Try refreshing the page, or check your internet connection.';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage);
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toBe(expectedMessage);
});
it('should match tpa authentication failed error message', () => {
props = {
loginError: {
errorCode: TPA_AUTHENTICATION_FAILURE,
context: {
errorMessage: 'An error occurred',
},
},
errorCode: TPA_AUTHENTICATION_FAILURE,
failureCount: 0,
context: { errorMessage: 'An error occurred' },
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
@@ -238,18 +281,24 @@ describe('LoginFailureMessage', () => {
const expectedMessageSubstring = 'We are sorry, you are not authorized to access';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toContain(expectedMessageSubstring);
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toContain('An error occurred');
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toContain(expectedMessageSubstring);
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toContain('An error occurred');
});
it('should show modal that nudges users to change password', () => {
props = {
loginError: {
errorCode: NUDGE_PASSWORD_CHANGE,
},
errorCode: NUDGE_PASSWORD_CHANGE,
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<MemoryRouter>
<IntlLoginFailureMessage {...props} />
@@ -257,21 +306,25 @@ describe('LoginFailureMessage', () => {
</IntlProvider>,
);
expect(loginFailureMessage.find('.pgn__modal-title').text()).toEqual('Password security');
expect(loginFailureMessage.find('.pgn__modal-body').text()).toEqual(
'Our system detected that your password is vulnerable. '
+ 'We recommend you change it so that your account stays secure.',
);
const message = 'Our system detected that your password is vulnerable. '
+ 'We recommend you change it so that your account stays secure.';
expect(screen.getByText(
'Password security',
{ selector: '.pgn__modal-title' },
).textContent).toEqual('Password security');
expect(screen.getByText(
'',
{ selector: '.pgn__modal-body' },
).textContent).toEqual(message);
});
it('should show modal that requires users to change password', () => {
props = {
loginError: {
errorCode: REQUIRE_PASSWORD_CHANGE,
},
errorCode: REQUIRE_PASSWORD_CHANGE,
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<MemoryRouter>
<IntlLoginFailureMessage {...props} />
@@ -279,8 +332,14 @@ describe('LoginFailureMessage', () => {
</IntlProvider>,
);
expect(loginFailureMessage.find('.pgn__modal-title').text()).toEqual('Password change required');
expect(loginFailureMessage.find('.pgn__modal-body').text()).toEqual(
expect(screen.getByText(
'Password change required',
{ selector: '.pgn__modal-title' },
).textContent).toEqual('Password change required');
expect(screen.getByText(
'',
{ selector: '.pgn__modal-body' },
).textContent).toEqual(
'Our system detected that your password is vulnerable. '
+ 'Change your password so that your account stays secure.',
);
@@ -288,18 +347,17 @@ describe('LoginFailureMessage', () => {
it('should show message if staff user try to login through password', () => {
props = {
loginError: {
context: {
email: 'text@example.com',
errorCode: ALLOWED_DOMAIN_LOGIN_ERROR,
context: {
allowedDomain: 'test.com',
provider: 'Google',
tpaHint: 'google-auth2',
},
allowedDomain: 'test.com',
provider: 'Google',
tpaHint: 'google-auth2',
},
errorCode: ALLOWED_DOMAIN_LOGIN_ERROR,
failureCount: 0,
};
const loginFailureMessage = mount(
render(
<IntlProvider locale="en">
<IntlLoginFailureMessage {...props} />
</IntlProvider>,
@@ -308,7 +366,11 @@ describe('LoginFailureMessage', () => {
const errorMessage = "We couldn't sign you in.As test.com user, You must login with your test.com Google account.";
const url = 'http://localhost:18000/dashboard/?tpa_hint=google-auth2';
expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(errorMessage);
expect(loginFailureMessage.find('#login-failure-alert').find('a').props().href).toEqual(url);
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toContain(errorMessage);
expect(screen.getByRole('link', { name: 'Google account' }).getAttribute('href')).toBe(url);
});
});

View File

@@ -2,19 +2,18 @@ import React from 'react';
import { Provider } from 'react-redux';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { sendPageEvent } from '@edx/frontend-platform/analytics';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import renderer from 'react-test-renderer';
import configureStore from 'redux-mock-store';
import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE } from '../../data/constants';
import {
loginRemovePasswordResetBanner, loginRequest, loginRequestFailure, loginRequestReset, setLoginFormData,
} from '../data/actions';
import { backupLoginFormBegin, dismissPasswordResetBanner, loginRequest } from '../data/actions';
import { INTERNAL_SERVER_ERROR } from '../data/constants';
import LoginFailureMessage from '../LoginFailure';
import LoginPage from '../LoginPage';
jest.mock('@edx/frontend-platform/analytics', () => ({
@@ -25,15 +24,14 @@ jest.mock('@edx/frontend-platform/auth', () => ({
getAuthService: jest.fn(),
}));
const IntlLoginFailureMessage = injectIntl(LoginFailureMessage);
const IntlLoginPage = injectIntl(LoginPage);
const mockStore = configureStore();
describe('LoginPage', () => {
let props = {};
let store = {};
let loginFormData = {};
const emptyFieldValidation = { emailOrUsername: 'Enter your username or email', password: 'Enter your password' };
const reduxWrapper = children => (
<IntlProvider locale="en">
<MemoryRouter>
@@ -83,81 +81,151 @@ describe('LoginPage', () => {
handleInstitutionLogin: jest.fn(),
institutionLogin: false,
};
loginFormData = {
emailOrUsername: '',
password: '',
errors: {
emailOrUsername: '',
password: '',
},
};
});
// ******** test login form submission ********
it('should submit form for valid input', () => {
store.dispatch = jest.fn(store.dispatch);
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.find('input#emailOrUsername').simulate('change', { target: { value: 'test@example.com' } });
loginPage.find('input#password').simulate('change', { target: { value: 'password' } });
loginPage.find('button.btn-brand').simulate('click');
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(store.dispatch).toHaveBeenCalledWith(loginRequest({ email_or_username: 'test@example.com', password: 'password' }));
fireEvent.change(screen.getByText(
'',
{ selector: '#emailOrUsername' },
), { target: { value: 'test', name: 'emailOrUsername' } });
fireEvent.change(screen.getByText(
'',
{ selector: '#password' },
), { target: { value: 'test-password', name: 'password' } });
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
expect(store.dispatch).toHaveBeenCalledWith(loginRequest({ email_or_username: 'test', password: 'test-password' }));
});
it('should not dispatch loginRequest on empty form submission', () => {
store.dispatch = jest.fn(store.dispatch);
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.find('button.btn-brand').simulate('click');
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
expect(store.dispatch).not.toHaveBeenCalledWith(loginRequest({}));
});
it('should dismiss reset password banner on form submission', () => {
store = mockStore({
...initialState,
login: {
...initialState.login,
showResetPasswordSuccessBanner: true,
},
});
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
expect(store.dispatch).toHaveBeenCalledWith(dismissPasswordResetBanner());
});
// ******** test login form validations ********
it('should match state on empty form submission', () => {
const errorState = { emailOrUsername: 'Enter your username or email', password: 'Enter your password' };
it('should match state for invalid email (less than 2 characters), on form submission', () => {
store.dispatch = jest.fn(store.dispatch);
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
loginPage.find('button.btn-brand').simulate('click');
render(reduxWrapper(<IntlLoginPage {...props} />));
// Check that loginRequestFailure was dispatched and state is updated
expect(loginPage.state('errors')).toEqual(errorState);
expect(store.dispatch).toHaveBeenCalledWith(loginRequestFailure({ errorCode: 'invalid-form' }));
fireEvent.change(screen.getByText(
'',
{ selector: '#password' },
), { target: { value: 'test' } });
fireEvent.change(screen.getByText(
'',
{ selector: '#emailOrUsername' },
), { target: { value: 't' } });
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
expect(screen.getByText('Username or email must have at least 2 characters.')).toBeDefined();
});
it('should match state for invalid email (less than 3 characters), on form submission', () => {
const errorState = { emailOrUsername: 'Username or email must have at least 3 characters.', password: '' };
store.dispatch = jest.fn(store.dispatch);
it('should show error messages for required fields on empty form submission', () => {
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
expect(container.querySelector('div[feedback-for="emailOrUsername"]').textContent).toEqual(emptyFieldValidation.emailOrUsername);
expect(container.querySelector('div[feedback-for="password"]').textContent).toEqual(emptyFieldValidation.password);
loginPage.find('input#password').simulate('change', { target: { value: 'test', name: 'password' } });
loginPage.find('input#emailOrUsername').simulate('change', { target: { value: 'te', name: 'email' } });
loginPage.find('button.btn-brand').simulate('click');
expect(loginPage.state('errors')).toEqual(errorState);
const alertBanner = 'We couldn\'t sign you in.Please fill in the fields below.';
expect(container.querySelector('#login-failure-alert').textContent).toEqual(alertBanner);
});
it('should reset field related error messages on onFocus event', () => {
const errorState = { emailOrUsername: '', password: '' };
it('should run frontend validations for emailOrUsername field on form submission', () => {
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.change(screen.getByText(
'',
{ selector: '#emailOrUsername' },
), { target: { value: 't', name: 'emailOrUsername' } });
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
expect(container.querySelector('div[feedback-for="emailOrUsername"]').textContent).toEqual('Username or email must have at least 2 characters.');
});
// ******** test field focus in functionality ********
it('should reset field related error messages on onFocus event', async () => {
store.dispatch = jest.fn(store.dispatch);
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
loginPage.find('button.btn-brand').simulate('click');
render(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.find('input#emailOrUsername').simulate('focus');
loginPage.find('input#password').simulate('focus');
expect(loginPage.state('errors')).toEqual(errorState);
await act(async () => {
// clicking submit button with empty fields to make the errors appear
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
// focusing the fields to verify that the errors are cleared
fireEvent.focus(screen.getByText(
'',
{ selector: '#password' },
));
fireEvent.focus(screen.getByText(
'',
{ selector: '#emailOrUsername' },
));
});
// verifying that the errors are cleared
await waitFor(() => {
expect(screen.queryByText('Enter your username or email')).toBeNull();
});
});
// ******** test form buttons and links ********
it('should match default button state', () => {
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('button[type="submit"] span').first().text()).toEqual('Sign in');
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText('Sign in')).toBeDefined();
});
it('should match pending button state', () => {
@@ -169,97 +237,105 @@ describe('LoginPage', () => {
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
const button = loginPage.find('button[type="submit"] span').first();
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(button.find('.sr-only').text()).toEqual('pending');
expect(screen.getByText(
'pending',
).textContent).toEqual('pending');
});
it('should show forgot password link', () => {
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('a#forgot-password').text()).toEqual('Forgot password');
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'Forgot password',
{ selector: '#forgot-password' },
).textContent).toEqual('Forgot password');
});
it('should show single sign on provider button', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [{
...ssoProvider,
}],
providers: [ssoProvider],
},
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find(`button#${ssoProvider.id}`).length).toEqual(1);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: `#${ssoProvider.id}` },
)).toBeDefined();
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
});
it('should not display institution login option when no secondary providers are present', () => {
const root = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(root.text().includes('Use my university info')).toBe(false);
});
it('should not show sign-in header and enterprise login once user authenticated through SSO', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
it('should display sign-in header only when primary or secondary providers are available.', () => {
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [{
...ssoProvider,
}],
},
},
});
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Company or school credentials')).toBeNull();
expect(queryByText('Or sign in with:')).toBeNull();
expect(queryByText('Institution/campus credentials')).toBeNull();
});
it('should hide sign-in header and enterprise login upon successful SSO authentication', () => {
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
currentProvider: 'Apple',
},
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.text().includes('Company or school credentials')).toBe(false);
expect(loginPage.text().includes('Or sign in with:')).toBe(false);
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Company or school credentials')).toBeNull();
expect(queryByText('Or sign in with:')).toBeNull();
});
it('should show sign-in header providers (ENABLE ENTERPRISE LOGIN)', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
// ******** test enterprise login enabled scenarios ********
it('should show sign-in header for enterprise login', () => {
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [{
...ssoProvider,
}],
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
},
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.text().includes('Or sign in with:')).toBe(true);
expect(loginPage.text().includes('Company or school credentials')).toBe(true);
expect(loginPage.text().includes('Institution/campus credentials')).toBe(false);
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Or sign in with:')).toBeDefined();
expect(queryByText('Company or school credentials')).toBeDefined();
expect(queryByText('Institution/campus credentials')).toBeDefined();
});
it('should show sign-in header with providers (DISABLE ENTERPRISE LOGIN)', () => {
// ******** test enterprise login disabled scenarios ********
it('should show sign-in header for institution login if enterprise login is disabled', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: true,
});
@@ -270,63 +346,16 @@ describe('LoginPage', () => {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [{
...ssoProvider,
}],
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
},
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.text().includes('Or sign in with:')).toBe(true);
expect(loginPage.text().includes('Company or school credentials')).toBe(false);
expect(loginPage.text().includes('Institution/campus credentials')).toBe(false);
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
});
it('should not show sign-in header without Providers and secondary Providers (ENABLE ENTERPRISE LOGIN)', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
},
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.text().includes('Or sign in with:')).toBe(false);
expect(loginPage.text().includes('Company or school credentials')).toBe(false);
expect(loginPage.text().includes('Institution/campus credentials')).toBe(false);
});
it('should not show sign-in header without Providers and secondary Providers (DISABLE ENTERPRISE LOGIN)', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: true,
});
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
},
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.text().includes('Or sign in with:')).toBe(false);
expect(loginPage.text().includes('Company or school credentials')).toBe(false);
expect(loginPage.text().includes('Institution/campus credentials')).toBe(false);
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Or sign in with:')).toBeDefined();
expect(queryByText('Company or school credentials')).toBeNull();
expect(queryByText('Institution/campus credentials')).toBeDefined();
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
@@ -351,40 +380,48 @@ describe('LoginPage', () => {
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.text().includes('Or sign in with:')).toBe(true);
expect(loginPage.text().includes('Institution/campus credentials')).toBe(true);
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Or sign in with:')).toBeDefined();
expect(queryByText('Institution/campus credentials')).toBeDefined();
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
});
it('should show sign-in header with Providers and secondary Providers', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: true,
});
it('should not show sign-in header without primary or secondary providers', () => {
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [{
...ssoProvider,
}],
secondaryProviders: [{
...secondaryProviders,
}],
},
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.text().includes('Or sign in with:')).toBe(true);
expect(loginPage.text().includes('Company or school credentials')).toBe(false);
expect(loginPage.text().includes('Institution/campus credentials')).toBe(true);
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Or sign in with:')).toBeNull();
expect(queryByText('Institution/campus credentials')).toBeNull();
expect(queryByText('Company or school credentials')).toBeNull();
});
it('should show enterprise login if even if only secondary providers are available', () => {
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
secondaryProviders: [secondaryProviders],
},
},
});
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Or sign in with:')).toBeDefined();
expect(queryByText('Company or school credentials')).toBeNull();
expect(queryByText('Institution/campus credentials')).toBeDefined();
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
@@ -393,30 +430,22 @@ describe('LoginPage', () => {
// ******** test alert messages ********
it('should match login error message', () => {
const errorMessage = 'An error has occurred. Try refreshing the page, or check your internet connection.';
it('should match login internal server error message', () => {
const expectedMessage = 'We couldn\'t sign you in.'
+ 'An error has occurred. Try refreshing the page, or check your internet connection.';
store = mockStore({
...initialState,
login: {
...initialState.login,
loginError: { errorCode: INTERNAL_SERVER_ERROR },
loginErrorCode: INTERNAL_SERVER_ERROR,
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('#login-failure-alert').first().text()).toEqual(`We couldn't sign you in.${errorMessage}`);
});
it('should match account activation message', () => {
const activationMessage = 'Success! You have activated your account.'
+ 'You will now receive email updates and alerts from us related '
+ 'to the courses you are enrolled in. Sign in to continue.';
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: '?account_activation_status=success' };
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('div#account-activation-message').text()).toEqual(activationMessage);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toEqual(`${expectedMessage}`);
});
it('should match third party auth alert', () => {
@@ -436,11 +465,14 @@ describe('LoginPage', () => {
+ 'linked '}${ getConfig().SITE_NAME } account. To link your accounts, sign in now using your ${
getConfig().SITE_NAME } password.`;
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('#tpa-alert').find('p').text()).toEqual(expectedMessage);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#tpa-alert' },
).textContent).toEqual(expectedMessage);
});
it('should show tpa authentication fails error message', () => {
it('should show third party authentication failure message', () => {
store = mockStore({
...initialState,
commonComponents: {
@@ -452,9 +484,11 @@ describe('LoginPage', () => {
},
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('#login-failure-alert').find('p').text()).toContain('An error occurred');
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toContain('An error occurred');
});
it('should match invalid login form error message', () => {
@@ -463,33 +497,36 @@ describe('LoginPage', () => {
...initialState,
login: {
...initialState.login,
loginError: { errorCode: 'invalid-form' },
loginErrorCode: 'invalid-form',
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('#login-failure-alert p').first().text()).toEqual(errorMessage);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toContain(errorMessage);
});
// ******** test redirection ********
it('should redirect to url returned by login endpoint', () => {
const dashboardUrl = 'http://localhost:18000/enterprise/select/active/?success_url=/dashboard';
it('should redirect to url returned by login endpoint after successful authentication', () => {
const dashboardURL = 'https://test.com/testing-dashboard/';
store = mockStore({
...initialState,
login: {
...initialState.login,
loginResult: {
success: true,
redirectUrl: dashboardUrl,
redirectUrl: dashboardURL,
},
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL };
renderer.create(reduxWrapper(<IntlLoginPage {...props} />));
expect(window.location.href).toBe(dashboardUrl);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(window.location.href).toBe(dashboardURL);
});
it('should redirect to finishAuthUrl upon successful login via SSO', () => {
@@ -515,26 +552,18 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL };
renderer.create(reduxWrapper(<IntlLoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + authCompleteUrl);
});
it('should redirect to social auth provider url on SSO button click', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: 'true',
});
const loginUrl = '/auth/login/apple-id/?auth_entry=login&next=/dashboard';
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [{
...ssoProvider,
loginUrl,
}],
providers: [ssoProvider],
},
},
});
@@ -542,14 +571,37 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL };
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.find('button#oa2-apple-id').simulate('click');
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + loginUrl);
fireEvent.click(screen.getByText(
'',
{ selector: '#oa2-apple-id' },
));
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + ssoProvider.loginUrl);
});
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
it('should redirect to finishAuthUrl upon successful authentication via SSO', () => {
const finishAuthUrl = '/auth/complete/google-oauth2/';
store = mockStore({
...initialState,
login: {
...initialState.login,
loginResult: { success: true, redirectUrl: '' },
},
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
finishAuthUrl,
},
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL };
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + finishAuthUrl);
});
// ******** test hinted third party auth ********
@@ -570,9 +622,35 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` };
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find(`button#${ssoProvider.id}`).find('span').text()).toEqual(ssoProvider.name);
expect(loginPage.find(`button#${ssoProvider.id}`).hasClass(`btn-tpa btn-${ssoProvider.id}`)).toEqual(true);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: `#${ssoProvider.id}` },
).textContent).toEqual(ssoProvider.name);
expect(screen.getByText(
'',
{ selector: `.btn-${ssoProvider.id}` },
)).toBeTruthy();
});
it('should render the skeleton when third party status is pending', () => {
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
},
thirdPartyAuthApiStatus: PENDING_STATE,
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` };
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(container.querySelector('.react-loading-skeleton')).toBeTruthy();
});
it('should render tpa button for tpa_hint id matching one of the secondary providers', () => {
@@ -593,15 +671,11 @@ describe('LoginPage', () => {
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` };
secondaryProviders.iconImage = null;
mount(reduxWrapper(<IntlLoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(window.location.href).toEqual(getConfig().LMS_BASE_URL + secondaryProviders.loginUrl);
});
it('should render regular tpa button for invalid tpa_hint value', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: 'true',
});
store = mockStore({
...initialState,
commonComponents: {
@@ -617,15 +691,15 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: '?next=/dashboard&tpa_hint=invalid' };
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find(`button#${ssoProvider.id}`).find('span#provider-name').text()).toEqual(`${ssoProvider.name}`);
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(container.querySelector(`#${ssoProvider.id}`).querySelector('#provider-name').textContent).toEqual(`${ssoProvider.name}`);
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
});
it('should render other ways to sign in button', () => {
it('should render "other ways to sign in" button on the tpa_hint page', () => {
store = mockStore({
...initialState,
commonComponents: {
@@ -641,14 +715,17 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?tpa_hint=${ssoProvider.id}` };
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('button#other-ways-to-sign-in').text()).toEqual('Show me other ways to sign in or register');
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'Show me other ways to sign in or register',
).textContent).toBeDefined();
});
it('should render other ways to sign in button when public account creation disabled', () => {
it('should render other ways to sign in button when public account creation is disabled', () => {
mergeConfig({
ALLOW_PUBLIC_ACCOUNT_CREATION: false,
});
store = mockStore({
...initialState,
commonComponents: {
@@ -664,106 +741,93 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?tpa_hint=${ssoProvider.id}` };
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('button#other-ways-to-sign-in').text()).toEqual('Show me other ways to sign in');
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'Show me other ways to sign in',
).textContent).toBeDefined();
});
// ******** miscellaneous tests ********
it('should send page event when login page is rendered', () => {
mount(reduxWrapper(<IntlLoginPage {...props} />));
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'login');
});
it('tests that form is only scrollable on form submission', () => {
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.find('input#password').simulate('change', { target: { value: 'test', name: 'password' } });
loginPage.find('button.btn-brand').simulate('click');
expect(loginPage.find(<IntlLoginFailureMessage />)).toBeTruthy();
expect(loginPage.find('LoginPage').state('isSubmitted')).toEqual(true);
});
it('should reset login form errors', () => {
const errorState = { emailOrUsername: '', password: '' };
store.dispatch = jest.fn(store.dispatch);
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
expect(store.dispatch).toHaveBeenCalledWith(loginRequestReset());
expect(loginPage.state('errors')).toEqual(errorState);
});
// persists form data tests
it('should set errors in redux store on submit form for invalid input', () => {
const formData = {
errors: {
emailOrUsername: 'Enter your username or email',
password: 'Enter your password',
},
};
store.dispatch = jest.fn(store.dispatch);
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.find('input#emailOrUsername').simulate('change', { target: { value: '' } });
loginPage.find('input#password').simulate('change', { target: { value: '' } });
loginPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(setLoginFormData(formData));
});
it('should set form data in redux store on onBlur', () => {
store.dispatch = jest.fn(store.dispatch);
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.find('input#emailOrUsername').simulate('blur');
expect(store.dispatch).toHaveBeenCalledWith(setLoginFormData({ emailOrUsername: '' }));
});
it('should clear form field errors in redux store on onFocus', () => {
store.dispatch = jest.fn(store.dispatch);
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.find('input#emailOrUsername').simulate('focus');
expect(store.dispatch).toHaveBeenCalledWith(setLoginFormData({
errors: {
...loginFormData.errors,
},
}));
});
it('should update form fields state if updated in redux store', () => {
const nextProps = {
loginFormData: {
emailOrUsername: 'john_doe',
password: 'password1',
},
};
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.find('LoginPage').instance().shouldComponentUpdate(nextProps);
expect(loginPage.find('LoginPage').state('emailOrUsername')).toEqual('john_doe');
expect(loginPage.find('LoginPage').state('password')).toEqual('password1');
});
it('should update reset password value when unmount called', () => {
it('tests that form is in invalid state when it is submitted', () => {
store = mockStore({
...initialState,
login: {
...initialState.login,
resetPassword: true,
shouldBackupState: true,
},
});
store.dispatch = jest.fn(store.dispatch);
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.unmount();
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(store.dispatch).toHaveBeenCalledWith(backupLoginFormBegin(
{
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
));
});
expect(store.dispatch).toHaveBeenCalledWith(loginRemovePasswordResetBanner());
it('should send track event when forgot password link is clicked', () => {
render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByText(
'Forgot password',
{ selector: '#forgot-password' },
));
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.password-reset_form.toggled', { category: 'user-engagement' });
});
it('should backup the login form state when shouldBackupState is true', () => {
store = mockStore({
...initialState,
login: {
...initialState.login,
shouldBackupState: true,
},
});
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(store.dispatch).toHaveBeenCalledWith(backupLoginFormBegin(
{
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
));
});
it('should update form fields state if updated in redux store', () => {
store = mockStore({
...initialState,
login: {
...initialState.login,
loginFormData: {
formFields: {
emailOrUsername: 'john_doe', password: 'test-password',
},
errors: {
emailOrUsername: '', password: '',
},
},
},
});
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(container.querySelector('input#emailOrUsername').value).toEqual('john_doe');
expect(container.querySelector('input#password').value).toEqual('test-password');
});
});

View File

@@ -25,6 +25,7 @@ import {
getTpaHint, getTpaProvider, updatePathWithQueryParams,
} from '../data/utils';
import { LoginPage } from '../login';
import { backupLoginForm } from '../login/data/actions';
import { RegistrationPage } from '../register';
import { backupRegistrationForm } from '../register/data/actions';
@@ -65,11 +66,16 @@ const Logistration = (props) => {
setInstitutionLogin(!institutionLogin);
};
const handleOnSelect = (tabKey) => {
const handleOnSelect = (tabKey, currentTab) => {
if (tabKey === currentTab) {
return;
}
sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement' });
props.clearThirdPartyAuthContextErrorMessage();
if (tabKey === LOGIN_PAGE) {
props.backupRegistrationForm();
} else if (tabKey === REGISTER_PAGE) {
props.backupLoginForm();
}
setKey(tabKey);
};
@@ -118,7 +124,7 @@ const Logistration = (props) => {
</Tabs>
)
: (!isValidTpaHint() && !hideRegistrationLink && (
<Tabs defaultActiveKey={selectedPage} id="controlled-tab" onSelect={handleOnSelect}>
<Tabs defaultActiveKey={selectedPage} id="controlled-tab" onSelect={(tabKey) => handleOnSelect(tabKey, selectedPage)}>
<Tab title={formatMessage(messages['logistration.register'])} eventKey={REGISTER_PAGE} />
<Tab title={formatMessage(messages['logistration.sign.in'])} eventKey={LOGIN_PAGE} />
</Tabs>
@@ -150,6 +156,7 @@ const Logistration = (props) => {
Logistration.propTypes = {
selectedPage: PropTypes.string,
backupLoginForm: PropTypes.func.isRequired,
backupRegistrationForm: PropTypes.func.isRequired,
clearThirdPartyAuthContextErrorMessage: PropTypes.func.isRequired,
tpaProviders: PropTypes.shape({
@@ -176,6 +183,7 @@ const mapStateToProps = state => ({
export default connect(
mapStateToProps,
{
backupLoginForm,
backupRegistrationForm,
clearThirdPartyAuthContextErrorMessage,
},

View File

@@ -4,16 +4,16 @@ import { Provider } from 'react-redux';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { configure, injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
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, REGISTER_PAGE,
} from '../data/constants';
import { backupLoginForm } from '../login/data/actions';
import { backupRegistrationForm } from '../register/data/actions';
jest.mock('@edx/frontend-platform/analytics', () => ({
@@ -94,21 +94,29 @@ describe('Logistration', () => {
});
});
it('should do nothing when user clicks on the same tab (login/register) again', () => {
const { container } = render(reduxWrapper(<IntlLogistration />));
// While staying on the registration form, clicking the register tab again
fireEvent.click(container.querySelector('a[data-rb-event-key="/register"]'));
expect(sendTrackEvent).not.toHaveBeenCalledWith('edx.bi.register_form.toggled', { category: 'user-engagement' });
});
it('should render registration page', () => {
mergeConfig({
ALLOW_PUBLIC_ACCOUNT_CREATION: true,
});
const logistration = mount(reduxWrapper(<IntlLogistration />));
const { container } = render(reduxWrapper(<IntlLogistration />));
expect(logistration.find('#main-content').find('RegistrationPage').exists()).toBeTruthy();
expect(container.querySelector('RegistrationPage')).toBeDefined();
});
it('should render login page', () => {
const props = { selectedPage: LOGIN_PAGE };
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
const { container } = render(reduxWrapper(<IntlLogistration {...props} />));
expect(logistration.find('#main-content').find('LoginPage').exists()).toBeTruthy();
expect(container.querySelector('LoginPage')).toBeDefined();
});
it('should render login/register headings when show registration links is disabled', () => {
@@ -117,18 +125,18 @@ describe('Logistration', () => {
});
let props = { selectedPage: LOGIN_PAGE };
let logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
const { rerender } = render(reduxWrapper(<IntlLogistration {...props} />));
// verifying sign in heading
expect(logistration.find('#main-content').find('h3').text()).toEqual('Sign in');
expect(screen.getByRole('heading', { level: 3 }).textContent).toEqual('Sign in');
// register page is still accessible when SHOW_REGISTRATION_LINKS is false
// but it needs to be accessed directly
props = { selectedPage: REGISTER_PAGE };
logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
rerender(reduxWrapper(<IntlLogistration {...props} />));
// verifying register heading
expect(logistration.find('#main-content').find('h3').text()).toEqual('Register');
expect(screen.getByRole('heading', { level: 3 }).textContent).toEqual('Register');
});
it('should render only login page when public account creation is disabled', () => {
@@ -152,14 +160,14 @@ describe('Logistration', () => {
});
const props = { selectedPage: LOGIN_PAGE };
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
const { container } = render(reduxWrapper(<IntlLogistration {...props} />));
// verifying sign in heading for institution login false
expect(logistration.find('#main-content').find('h3').text()).toEqual('Sign in');
expect(screen.getByRole('heading', { level: 3 }).textContent).toEqual('Sign in');
// verifying tabs heading for institution login true
logistration.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
expect(logistration.find('#controlled-tab').exists()).toBeTruthy();
fireEvent.click(screen.getByRole('link'));
expect(container.querySelector('#controlled-tab')).toBeDefined();
});
it('should display institution login option when secondary providers are present', () => {
@@ -182,12 +190,12 @@ describe('Logistration', () => {
});
const props = { selectedPage: LOGIN_PAGE };
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
expect(logistration.text().includes('Institution/campus credentials')).toBe(true);
render(reduxWrapper(<IntlLogistration {...props} />));
expect(screen.getByText('Institution/campus credentials')).toBeDefined();
// on clicking "Institution/campus credentials" button, it should display institution login page
logistration.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
expect(logistration.text().includes('Test University')).toBe(true);
fireEvent.click(screen.getByText('Institution/campus credentials'));
expect(screen.getByText('Test University')).toBeDefined();
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
@@ -213,8 +221,8 @@ describe('Logistration', () => {
});
const props = { selectedPage: LOGIN_PAGE };
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
logistration.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
render(reduxWrapper(<IntlLogistration {...props} />));
fireEvent.click(screen.getByText('Institution/campus credentials'));
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.institution_login_form.toggled', { category: 'user-engagement' });
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login');
@@ -245,9 +253,9 @@ describe('Logistration', () => {
delete window.location;
window.location = { hostname: getConfig().SITE_NAME, href: getConfig().BASE_URL };
const root = mount(reduxWrapper(<IntlLogistration />));
root.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
expect(root.text().includes('Test University')).toBe(true);
render(reduxWrapper(<IntlLogistration />));
fireEvent.click(screen.getByText('Institution/campus credentials'));
expect(screen.getByText('Test University')).toBeDefined();
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
@@ -256,15 +264,23 @@ describe('Logistration', () => {
it('should fire action to backup registration form on tab click', () => {
store.dispatch = jest.fn(store.dispatch);
const logistration = mount(reduxWrapper(<IntlLogistration />));
logistration.find('a[data-rb-event-key="/login"]').simulate('click');
const { container } = render(reduxWrapper(<IntlLogistration />));
fireEvent.click(container.querySelector('a[data-rb-event-key="/login"]'));
expect(store.dispatch).toHaveBeenCalledWith(backupRegistrationForm());
});
it('should fire action to backup login form on tab click', () => {
store.dispatch = jest.fn(store.dispatch);
const props = { selectedPage: LOGIN_PAGE };
const { container } = render(reduxWrapper(<IntlLogistration {...props} />));
fireEvent.click(container.querySelector('a[data-rb-event-key="/register"]'));
expect(store.dispatch).toHaveBeenCalledWith(backupLoginForm());
});
it('should clear tpa context errorMessage tab click', () => {
store.dispatch = jest.fn(store.dispatch);
const logistration = mount(reduxWrapper(<IntlLogistration />));
logistration.find('a[data-rb-event-key="/login"]').simulate('click');
const { container } = render(reduxWrapper(<IntlLogistration />));
fireEvent.click(container.querySelector('a[data-rb-event-key="/login"]'));
expect(store.dispatch).toHaveBeenCalledWith(clearThirdPartyAuthContextErrorMessage());
});
});

View File

@@ -156,6 +156,7 @@ const ProgressiveProfiling = (props) => {
isGenderSelected: !!values.gender,
isYearOfBirthSelected: !!values.year_of_birth,
isLevelOfEducationSelected: !!values.level_of_education,
isWorkExperienceSelected: !!values.work_experience,
host: queryParams?.host || '',
},
);
@@ -195,7 +196,7 @@ const ProgressiveProfiling = (props) => {
});
return (
<BaseContainer showWelcomeBanner username={authenticatedUser?.username}>
<BaseContainer showWelcomeBanner fullName={authenticatedUser?.fullName || authenticatedUser?.name}>
<Helmet>
<title>{formatMessage(messages['progressive.profiling.page.title'],
{ siteName: getConfig().SITE_NAME })}
@@ -284,6 +285,7 @@ ProgressiveProfiling.propTypes = {
authenticatedUser: PropTypes.shape({
username: PropTypes.string,
userId: PropTypes.number,
fullName: PropTypes.string,
}),
showError: PropTypes.bool,
shouldRedirect: PropTypes.bool,

View File

@@ -173,6 +173,7 @@ describe('ProgressiveProfilingTests', () => {
isGenderSelected: false,
isYearOfBirthSelected: false,
isLevelOfEducationSelected: false,
isWorkExperienceSelected: false,
host: '',
};
delete window.location;
@@ -346,6 +347,7 @@ describe('ProgressiveProfilingTests', () => {
isGenderSelected: false,
isYearOfBirthSelected: false,
isLevelOfEducationSelected: false,
isWorkExperienceSelected: false,
host: 'http://example.com',
};
delete window.location;

View File

@@ -20,7 +20,7 @@ const BaseCard = ({
<div className="recommendation-card" key={`container-${uuid}`}>
<Hyperlink
target="_blank"
className="card-box"
className="card-box d-inline"
showLaunchIcon={false}
onClick={handleOnClick}
>

View File

@@ -31,6 +31,13 @@ const CountryField = (props) => {
} = props;
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const countryFieldValue = {
userProvidedText: selectedCountry.displayValue,
selectionValue: selectedCountry.displayValue,
selectionId: selectedCountry.countryCode,
};
const backendCountryCode = useSelector(state => state.register.backendCountryCode);
useEffect(() => {
@@ -49,6 +56,11 @@ const CountryField = (props) => {
{ target: { name: 'country' } },
{ countryCode, displayValue: countryDisplayValue },
);
} else if (!selectedCountry.displayValue) {
onChangeHandler(
{ target: { name: 'country' } },
{ countryCode: '', displayValue: '' },
);
}
}, [backendCountryCode, countryList]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -60,18 +72,12 @@ const CountryField = (props) => {
const { value } = event.target;
const { countryCode, displayValue, error } = validateCountryField(
const { error } = validateCountryField(
value.trim(), countryList, formatMessage(messages['empty.country.field.error']), formatMessage(messages['invalid.country.field.error']),
);
onChangeHandler({ target: { name: 'country' } }, { countryCode, displayValue });
handleErrorChange('country', error);
};
const handleSelected = (value) => {
handleOnBlur({ target: { name: 'country', value } });
};
const handleOnFocus = (event) => {
handleErrorChange('country', '');
dispatch(clearRegistrationBackendError('country'));
@@ -79,11 +85,19 @@ const CountryField = (props) => {
};
const handleOnChange = (value) => {
onChangeHandler({ target: { name: 'country' } }, { countryCode: '', displayValue: value });
onChangeHandler({ target: { name: 'country' } }, { countryCode: value.selectionId, displayValue: value.userProvidedText });
// We have put this check because proviously we also had onSelected event handler and we call
// the onBlur on that event handler but now there is no such handler and we only have
// onChange so we check the is there is proper sectionId which only be
// proper one when we select it from dropdown's item otherwise its null.
if (value.selectionId !== '') {
handleOnBlur({ target: { name: 'country', value: value.userProvidedText } });
}
};
const getCountryList = () => countryList.map((country) => (
<FormAutosuggestOption key={country[COUNTRY_CODE_KEY]}>
<FormAutosuggestOption key={country[COUNTRY_CODE_KEY]} id={country[COUNTRY_CODE_KEY]}>
{country[COUNTRY_DISPLAY_KEY]}
</FormAutosuggestOption>
));
@@ -94,9 +108,8 @@ const CountryField = (props) => {
floatingLabel={formatMessage(messages['registration.country.label'])}
aria-label="form autosuggest"
name="country"
value={selectedCountry.displayValue || ''}
value={countryFieldValue || {}}
className={classNames({ 'form-field-error': props.errorMessage })}
onSelected={(value) => handleSelected(value)}
onFocus={(e) => handleOnFocus(e)}
onBlur={(e) => handleOnBlur(e)}
onChange={(value) => handleOnChange(value)}

View File

@@ -3,7 +3,7 @@ import { Provider } from 'react-redux';
import { mergeConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { fireEvent, render } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -82,8 +82,13 @@ describe('CountryField', () => {
};
it('should run country field validation when onBlur is fired', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('blur', { target: { value: '', name: 'country' } });
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const countryInput = container.querySelector('input[name="country"]');
fireEvent.blur(countryInput, {
target: { value: '', name: 'country' },
});
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'country',
@@ -92,8 +97,13 @@ describe('CountryField', () => {
});
it('should run country field validation when country name is invalid', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('blur', { target: { value: 'Pak', name: 'country' } });
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const countryInput = container.querySelector('input[name="country"]');
fireEvent.blur(countryInput, {
target: { value: 'Pak', name: 'country' },
});
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'country',
@@ -102,34 +112,36 @@ describe('CountryField', () => {
});
it('should not run country field validation when onBlur is fired by drop-down arrow icon click', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('blur', {
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const countryInput = container.querySelector('input[name="country"]');
const dropdownArrowIcon = container.querySelector('.btn-icon.pgn__form-autosuggest__icon-button');
fireEvent.blur(countryInput, {
target: { value: '', name: 'country' },
relatedTarget: { type: 'button', className: 'btn-icon pgn__form-autosuggest__icon-button' },
relatedTarget: dropdownArrowIcon,
});
expect(props.handleErrorChange).toHaveBeenCalledTimes(0);
});
it('should update errors for frontend validations', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const countryInput = container.querySelector('input[name="country"]');
fireEvent.blur(countryInput, { target: { value: '', name: 'country' } });
countryField.find('input[name="country"]').simulate('blur', { target: { value: '', name: 'country' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'country',
emptyFieldValidation.country,
);
expect(props.handleErrorChange).toHaveBeenCalledWith('country', emptyFieldValidation.country);
});
it('should clear error on focus', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const countryInput = container.querySelector('input[name="country"]');
fireEvent.focus(countryInput);
countryField.find('input[name="country"]').simulate('focus', { target: { value: '', name: 'country' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'country',
'',
);
expect(props.handleErrorChange).toHaveBeenCalledWith('country', '');
});
it('should update state from country code present in redux store', () => {
@@ -141,7 +153,9 @@ describe('CountryField', () => {
},
});
mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
container.querySelector('input[name="country"]');
expect(props.onChangeHandler).toHaveBeenCalledTimes(1);
expect(props.onChangeHandler).toHaveBeenCalledWith(
{ target: { name: 'country' } },
@@ -150,12 +164,15 @@ describe('CountryField', () => {
});
it('should set option on dropdown menu item click', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('.pgn__form-autosuggest__icon-button').first().simulate('click');
countryField.find('.dropdown-item').first().simulate('click');
const dropdownButton = container.querySelector('.pgn__form-autosuggest__icon-button');
fireEvent.click(dropdownButton);
expect(props.onChangeHandler).toHaveBeenCalledTimes(1);
const dropdownItem = container.querySelector('.dropdown-item');
fireEvent.click(dropdownItem);
expect(props.onChangeHandler).toHaveBeenCalledTimes(2);
expect(props.onChangeHandler).toHaveBeenCalledWith(
{ target: { name: 'country' } },
{ countryCode: 'PK', displayValue: 'Pakistan' },
@@ -163,13 +180,14 @@ describe('CountryField', () => {
});
it('should set value on change', () => {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate(
'change', { target: { value: 'pak', name: 'country' } },
const { container } = render(
routerWrapper(reduxWrapper(<IntlCountryField {...props} />)),
);
expect(props.onChangeHandler).toHaveBeenCalledTimes(1);
const countryInput = container.querySelector('input[name="country"]');
fireEvent.change(countryInput, { target: { value: 'pak', name: 'country' } });
expect(props.onChangeHandler).toHaveBeenCalledTimes(2);
expect(props.onChangeHandler).toHaveBeenCalledWith(
{ target: { name: 'country' } },
{ countryCode: '', displayValue: 'pak' },
@@ -182,9 +200,11 @@ describe('CountryField', () => {
errorMessage: 'country error message',
};
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
expect(countryField.find('div[feedback-for="country"]').text()).toEqual('country error message');
const feedbackElement = container.querySelector('div[feedback-for="country"]');
expect(feedbackElement).toBeTruthy();
expect(feedbackElement.textContent).toEqual('country error message');
});
});
});

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -9,9 +9,9 @@ import PropTypes from 'prop-types';
import validateEmail from './validator';
import { FormGroup } from '../../../common-components';
import {
backupRegistrationFormBegin,
clearRegistrationBackendError,
fetchRealtimeValidations,
setEmailSuggestionInStore,
} from '../../data/actions';
import messages from '../../messages';
@@ -44,6 +44,10 @@ const EmailField = (props) => {
const [emailSuggestion, setEmailSuggestion] = useState({ ...backedUpFormData?.emailSuggestion });
useEffect(() => {
setEmailSuggestion(backedUpFormData.emailSuggestion);
}, [backedUpFormData.emailSuggestion]);
const handleOnBlur = (e) => {
const { value } = e.target;
const { fieldError, confirmEmailError, suggestion } = validateEmail(value, confirmEmailValue, formatMessage);
@@ -52,10 +56,7 @@ const EmailField = (props) => {
handleErrorChange('confirm_email', confirmEmailError);
}
dispatch(backupRegistrationFormBegin({
...backedUpFormData,
emailSuggestion: { ...suggestion },
}));
dispatch(setEmailSuggestionInStore(suggestion));
setEmailSuggestion(suggestion);
if (fieldError) {

View File

@@ -3,7 +3,7 @@ import { Provider } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { fireEvent, render } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -46,7 +46,14 @@ describe('EmailField', () => {
);
const initialState = {
register: {},
register: {
registrationFormData: {
emailSuggestion: {
suggestion: 'example@gmail.com',
type: 'warning',
},
},
},
};
beforeEach(() => {
@@ -73,9 +80,10 @@ describe('EmailField', () => {
};
it('should run email field validation when onBlur is fired', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate('blur', { target: { value: '', name: 'email' } });
const emailInput = container.querySelector('input#email');
fireEvent.blur(emailInput, { target: { value: '', name: 'email' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'email',
@@ -84,9 +92,11 @@ describe('EmailField', () => {
});
it('should update errors for frontend validations', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const emailInput = container.querySelector('input#email');
fireEvent.blur(emailInput, { target: { value: 'ab', name: 'email' } });
emailField.find('input#email').simulate('blur', { target: { value: 'ab', name: 'email' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'email',
@@ -95,9 +105,11 @@ describe('EmailField', () => {
});
it('should clear error on focus', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const emailInput = container.querySelector('input#email');
fireEvent.focus(emailInput, { target: { value: '', name: 'email' } });
emailField.find('input#email').simulate('focus', { target: { value: '', name: 'email' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'email',
@@ -107,26 +119,34 @@ describe('EmailField', () => {
it('should call backend validation api on blur event, if frontend validations have passed', () => {
store.dispatch = jest.fn(store.dispatch);
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
// Enter a valid email so that frontend validations are passed
emailField.find('input#email').simulate('blur', { target: { value: 'test@gmail.com', name: 'email' } });
const emailInput = container.querySelector('input#email');
fireEvent.blur(emailInput, { target: { value: 'test@gmail.com', name: 'email' } });
expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations({ email: 'test@gmail.com' }));
});
it('should give email suggestions for common service provider domain typos', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate('blur', { target: { value: 'john@yopmail.com', name: 'email' } });
const emailInput = container.querySelector('input#email');
fireEvent.blur(emailInput, { target: { value: 'john@yopmail.com', name: 'email' } });
expect(emailField.find('#email-warning').text()).toEqual('Did you mean: john@hotmail.com?');
const emailWarning = container.querySelector('#email-warning');
expect(emailWarning.textContent).toEqual('Did you mean: john@hotmail.com?');
});
it('should be able to click on email suggestions and set it as value', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const emailInput = container.querySelector('input#email');
fireEvent.blur(emailInput, { target: { value: 'john@yopmail.com', name: 'email' } });
const emailSuggestion = container.querySelector('.email-suggestion-alert-warning');
fireEvent.click(emailSuggestion);
emailField.find('input#email').simulate('blur', { target: { value: 'john@yopmail.com', name: 'email' } });
emailField.find('.email-suggestion-alert-warning').first().simulate('click');
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'email', value: 'john@hotmail.com' } },
@@ -134,21 +154,24 @@ describe('EmailField', () => {
});
it('should give error for common top level domain mistakes', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate(
'blur', { target: { value: 'john@gmail.mistake', name: 'email' } },
);
expect(emailField.find('.alert-danger').text()).toEqual('Did you mean john@gmail.com?');
const emailInput = container.querySelector('input#email');
fireEvent.blur(emailInput, { target: { value: 'john@gmail.mistake', name: 'email' } });
const errorElement = container.querySelector('.alert-danger');
expect(errorElement.textContent).toEqual('Did you mean john@gmail.com?');
});
it('should give error and suggestion for invalid email', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const emailInput = container.querySelector('input#email');
fireEvent.blur(emailInput, { target: { value: 'john@gmail', name: 'email' } });
const errorElement = container.querySelector('.alert-danger');
expect(errorElement.textContent).toEqual('Did you mean john@gmail.com?');
emailField.find('input#email').simulate(
'blur', { target: { value: 'john@gmail', name: 'email' } },
);
expect(emailField.find('.alert-danger').text()).toEqual('Did you mean john@gmail.com?');
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'email',
@@ -170,21 +193,29 @@ describe('EmailField', () => {
});
store.dispatch = jest.fn(store.dispatch);
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate('focus', { target: { value: 'a@gmail.com', name: 'email' } });
const { container } = render(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const emailInput = container.querySelector('input#email');
fireEvent.focus(emailInput, { target: { value: 'a@gmail.com', name: 'email' } });
expect(store.dispatch).toHaveBeenCalledWith(clearRegistrationBackendError('email'));
});
it('should clear email suggestions when close icon is clicked', () => {
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate(
'blur', { target: { value: 'john@gmail.mistake', name: 'email' } },
);
expect(emailField.find('.alert-danger').text()).toEqual('Did you mean john@gmail.com?');
const emailInput = container.querySelector('input#email');
fireEvent.blur(emailInput, { target: { value: 'john@gmail.mistake', name: 'email' } });
emailField.find('.email-suggestion__close').at(0).simulate('click');
expect(emailField.find('.alert-danger').exists()).toBeFalsy();
const suggestionText = container.querySelector('.alert-danger');
expect(suggestionText.textContent).toEqual('Did you mean john@gmail.com?');
const closeButton = container.querySelector('.email-suggestion__close');
fireEvent.click(closeButton);
const closedSuggestionText = container.querySelector('.alert-danger');
expect(closedSuggestionText).toBeNull();
});
it('should set confirm email error if it exist', () => {
@@ -193,10 +224,10 @@ describe('EmailField', () => {
confirmEmailValue: 'confirmEmail@yopmail.com',
};
const emailField = mount(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
emailField.find('input#email').simulate(
'blur', { target: { value: 'differentEmail@yopmail.com', name: 'email' } },
);
const { container } = render(routerWrapper(reduxWrapper(<IntlEmailField {...props} />)));
const emailInput = container.querySelector('input#email');
fireEvent.blur(emailInput, { target: { value: 'differentEmail@yopmail.com', name: 'email' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'confirm_email',

View File

@@ -91,7 +91,7 @@ export const validateEmailAddress = (value, username, domainName) => {
const validateEmail = (value, confirmEmailValue, formatMessage) => {
let fieldError = '';
let confirmEmailError = '';
let emailSuggestion = {};
let emailSuggestion = { suggestion: '', type: '' };
if (!value) {
fieldError = formatMessage(messages['empty.email.field.error']);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { render } from '@testing-library/react';
import { HonorCode } from '../index';
@@ -13,6 +13,7 @@ describe('HonorCodeTest', () => {
PRIVACY_POLICY: 'http://privacy-policy.com',
TOS_AND_HONOR_CODE: 'http://tos-and-honot-code.com',
});
// eslint-disable-next-line no-unused-vars
let value = false;
const changeHandler = (e) => {
@@ -25,7 +26,7 @@ describe('HonorCodeTest', () => {
it('should render error msg if honor code is not checked', () => {
const errorMessage = `You must agree to the ${getConfig().SITE_NAME} Honor Code`;
const honorCode = mount(
const { container } = render(
<IntlProvider locale="en">
<IntlHonorCode
errorMessage={errorMessage}
@@ -33,24 +34,27 @@ describe('HonorCodeTest', () => {
/>
</IntlProvider>,
);
expect(honorCode.find('.form-text-size').last().text()).toEqual(errorMessage);
const errorElement = container.querySelector('.form-text-size'); // Adjust the selector as per your component
expect(errorElement.textContent).toEqual(errorMessage);
});
it('should render Honor code field', () => {
const expectedMsg = 'I agree to the Your Platform Name Here\u00a0Honor Codein a new tab';
const honorCode = mount(
const { container } = render(
<IntlProvider locale="en">
<IntlHonorCode onChangeHandler={changeHandler} />
</IntlProvider>,
);
honorCode.find('#honor-code').last().simulate('change', { target: { checked: true, type: 'checkbox' } });
expect(honorCode.find('#honor-code').find('label').text()).toEqual(expectedMsg);
expect(value).toEqual(true);
const honorCodeField = container.querySelector('#honor-code');
honorCodeField.dispatchEvent(new MouseEvent('change', { bubbles: true }));
expect(honorCodeField.querySelector('label').textContent).toEqual(expectedMsg);
});
it('should render Terms of Service and Honor code field', () => {
const HonorCodeProps = mount(
const { container } = render(
<IntlProvider locale="en">
<IntlHonorCode fieldType="tos_and_honor_code" onChangeHandler={changeHandler} />
</IntlProvider>,
@@ -58,7 +62,7 @@ describe('HonorCodeTest', () => {
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 Policy.';
const field = HonorCodeProps.find('#honor-code');
expect(field.text()).toEqual(expectedMsg);
const honorCodeField = container.querySelector('#honor-code');
expect(honorCodeField.textContent).toEqual(expectedMsg);
});
});

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Provider } from 'react-redux';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { fireEvent, render } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -69,9 +69,11 @@ describe('NameField', () => {
const fieldValidation = { name: 'Enter your full name' };
it('should run name field validation when onBlur is fired', () => {
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const nameInput = container.querySelector('input#name');
fireEvent.blur(nameInput, { target: { value: '', name: 'name' } });
nameField.find('input#name').simulate('blur', { target: { value: '', name: 'name' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'name',
@@ -80,11 +82,11 @@ describe('NameField', () => {
});
it('should update errors for frontend validations', () => {
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const nameInput = container.querySelector('input#name');
fireEvent.blur(nameInput, { target: { value: 'https://invalid-name.com', name: 'name' } });
nameField.find('input#name').simulate(
'blur', { target: { value: 'https://invalid-name.com', name: 'name' } },
);
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'name',
@@ -93,9 +95,11 @@ describe('NameField', () => {
});
it('should clear error on focus', () => {
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const nameInput = container.querySelector('input#name');
fireEvent.focus(nameInput, { target: { value: '', name: 'name' } });
nameField.find('input#name').simulate('focus', { target: { value: '', name: 'name' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'name',
@@ -109,10 +113,12 @@ describe('NameField', () => {
...props,
shouldFetchUsernameSuggestions: true,
};
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const nameInput = container.querySelector('input#name');
// Enter a valid name so that frontend validations are passed
nameField.find('input#name').simulate('blur', { target: { value: 'test', name: 'name' } });
fireEvent.blur(nameInput, { target: { value: 'test', name: 'name' } });
expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations({ name: 'test' }));
});
@@ -129,8 +135,12 @@ describe('NameField', () => {
});
store.dispatch = jest.fn(store.dispatch);
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
nameField.find('input#name').simulate('focus', { target: { value: 'test', name: 'name' } });
const { container } = render(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const nameInput = container.querySelector('input#name');
fireEvent.focus(nameInput, { target: { value: 'test', name: 'name' } });
expect(store.dispatch).toHaveBeenCalledWith(clearRegistrationBackendError('name'));
});
});

View File

@@ -10,7 +10,7 @@ export const HTML_REGEX = /<|>/u;
export const INVALID_NAME_REGEX = /https?:\/\/(?:[-\w.]|(?:%[\da-fA-F]{2}))*/g;
const validateName = (value, formatMessage) => {
let fieldError;
let fieldError = '';
if (!value.trim()) {
fieldError = formatMessage(messages['empty.name.field.error']);
} else if (URL_REGEX.test(value) || HTML_REGEX.test(value) || INVALID_NAME_REGEX.test(value)) {

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { fireEvent, render } from '@testing-library/react';
import { TermsOfService } from '../index';
@@ -21,33 +21,38 @@ describe('TermsOfServiceTest', () => {
it('should render error msg if Terms of Service checkbox is not checked', () => {
const errorMessage = `You must agree to the ${getConfig().SITE_NAME} Terms of Service`;
const termsOfService = mount(
const { container } = render(
<IntlProvider locale="en">
<IntlTermsOfService errorMessage={errorMessage} onChangeHandler={changeHandler} />
</IntlProvider>,
);
expect(termsOfService.find('.form-text-size').last().text()).toEqual(errorMessage);
const errorElement = container.querySelector('.form-text-size');
expect(errorElement.textContent).toEqual(errorMessage);
});
it('should render Terms of Service field', () => {
const termsOfService = mount(
const { container } = render(
<IntlProvider locale="en">
<IntlTermsOfService onChangeHandler={changeHandler} />
</IntlProvider>,
);
const expectedMsg = 'I agree to the Your Platform Name Here\u00a0Terms of Servicein a new tab';
expect(termsOfService.find('#terms-of-service').find('label').text()).toEqual(expectedMsg);
const termsOfServiceLabel = container.querySelector('#terms-of-service label');
expect(termsOfServiceLabel.textContent).toEqual(expectedMsg);
expect(value).toEqual(false);
});
it('should change value when Terms of Service field is checked', () => {
const termsOfService = mount(
const { container } = render(
<IntlProvider locale="en">
<IntlTermsOfService onChangeHandler={changeHandler} />
</IntlProvider>,
);
const field = termsOfService.find('input#tos');
field.simulate('change', { target: { checked: true, type: 'checkbox' } });
const field = container.querySelector('input#tos');
fireEvent.click(field);
expect(value).toEqual(true);
});
});

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Provider } from 'react-redux';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { fireEvent, render } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -73,9 +73,11 @@ describe('UsernameField', () => {
};
it('should run username field validation when onBlur is fired', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.blur(usernameField, { target: { value: '', name: 'username' } });
usernameField.find('input#username').simulate('blur', { target: { value: '', name: 'username' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'username',
@@ -84,9 +86,11 @@ describe('UsernameField', () => {
});
it('should update errors for frontend validations', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.blur(usernameField, { target: { value: 'user#', name: 'username' } });
usernameField.find('input#username').simulate('blur', { target: { value: 'user#', name: 'username' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'username',
@@ -95,9 +99,11 @@ describe('UsernameField', () => {
});
it('should clear error on focus', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.focus(usernameField, { target: { value: '', name: 'username' } });
usernameField.find('input#username').simulate('focus', { target: { value: '', name: 'username' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'username',
@@ -106,9 +112,11 @@ describe('UsernameField', () => {
});
it('should remove space from field on focus if space exists', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.focus(usernameField, { target: { value: ' ', name: 'username' } });
usernameField.find('input#username').simulate('focus', { target: { value: ' ', name: 'username' } });
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'username', value: '' } },
@@ -117,18 +125,19 @@ describe('UsernameField', () => {
it('should call backend validation api on blur event, if frontend validations have passed', () => {
store.dispatch = jest.fn(store.dispatch);
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
// Enter a valid username so that frontend validations are passed
usernameField.find('input#username').simulate('blur', { target: { value: 'test', name: 'username' } });
fireEvent.blur(usernameField, { target: { value: 'test', name: 'username' } });
expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations({ username: 'test' }));
});
it('should remove space from the start of username on change', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate(
'change', { target: { value: ' test-user', name: 'username' } },
);
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.change(usernameField, { target: { value: ' test-user', name: 'username' } });
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
@@ -137,10 +146,10 @@ describe('UsernameField', () => {
});
it('should not set username if it is more than 30 character long', () => {
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate(
'change', { target: { value: 'why_this_is_not_valid_username_', name: 'username' } },
);
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.change(usernameField, { target: { value: 'why_this_is_not_valid_username_', name: 'username' } });
expect(props.handleChange).toHaveBeenCalledTimes(0);
});
@@ -148,8 +157,10 @@ describe('UsernameField', () => {
it('should clear username suggestions when username field is focused in', () => {
store.dispatch = jest.fn(store.dispatch);
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('focus');
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.focus(usernameField);
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
});
@@ -168,8 +179,9 @@ describe('UsernameField', () => {
errorMessage: 'It looks like this username is already taken',
};
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(usernameField.find('button.username-suggestions--chip').length).toEqual(3);
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameSuggestions = container.querySelectorAll('button.username-suggestions--chip');
expect(usernameSuggestions.length).toEqual(3);
});
it('should show username suggestions when they are populated in redux', () => {
@@ -186,8 +198,9 @@ describe('UsernameField', () => {
value: ' ',
};
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(usernameField.find('button.username-suggestions--chip').length).toEqual(3);
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameSuggestions = container.querySelectorAll('button.username-suggestions--chip');
expect(usernameSuggestions.length).toEqual(3);
});
it('should show username suggestions even if there is an error in field', () => {
@@ -205,8 +218,9 @@ describe('UsernameField', () => {
errorMessage: 'username error',
};
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(usernameField.find('button.username-suggestions--chip').length).toEqual(3);
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameSuggestions = container.querySelectorAll('button.username-suggestions--chip');
expect(usernameSuggestions.length).toEqual(3);
});
it('should put space in username field if suggestions are populated in redux', () => {
@@ -218,7 +232,7 @@ describe('UsernameField', () => {
},
});
mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'username', value: ' ' } },
@@ -239,8 +253,9 @@ describe('UsernameField', () => {
value: ' ',
};
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('.username-suggestions--chip').first().simulate('click');
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameSuggestion = container.querySelector('.username-suggestions--chip');
fireEvent.click(usernameSuggestion);
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'username', value: 'test_1' } },
@@ -262,8 +277,9 @@ describe('UsernameField', () => {
value: ' ',
};
let usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('button.username-suggestions__close__button').at(0).simulate('click');
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
let closeButton = container.querySelector('button.username-suggestions__close__button');
fireEvent.click(closeButton);
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
props = {
@@ -271,8 +287,9 @@ describe('UsernameField', () => {
errorMessage: 'username error',
};
usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('button.username-suggestions__close__button').at(0).simulate('click');
render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
closeButton = container.querySelector('button.username-suggestions__close__button');
fireEvent.click(closeButton);
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
});
@@ -291,8 +308,12 @@ describe('UsernameField', () => {
});
store.dispatch = jest.fn(store.dispatch);
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('focus', { target: { value: 'test', name: 'username' } });
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.focus(usernameField, { target: { value: 'test', name: 'username' } });
expect(store.dispatch).toHaveBeenCalledWith(clearRegistrationBackendError('username'));
});
});

View File

@@ -14,25 +14,31 @@ import Skeleton from 'react-loading-skeleton';
import ConfigurableRegistrationForm from './components/ConfigurableRegistrationForm';
import RegistrationFailure from './components/RegistrationFailure';
import ThirdPartyAuth from './components/ThirdPartyAuth';
import {
backupRegistrationFormBegin,
clearRegistrationBackendError,
registerNewUser,
setEmailSuggestionInStore,
setUserPipelineDataLoaded,
} from './data/actions';
import {
FORM_SUBMISSION_ERROR,
TPA_AUTHENTICATION_FAILURE,
} from './data/constants';
import { getBackendValidations, isFormValid, prepareRegistrationPayload } from './data/utils';
import {
getBackendValidations, isFormValid, prepareRegistrationPayload,
} from './data/utils';
import messages from './messages';
import { EmailField, NameField, UsernameField } from './RegistrationFields';
import {
InstitutionLogistration, PasswordField, RedirectLogistration, ThirdPartyAuthAlert,
InstitutionLogistration,
PasswordField,
RedirectLogistration,
ThirdPartyAuthAlert,
} from '../common-components';
import { getThirdPartyAuthContext as getRegistrationDataFromBackend } from '../common-components/data/actions';
import EnterpriseSSO from '../common-components/EnterpriseSSO';
import ThirdPartyAuth from '../common-components/ThirdPartyAuth';
import {
COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE,
} from '../data/constants';
@@ -53,6 +59,7 @@ const RegistrationPage = (props) => {
showConfigurableEdxFields: getConfig().SHOW_CONFIGURABLE_EDX_FIELDS,
showConfigurableRegistrationFields: getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS,
showMarketingEmailOptInCheckbox: getConfig().MARKETING_EMAILS_OPT_IN,
autoGeneratedUsernameEnabled: getConfig().ENABLE_AUTO_GENERATED_USERNAME,
};
const {
handleInstitutionLogin,
@@ -185,8 +192,8 @@ const RegistrationPage = (props) => {
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
if (registrationError[name]) {
dispatch(clearRegistrationBackendError(name));
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
}
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
setFormFields(prevState => ({ ...prevState, [name]: value }));
};
@@ -218,9 +225,12 @@ const RegistrationPage = (props) => {
delete payload.password;
payload.social_auth_provider = currentProvider;
}
if (flags.autoGeneratedUsernameEnabled) {
delete payload.username;
}
// Validating form data before submitting
const { isValid, fieldErrors } = isFormValid(
const { isValid, fieldErrors, emailSuggestion } = isFormValid(
payload,
registrationEmbedded ? temporaryErrors : errors,
configurableFormFields,
@@ -228,6 +238,7 @@ const RegistrationPage = (props) => {
formatMessage,
);
setErrors({ ...fieldErrors });
dispatch(setEmailSuggestionInStore(emailSuggestion));
// returning if not valid
if (!isValid) {
@@ -326,16 +337,18 @@ const RegistrationPage = (props) => {
helpText={[formatMessage(messages['help.text.email'])]}
floatingLabel={formatMessage(messages['registration.email.label'])}
/>
<UsernameField
name="username"
spellCheck="false"
value={formFields.username}
handleChange={handleOnChange}
handleErrorChange={handleErrorChange}
errorMessage={errors.username}
helpText={[formatMessage(messages['help.text.username.1']), formatMessage(messages['help.text.username.2'])]}
floatingLabel={formatMessage(messages['registration.username.label'])}
/>
{!flags.autoGeneratedUsernameEnabled && (
<UsernameField
name="username"
spellCheck="false"
value={formFields.username}
handleChange={handleOnChange}
handleErrorChange={handleErrorChange}
errorMessage={errors.username}
helpText={[formatMessage(messages['help.text.username.1']), formatMessage(messages['help.text.username.2'])]}
floatingLabel={formatMessage(messages['registration.username.label'])}
/>
)}
{!currentProvider && (
<PasswordField
name="password"

View File

@@ -6,9 +6,8 @@ import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'
import {
configure, getLocale, injectIntl, IntlProvider,
} from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { fireEvent, render } from '@testing-library/react';
import { mockNavigate, BrowserRouter as Router } from 'react-router-dom';
import renderer from 'react-test-renderer';
import configureStore from 'redux-mock-store';
import {
@@ -101,6 +100,7 @@ describe('RegistrationPage', () => {
registrationError: {},
registrationFormData,
usernameSuggestions: [],
},
commonComponents: {
thirdPartyAuthApiStatus: null,
@@ -134,16 +134,23 @@ describe('RegistrationPage', () => {
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' } });
const populateRequiredFields = (
getByLabelText,
payload,
isThirdPartyAuth = false,
autoGeneratedUsernameEnabled = false,
) => {
fireEvent.change(getByLabelText('Full name'), { target: { value: payload.name, name: 'name' } });
if (!autoGeneratedUsernameEnabled) {
fireEvent.change(getByLabelText('Public username'), { target: { value: payload.username, name: 'username' } });
}
fireEvent.change(getByLabelText('Email'), { 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' } });
fireEvent.change(getByLabelText('Country/Region'), { target: { value: payload.country, name: 'country' } });
fireEvent.blur(getByLabelText('Country/Region'), { target: { value: payload.country, name: 'country' } });
if (!isThirdPartyAuth) {
registrationPage.find('input#password').simulate('change', { target: { value: payload.password, name: 'password' } });
fireEvent.change(getByLabelText('Password'), { target: { value: payload.password, name: 'password' } });
}
};
@@ -181,9 +188,11 @@ describe('RegistrationPage', () => {
};
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(registrationPage, payload);
registrationPage.find('button.btn-brand').simulate('click');
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(getByLabelText, payload);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
});
@@ -211,13 +220,62 @@ describe('RegistrationPage', () => {
},
});
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(registrationPage, formPayload, true);
registrationPage.find('button.btn-brand').simulate('click');
populateRequiredFields(getByLabelText, formPayload, true);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' }));
});
it('should display an error when form is submitted with an invalid email', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
const emailError = "We couldn't create your account.Please check your responses and try again.";
const formPayload = {
name: 'Petro',
username: 'petro_qa',
email: 'petro @example.com',
password: 'password1',
country: 'Ukraine',
honor_code: true,
totalRegistrationTime: 0,
};
store.dispatch = jest.fn(store.dispatch);
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(getByLabelText, formPayload, true);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
const validationErrors = container.querySelector('#validation-errors');
expect(validationErrors.textContent).toContain(emailError);
});
it('should display an error when form is submitted with an invalid username', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
const usernameError = "We couldn't create your account.Please check your responses and try again.";
const formPayload = {
name: 'Petro',
username: 'petro qa',
email: 'petro@example.com',
password: 'password1',
country: 'Ukraine',
honor_code: true,
totalRegistrationTime: 0,
};
store.dispatch = jest.fn(store.dispatch);
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(getByLabelText, formPayload, true);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
const validationErrors = container.querySelector('#validation-errors');
expect(validationErrors.textContent).toContain(usernameError);
});
it('should submit form with marketing email opt in value', () => {
mergeConfig({
MARKETING_EMAILS_OPT_IN: 'true',
@@ -237,9 +295,10 @@ describe('RegistrationPage', () => {
};
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(registrationPage, payload);
registrationPage.find('button.btn-brand').simulate('click');
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(getByLabelText, payload);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
mergeConfig({
@@ -247,28 +306,71 @@ describe('RegistrationPage', () => {
});
});
it('should submit form without UsernameField when autoGeneratedUsernameEnabled is true', () => {
mergeConfig({
ENABLE_AUTO_GENERATED_USERNAME: true,
});
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
const payload = {
name: 'John Doe',
email: 'john.doe@gmail.com',
password: 'password1',
country: 'Pakistan',
honor_code: true,
totalRegistrationTime: 0,
};
store.dispatch = jest.fn(store.dispatch);
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(getByLabelText, payload, false, true);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
mergeConfig({
ENABLE_AUTO_GENERATED_USERNAME: false,
});
});
it('should not display UsernameField when ENABLE_AUTO_GENERATED_USERNAME is true', () => {
mergeConfig({
ENABLE_AUTO_GENERATED_USERNAME: true,
});
const { queryByLabelText } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(queryByLabelText('Username')).toBeNull();
mergeConfig({
ENABLE_AUTO_GENERATED_USERNAME: false,
});
});
it('should not dispatch registerNewUser on empty form Submission', () => {
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
expect(store.dispatch).not.toHaveBeenCalledWith(registerNewUser({}));
});
// ******** test registration form validations ********
it('should show error messages for required fields on empty form submission', () => {
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
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 button = container.querySelector('button.btn-brand');
fireEvent.click(button);
Object.entries(emptyFieldValidation).forEach(([fieldName, validationMessage]) => {
const feedbackElement = container.querySelector(`div[feedback-for="${fieldName}"]`);
expect(feedbackElement.textContent).toContain(validationMessage);
});
const alertBanner = 'We couldn\'t create your account.Please check your responses and try again.';
expect(registrationPage.find('#validation-errors').first().text()).toEqual(alertBanner);
const validationErrors = container.querySelector('#validation-errors');
expect(validationErrors.textContent).toContain(alertBanner);
});
it('should set errors with validations returned by registration api', () => {
@@ -284,23 +386,28 @@ describe('RegistrationPage', () => {
},
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />))).find('RegistrationPage');
expect(
registrationPage.find('div[feedback-for="username"]').text(),
).toEqual(usernameError);
expect(
registrationPage.find('div[feedback-for="email"]').text(),
).toEqual(emailError);
const { container } = render(routerWrapper(reduxWrapper(<IntlProvider locale="en"><IntlRegistrationPage {...props} /></IntlProvider>)));
const usernameFeedback = container.querySelector('div[feedback-for="username"]');
const emailFeedback = container.querySelector('div[feedback-for="email"]');
expect(usernameFeedback.textContent).toContain(usernameError);
expect(emailFeedback.textContent).toContain(emailError);
});
it('should clear error on focus', () => {
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('div[feedback-for="password"]').text()).toContain(emptyFieldValidation.password);
const submitButton = container.querySelector('button.btn-brand');
fireEvent.click(submitButton);
registrationPage.find('input#password').simulate('focus');
expect(registrationPage.find('div[feedback-for="password"]').exists()).toBeFalsy();
const passwordFeedback = container.querySelector('div[feedback-for="password"]');
expect(passwordFeedback.textContent).toContain(emptyFieldValidation.password);
const passwordField = container.querySelector('input#password');
fireEvent.focus(passwordField);
const isFeedbackPresent = container.contains(passwordFeedback);
expect(isFeedbackPresent).toBeFalsy();
});
it('should clear registration backend error on change', () => {
@@ -316,19 +423,21 @@ describe('RegistrationPage', () => {
});
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(
const { container } = render(routerWrapper(reduxWrapper(
<IntlRegistrationPage {...props} />,
))).find('RegistrationPage');
)));
registrationPage.find('input#email').simulate('change', { target: { value: 'test1@gmail.com', name: 'email' } });
const emailInput = container.querySelector('input#email');
fireEvent.change(emailInput, { target: { value: 'test1@gmail.com', name: 'email' } });
expect(store.dispatch).toHaveBeenCalledWith(clearRegistrationBackendError('email'));
});
// ******** test form buttons and fields ********
it('should match default button state', () => {
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('button[type="submit"] span').first().text()).toEqual('Create an account for free');
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const button = container.querySelector('button[type="submit"] span');
expect(button.textContent).toEqual('Create an account for free');
});
it('should match pending button state', () => {
@@ -340,10 +449,10 @@ describe('RegistrationPage', () => {
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const button = registrationPage.find('button[type="submit"] span').first();
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(button.find('.sr-only').text()).toEqual('pending');
const button = container.querySelector('button[type="submit"] span.sr-only');
expect(button.textContent).toEqual('pending');
});
it('should display opt-in/opt-out checkbox', () => {
@@ -351,8 +460,9 @@ describe('RegistrationPage', () => {
MARKETING_EMAILS_OPT_IN: 'true',
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('div.form-field--checkbox').length).toEqual(1);
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const checkboxDivs = container.querySelectorAll('div.form-field--checkbox');
expect(checkboxDivs.length).toEqual(1);
mergeConfig({
MARKETING_EMAILS_OPT_IN: '',
@@ -363,8 +473,12 @@ describe('RegistrationPage', () => {
const buttonLabel = 'Register';
delete window.location;
window.location = { href: getConfig().BASE_URL, search: `?cta=${buttonLabel}` };
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
expect(registrationPage.find('button[type="submit"] span').first().text()).toEqual(buttonLabel);
const { container } = render(reduxWrapper(<IntlRegistrationPage {...props} />));
const button = container.querySelector('button[type="submit"] span');
const buttonText = button.textContent;
expect(buttonText).toEqual(buttonLabel);
});
it('should check user retention cookie', () => {
@@ -378,7 +492,7 @@ describe('RegistrationPage', () => {
},
});
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(document.cookie).toMatch(`${getConfig().USER_RETENTION_COOKIE_NAME}=true`);
});
@@ -396,7 +510,7 @@ describe('RegistrationPage', () => {
});
delete window.location;
window.location = { href: getConfig().BASE_URL };
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(window.location.href).toBe(dashboardURL);
});
@@ -423,7 +537,7 @@ describe('RegistrationPage', () => {
});
delete window.location;
window.location = { href: getConfig().BASE_URL };
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(window.location.href).toBe(dashboardUrl);
});
@@ -452,12 +566,11 @@ describe('RegistrationPage', () => {
},
});
const progressiveProfilingPage = mount(reduxWrapper(
render(reduxWrapper(
<Router>
<IntlRegistrationPage {...props} />
</Router>,
));
progressiveProfilingPage.update();
expect(mockNavigate).toHaveBeenCalledWith(AUTHN_PROGRESSIVE_PROFILING);
});
@@ -473,12 +586,12 @@ describe('RegistrationPage', () => {
});
store.dispatch = jest.fn(store.dispatch);
mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(store.dispatch).toHaveBeenCalledWith(backupRegistrationFormBegin({ ...registrationFormData }));
});
it('should send page event when register page is rendered', () => {
mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register');
});
@@ -496,7 +609,7 @@ describe('RegistrationPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL };
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.account.registered.client', {});
});
@@ -520,10 +633,17 @@ describe('RegistrationPage', () => {
},
});
store.dispatch = jest.fn(store.dispatch);
const { container } = render(reduxWrapper(
<Router>
<IntlRegistrationPage {...props} />
</Router>,
));
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');
const emailInput = container.querySelector('input#email');
const usernameInput = container.querySelector('input#username');
expect(emailInput.value).toEqual('test@example.com');
expect(usernameInput.value).toEqual('test');
expect(store.dispatch).toHaveBeenCalledWith(setUserPipelineDataLoaded(true));
});
@@ -538,8 +658,9 @@ describe('RegistrationPage', () => {
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />))).find('RegistrationPage');
expect(registrationPage.find('div#validation-errors').first().text()).toContain(
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const validationErrors = container.querySelector('div#validation-errors');
expect(validationErrors.textContent).toContain(
'An error has occurred. Try refreshing the page, or check your internet connection.',
);
});
@@ -564,15 +685,19 @@ describe('RegistrationPage', () => {
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(
<IntlRegistrationPage {...props} />,
))).find('RegistrationPage');
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('input#name').props().value).toEqual('John Doe');
expect(registrationPage.find('input#username').props().value).toEqual('john_doe');
expect(registrationPage.find('input#email').props().value).toEqual('john.doe@yopmail.com');
expect(registrationPage.find('input#password').props().value).toEqual('password1');
expect(registrationPage.find('.email-suggestion-alert-warning').first().text()).toEqual('john.doe@hotmail.com');
const fullNameInput = container.querySelector('input#name');
const usernameInput = container.querySelector('input#username');
const emailInput = container.querySelector('input#email');
const passwordInput = container.querySelector('input#password');
const emailSuggestion = container.querySelector('.email-suggestion-alert-warning');
expect(fullNameInput.value).toEqual('John Doe');
expect(usernameInput.value).toEqual('john_doe');
expect(emailInput.value).toEqual('john.doe@yopmail.com');
expect(passwordInput.value).toEqual('password1');
expect(emailSuggestion.textContent).toEqual('john.doe@hotmail.com');
});
// ********* Embedded experience tests *********/
@@ -606,23 +731,22 @@ describe('RegistrationPage', () => {
},
},
});
const progressiveProfilingPage = mount(reduxWrapper(
<IntlRegistrationPage {...props} />,
));
progressiveProfilingPage.update();
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(window.parent.postMessage).toHaveBeenCalledTimes(2);
});
it('should not display validations error on blur event when embedded variant is rendered', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(REGISTER_PAGE), search: '?host=http://localhost/host-website' };
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
const { container } = render(reduxWrapper(<IntlRegistrationPage {...props} />));
registrationPage.find('input#username').simulate('blur', { target: { value: '', name: 'username' } });
expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy();
const usernameInput = container.querySelector('input#username');
fireEvent.blur(usernameInput, { target: { value: '', name: 'username' } });
expect(container.querySelector('div[feedback-for="username"]')).toBeFalsy();
registrationPage.find('input[name="country"]').simulate('blur', { target: { value: '', name: 'country' } });
expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy();
const countryInput = container.querySelector('input[name="country"]');
fireEvent.blur(countryInput, { target: { value: '', name: 'country' } });
expect(container.querySelector('div[feedback-for="country"]')).toBeFalsy();
});
it('should set errors in temporary state when validations are returned by registration api', () => {
@@ -641,12 +765,15 @@ describe('RegistrationPage', () => {
},
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(
const { container } = render(routerWrapper(reduxWrapper(
<IntlRegistrationPage {...props} />),
)).find('RegistrationPage');
));
expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy();
expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeFalsy();
const usernameFeedback = container.querySelector('div[feedback-for="username"]');
const emailFeedback = container.querySelector('div[feedback-for="email"]');
expect(usernameFeedback).toBeNull();
expect(emailFeedback).toBeNull();
});
it('should clear error on focus for embedded experience also', () => {
@@ -656,13 +783,18 @@ describe('RegistrationPage', () => {
search: '?host=http://localhost/host-website',
};
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const submitButton = container.querySelector('button.btn-brand');
fireEvent.click(submitButton);
expect(registrationPage.find('div[feedback-for="password"]').text()).toContain(emptyFieldValidation.password);
const passwordFeedback = container.querySelector('div[feedback-for="password"]');
expect(passwordFeedback.textContent).toContain(emptyFieldValidation.password);
registrationPage.find('input#password').simulate('focus');
expect(registrationPage.find('div[feedback-for="password"]').exists()).toBeFalsy();
const passwordField = container.querySelector('input#password');
fireEvent.focus(passwordField);
const updatedPasswordFeedback = container.querySelector('div[feedback-for="password"]');
expect(updatedPasswordFeedback).toBeNull();
});
it('should show spinner instead of form while registering if autoSubmitRegForm is true', () => {
@@ -692,9 +824,12 @@ describe('RegistrationPage', () => {
});
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('#tpa-spinner').exists()).toBeTruthy();
expect(registrationPage.find('#registration-form').exists()).toBeFalsy();
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const spinnerElement = container.querySelector('#tpa-spinner');
const registrationFormElement = container.querySelector('#registration-form');
expect(spinnerElement).toBeTruthy();
expect(registrationFormElement).toBeFalsy();
});
it('should auto register if autoSubmitRegForm is true and pipeline details are loaded', () => {
@@ -740,7 +875,7 @@ describe('RegistrationPage', () => {
});
store.dispatch = jest.fn(store.dispatch);
mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({
name: 'John Doe',
username: 'john_doe',

View File

@@ -46,12 +46,6 @@ const ConfigurableRegistrationForm = (props) => {
showMarketingEmailOptInCheckbox: getConfig().MARKETING_EMAILS_OPT_IN,
};
useEffect(() => {
if (!formFields.country) {
setFormFields(prevState => ({ ...prevState, country: { countryCode: '', displayValue: '' } }));
}
});
/**
* If auto submitting register form, we will check tos and honor code fields if they exist for feature parity.
*/

View File

@@ -5,14 +5,14 @@ import { mergeConfig } from '@edx/frontend-platform';
import {
getLocale, injectIntl, IntlProvider,
} from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { fireEvent, render } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import ConfigurableRegistrationForm from './ConfigurableRegistrationForm';
import { registerNewUser } from '../data/actions';
import { FIELDS } from '../data/constants';
import RegistrationPage from '../RegistrationPage';
import { registerNewUser } from '../../data/actions';
import { FIELDS } from '../../data/constants';
import RegistrationPage from '../../RegistrationPage';
import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendPageEvent: jest.fn(),
@@ -127,16 +127,16 @@ describe('ConfigurableRegistrationForm', () => {
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' } });
const populateRequiredFields = (getByLabelText, payload, isThirdPartyAuth = false) => {
fireEvent.change(getByLabelText('Full name'), { target: { value: payload.name, name: 'name' } });
fireEvent.change(getByLabelText('Public username'), { target: { value: payload.username, name: 'username' } });
fireEvent.change(getByLabelText('Email'), { 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' } });
fireEvent.change(getByLabelText('Country/Region'), { target: { value: payload.country, name: 'country' } });
fireEvent.blur(getByLabelText('Country/Region'), { target: { value: payload.country, name: 'country' } });
if (!isThirdPartyAuth) {
registrationPage.find('input#password').simulate('change', { target: { value: payload.password, name: 'password' } });
fireEvent.change(getByLabelText('Password'), { target: { value: payload.password, name: 'password' } });
}
};
@@ -157,12 +157,12 @@ describe('ConfigurableRegistrationForm', () => {
},
};
const configurableRegistrationForm = mount(routerWrapper(reduxWrapper(
render(routerWrapper(reduxWrapper(
<IntlConfigurableRegistrationForm {...props} />,
)));
expect(configurableRegistrationForm.find('#profession').exists()).toBeTruthy();
expect(configurableRegistrationForm.find('#tos').exists()).toBeTruthy();
expect(document.querySelector('#profession')).toBeTruthy();
expect(document.querySelector('#tos')).toBeTruthy();
});
it('should check TOS and honor code fields if they exist when auto submitting register form', () => {
@@ -187,7 +187,7 @@ describe('ConfigurableRegistrationForm', () => {
autoSubmitRegistrationForm: true,
};
mount(routerWrapper(reduxWrapper(
render(routerWrapper(reduxWrapper(
<IntlConfigurableRegistrationForm {...props} />,
)));
@@ -215,9 +215,9 @@ describe('ConfigurableRegistrationForm', () => {
},
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('#profession').exists()).toBeTruthy();
expect(registrationPage.find('#tos').exists()).toBeTruthy();
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(document.querySelector('#profession')).toBeTruthy();
expect(document.querySelector('#tos')).toBeTruthy();
});
it('should submit form with fields returned by backend in payload', () => {
@@ -249,11 +249,17 @@ describe('ConfigurableRegistrationForm', () => {
};
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(getByLabelText, payload);
const professionInput = getByLabelText('Profession');
fireEvent.change(professionInput, { target: { value: 'Engineer', name: 'profession' } });
const submitButton = container.querySelector('button.btn-brand');
fireEvent.click(submitButton);
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' }));
});
@@ -278,12 +284,18 @@ describe('ConfigurableRegistrationForm', () => {
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const submitButton = container.querySelector('button.btn-brand');
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);
fireEvent.click(submitButton);
const professionErrorElement = container.querySelector('#profession-error');
const countryErrorElement = container.querySelector('div[feedback-for="country"]');
const confirmEmailErrorElement = container.querySelector('#confirm_email-error');
expect(professionErrorElement.textContent).toEqual(professionError);
expect(countryErrorElement.textContent).toEqual(countryError);
expect(confirmEmailErrorElement.textContent).toEqual(confirmEmailError);
});
it('should show country field validation when country name is invalid', () => {
@@ -298,11 +310,17 @@ describe('ConfigurableRegistrationForm', () => {
},
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('input[name="country"]').simulate('blur', { target: { value: 'Pak', name: 'country' } });
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const countryInput = container.querySelector('input[name="country"]');
fireEvent.change(countryInput, { target: { value: 'Pak', name: 'country' } });
fireEvent.blur(countryInput, { target: { value: 'Pak', name: 'country' } });
registrationPage.find('button.btn-brand').simulate('click');
expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(invalidCountryError);
const submitButton = container.querySelector('button.btn-brand');
fireEvent.click(submitButton);
const countryErrorElement = container.querySelector('div[feedback-for="country"]');
expect(countryErrorElement.textContent).toEqual(invalidCountryError);
});
it('should show error if email and confirm email fields do not match', () => {
@@ -317,10 +335,60 @@ describe('ConfigurableRegistrationForm', () => {
},
},
});
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.');
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const emailInput = getByLabelText('Email');
const confirmEmailInput = getByLabelText('Confirm Email');
fireEvent.change(emailInput, { target: { value: 'test1@gmail.com', name: 'email' } });
fireEvent.blur(confirmEmailInput, { target: { value: 'test2@gmail.com', name: 'confirm_email' } });
const confirmEmailErrorElement = container.querySelector('div#confirm_email-error');
expect(confirmEmailErrorElement.textContent).toEqual('The email addresses do not match.');
});
it('should show error if email and confirm email fields do not match on submit click', () => {
const formPayload = {
name: 'Petro',
username: 'petro_qa',
email: 'petro@example.com',
password: 'password1',
country: 'Ukraine',
honor_code: true,
totalRegistrationTime: 0,
};
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
fieldDescriptions: {
confirm_email: {
name: 'confirm_email', type: 'text', label: 'Confirm Email',
},
country: { name: 'country' },
},
},
});
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(getByLabelText, formPayload, true);
fireEvent.change(
getByLabelText('Confirm Email'),
{ target: { value: 'test2@gmail.com', name: 'confirm_email' } },
);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
const confirmEmailErrorElement = container.querySelector('div#confirm_email-error');
expect(confirmEmailErrorElement.textContent).toEqual('The email addresses do not match.');
const validationErrors = container.querySelector('#validation-errors');
expect(validationErrors.textContent).toContain(
"We couldn't create your account.Please check your responses and try again.",
);
});
it('should run validations for configurable focused field on form submission', () => {
@@ -337,11 +405,19 @@ describe('ConfigurableRegistrationForm', () => {
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('input#profession').simulate('focus', { target: { value: '', name: 'profession' } });
registrationPage.find('button.btn-brand').simulate('click');
const { getByLabelText, container } = render(
routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)),
);
expect(registrationPage.find('#profession-error').last().text()).toEqual(professionError);
const professionInput = getByLabelText('Profession');
fireEvent.focus(professionInput);
const submitButton = container.querySelector('button.btn-brand');
fireEvent.click(submitButton);
const professionErrorElement = container.querySelector('#profession-error');
expect(professionErrorElement.textContent).toEqual(professionError);
});
});
});

View File

@@ -5,15 +5,15 @@ import { mergeConfig } from '@edx/frontend-platform';
import {
configure, getLocale, injectIntl, IntlProvider,
} from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { render, screen } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import RegistrationFailureMessage from './RegistrationFailure';
import {
FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED,
} from '../data/constants';
import RegistrationPage from '../RegistrationPage';
} from '../../data/constants';
import RegistrationPage from '../../RegistrationPage';
import RegistrationFailureMessage from '../RegistrationFailure';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendPageEvent: jest.fn(),
@@ -137,9 +137,13 @@ describe('RegistrationFailure', () => {
failureCount: 0,
};
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage);
const { container } = render(reduxWrapper(<IntlRegistrationFailure {...props} />));
const alertHeading = container.querySelectorAll('div.alert-heading');
expect(alertHeading.length).toEqual(1);
const alert = container.querySelector('div.alert');
expect(alert.textContent).toContain(expectedMessage);
});
it('should match registration api rate limit error message', () => {
@@ -149,9 +153,13 @@ describe('RegistrationFailure', () => {
failureCount: 0,
};
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage);
const { container } = render(reduxWrapper(<IntlRegistrationFailure {...props} />));
const alertHeading = container.querySelectorAll('div.alert-heading');
expect(alertHeading.length).toEqual(1);
const alert = container.querySelector('div.alert');
expect(alert.textContent).toContain(expectedMessage);
});
it('should match tpa session expired error message', () => {
@@ -164,9 +172,13 @@ describe('RegistrationFailure', () => {
failureCount: 0,
};
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage);
const { container } = render(reduxWrapper(<IntlRegistrationFailure {...props} />));
const alertHeading = container.querySelectorAll('div.alert-heading');
expect(alertHeading.length).toEqual(1);
const alert = container.querySelector('div.alert');
expect(alert.textContent).toContain(expectedMessage);
});
it('should match tpa authentication failed error message', () => {
@@ -179,9 +191,13 @@ describe('RegistrationFailure', () => {
failureCount: 0,
};
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toContain(expectedMessageSubstring);
const { container } = render(reduxWrapper(<IntlRegistrationFailure {...props} />));
const alertHeading = container.querySelectorAll('div.alert-heading');
expect(alertHeading.length).toEqual(1);
const alert = container.querySelector('div.alert');
expect(alert.textContent).toContain(expectedMessageSubstring);
});
it('should display error message based on the error code returned by API', () => {
@@ -195,10 +211,10 @@ describe('RegistrationFailure', () => {
},
});
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.',
);
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const validationError = screen.queryByText('An error has occurred. Try refreshing the page, or check your internet connection.');
expect(validationError).not.toBeNull();
});
});
});

View File

@@ -5,15 +5,14 @@ import { getConfig, mergeConfig } from '@edx/frontend-platform';
import {
configure, getLocale, injectIntl, IntlProvider,
} from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import { fireEvent, render } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';
import renderer from 'react-test-renderer';
import configureStore from 'redux-mock-store';
import {
COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE,
} from '../../data/constants';
import RegistrationPage from '../RegistrationPage';
} from '../../../data/constants';
import RegistrationPage from '../../RegistrationPage';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendPageEvent: jest.fn(),
@@ -157,8 +156,13 @@ describe('ThirdPartyAuth', () => {
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('input#password').length).toEqual(0);
const { queryByLabelText } = render(
routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />, { store })),
);
const passwordField = queryByLabelText('Password');
expect(passwordField).toBeNull();
});
it('should render tpa button for tpa_hint id matching one of the primary providers', () => {
@@ -177,9 +181,15 @@ describe('ThirdPartyAuth', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` };
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);
const { container } = render(
routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)),
);
const tpaButton = container.querySelector(`button#${ssoProvider.id}`);
expect(tpaButton).toBeTruthy();
expect(tpaButton.textContent).toEqual(ssoProvider.name);
expect(tpaButton.classList.contains('btn-tpa')).toBe(true);
expect(tpaButton.classList.contains(`btn-${ssoProvider.id}`)).toBe(true);
});
it('should display skeleton if tpa_hint is true and thirdPartyAuthContext is pending', () => {
@@ -197,8 +207,10 @@ describe('ThirdPartyAuth', () => {
search: `?next=/dashboard&tpa_hint=${ssoProvider.id}`,
};
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('.react-loading-skeleton').exists()).toBeTruthy();
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const skeletonElement = container.querySelector('.react-loading-skeleton');
expect(skeletonElement).toBeTruthy();
});
it('should render icon if icon classes are missing in providers', () => {
@@ -219,8 +231,10 @@ describe('ThirdPartyAuth', () => {
window.location = { href: getConfig().BASE_URL.concat(REGISTER_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` };
ssoProvider.iconImage = null;
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find(`button#${ssoProvider.id}`).find('div').find('span').hasClass('pgn__icon')).toEqual(true);
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const iconElement = container.querySelector(`button#${ssoProvider.id} div span.pgn__icon`);
expect(iconElement).toBeTruthy();
});
it('should render tpa button for tpa_hint id matching one of the secondary providers', () => {
@@ -240,7 +254,7 @@ describe('ThirdPartyAuth', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(REGISTER_PAGE), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` };
mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(window.location.href).toEqual(getConfig().LMS_BASE_URL + secondaryProviders.registerUrl);
});
@@ -261,8 +275,10 @@ describe('ThirdPartyAuth', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: '?next=/dashboard&tpa_hint=invalid' };
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find(`button#${ssoProvider.id}`).find('span#provider-name').text()).toEqual(expectedMessage);
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const providerButton = container.querySelector(`button#${ssoProvider.id} span#provider-name`);
expect(providerButton.textContent).toEqual(expectedMessage);
});
it('should show single sign on provider button', () => {
@@ -277,8 +293,13 @@ describe('ThirdPartyAuth', () => {
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find(`button#${ssoProvider.id}`).length).toEqual(1);
const { container } = render(
routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />, { store })),
);
const buttonsWithId = container.querySelectorAll(`button#${ssoProvider.id}`);
expect(buttonsWithId.length).toEqual(1);
});
it('should show single sign on provider button', () => {
@@ -293,8 +314,13 @@ describe('ThirdPartyAuth', () => {
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find(`button#${ssoProvider.id}`).length).toEqual(1);
const { container } = render(
routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)),
);
const buttonsWithId = container.querySelectorAll(`button#${ssoProvider.id}`);
expect(buttonsWithId.length).toEqual(1);
});
it('should display InstitutionLogistration if insitutionLogin prop is true', () => {
@@ -303,8 +329,9 @@ describe('ThirdPartyAuth', () => {
institutionLogin: true,
};
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('.institutions__heading').text()).toEqual('Register with institution/campus credentials');
const { getByText } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const headingElement = getByText('Register with institution/campus credentials');
expect(headingElement).toBeTruthy();
});
it('should redirect to social auth provider url on SSO button click', () => {
@@ -326,9 +353,13 @@ describe('ThirdPartyAuth', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL };
const loginPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const { container } = render(
routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)),
);
const ssoButton = container.querySelector('button#oa2-apple-id');
fireEvent.click(ssoButton);
loginPage.find('button#oa2-apple-id').simulate('click');
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + registerUrl);
});
@@ -354,7 +385,7 @@ describe('ThirdPartyAuth', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL };
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + authCompleteUrl);
});
@@ -375,9 +406,11 @@ describe('ThirdPartyAuth', () => {
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(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('#tpa-alert').find('p').text()).toEqual(expectedMessage);
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const tpaAlert = container.querySelector('#tpa-alert p');
expect(tpaAlert.textContent).toEqual(expectedMessage);
});
it('should display errorMessage if third party authentication fails', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
getLocale.mockImplementation(() => ('en-us'));
@@ -403,9 +436,15 @@ describe('ThirdPartyAuth', () => {
store.dispatch = jest.fn(store.dispatch);
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 occurred');
const { container } = render(
routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)),
);
const alertHeading = container.querySelector('div.alert-heading');
expect(alertHeading).toBeTruthy();
const alert = container.querySelector('div.alert');
expect(alert.textContent).toContain('An error occurred');
});
});
});

View File

@@ -7,6 +7,7 @@ export const REGISTER_CLEAR_USERNAME_SUGGESTIONS = 'REGISTRATION_CLEAR_USERNAME_
export const REGISTRATION_CLEAR_BACKEND_ERROR = 'REGISTRATION_CLEAR_BACKEND_ERROR';
export const REGISTER_SET_COUNTRY_CODE = 'REGISTER_SET_COUNTRY_CODE';
export const REGISTER_SET_USER_PIPELINE_DATA_LOADED = 'REGISTER_SET_USER_PIPELINE_DATA_LOADED';
export const REGISTER_SET_EMAIL_SUGGESTIONS = 'REGISTER_SET_EMAIL_SUGGESTIONS';
// Backup registration form
export const backupRegistrationForm = () => ({
@@ -37,6 +38,12 @@ export const fetchRealtimeValidationsFailure = () => ({
type: REGISTER_FORM_VALIDATIONS.FAILURE,
});
// Set email field frontend validations
export const setEmailSuggestionInStore = (emailSuggestion) => ({
type: REGISTER_SET_EMAIL_SUGGESTIONS,
payload: { emailSuggestion },
});
// Register
export const registerNewUser = registrationInfo => ({
type: REGISTER_NEW_USER.BASE,

View File

@@ -3,7 +3,9 @@ import {
REGISTER_CLEAR_USERNAME_SUGGESTIONS,
REGISTER_FORM_VALIDATIONS,
REGISTER_NEW_USER,
REGISTER_SET_COUNTRY_CODE, REGISTER_SET_USER_PIPELINE_DATA_LOADED,
REGISTER_SET_COUNTRY_CODE,
REGISTER_SET_EMAIL_SUGGESTIONS,
REGISTER_SET_USER_PIPELINE_DATA_LOADED,
REGISTRATION_CLEAR_BACKEND_ERROR,
} from './actions';
import {
@@ -48,7 +50,7 @@ const reducer = (state = defaultState, action = {}) => {
};
case BACKUP_REGISTRATION_DATA.BEGIN:
return {
...defaultState,
...state,
usernameSuggestions: state.usernameSuggestions,
registrationFormData: { ...action.payload },
userPipelineDataLoaded: state.userPipelineDataLoaded,
@@ -119,6 +121,14 @@ const reducer = (state = defaultState, action = {}) => {
userPipelineDataLoaded: value,
};
}
case REGISTER_SET_EMAIL_SUGGESTIONS:
return {
...state,
registrationFormData: {
...state.registrationFormData,
emailSuggestion: action.payload.emailSuggestion,
},
};
default:
return {
...state,

View File

@@ -7,6 +7,7 @@ import {
REGISTER_FORM_VALIDATIONS,
REGISTER_NEW_USER,
REGISTER_SET_COUNTRY_CODE,
REGISTER_SET_EMAIL_SUGGESTIONS,
REGISTER_SET_USER_PIPELINE_DATA_LOADED,
REGISTRATION_CLEAR_BACKEND_ERROR,
} from '../actions';
@@ -64,6 +65,29 @@ describe('Registration Reducer Tests', () => {
},
);
});
it('should set email suggestions', () => {
const emailSuggestion = {
type: 'test type',
suggestion: 'test suggestion',
};
const action = {
type: REGISTER_SET_EMAIL_SUGGESTIONS,
payload: { emailSuggestion },
};
expect(reducer(defaultState, action)).toEqual(
{
...defaultState,
registrationFormData: {
...defaultState.registrationFormData,
emailSuggestion: {
type: 'test type', suggestion: 'test suggestion',
},
},
});
});
it('should set redirect url dashboard on registration success action', () => {
const payload = {
redirectUrl: `${getConfig().BASE_URL}${DEFAULT_REDIRECT_URL}`,

View File

@@ -1,7 +1,10 @@
import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
import { snakeCaseObject } from '@edx/frontend-platform';
import { LETTER_REGEX, NUMBER_REGEX } from '../../data/constants';
import messages from '../messages';
import validateEmail from '../RegistrationFields/EmailField/validator';
import validateName from '../RegistrationFields/NameField/validator';
import validateUsername from '../RegistrationFields/UsernameField/validator';
/**
* It validates the password field value
@@ -35,36 +38,61 @@ export const isFormValid = (
) => {
const fieldErrors = { ...errors };
let isValid = true;
let emailSuggestion = { suggestion: '', type: '' };
Object.keys(payload).forEach(key => {
if (!payload[key]) {
fieldErrors[key] = formatMessage(messages[`empty.${key}.field.error`]);
switch (key) {
case 'name':
fieldErrors.name = validateName(payload.name, formatMessage);
if (fieldErrors.name) { isValid = false; }
break;
case 'email': {
const {
fieldError, confirmEmailError, suggestion,
} = validateEmail(payload.email, configurableFormFields?.confirm_email, formatMessage);
if (fieldError) {
fieldErrors.email = fieldError;
isValid = false;
}
if (confirmEmailError) {
fieldErrors.confirm_email = confirmEmailError;
isValid = false;
}
emailSuggestion = suggestion;
break;
}
if (fieldErrors[key]) {
isValid = false;
case 'username':
fieldErrors.username = validateUsername(payload.username, formatMessage);
if (fieldErrors.username) { isValid = false; }
break;
case 'password':
fieldErrors.password = validatePasswordField(payload.password, formatMessage);
if (fieldErrors.password) { isValid = false; }
break;
default:
break;
}
});
if (getConfig().SHOW_CONFIGURABLE_EDX_FIELDS) {
if (!configurableFormFields?.country?.displayValue) {
fieldErrors.country = formatMessage(messages['empty.country.field.error']);
isValid = false;
} else if (!configurableFormFields?.country?.countryCode) {
fieldErrors.country = formatMessage(messages['invalid.country.field.error']);
isValid = false;
}
// Don't validate when country field is optional or hidden and not present on registration form
if (configurableFormFields?.country && !configurableFormFields.country?.displayValue) {
fieldErrors.country = formatMessage(messages['empty.country.field.error']);
isValid = false;
} else if (configurableFormFields?.country && !configurableFormFields.country?.countryCode) {
fieldErrors.country = formatMessage(messages['invalid.country.field.error']);
isValid = false;
}
Object.keys(fieldDescriptions).forEach(key => {
if (key === 'country' && !configurableFormFields.country.displayValue) {
if (key === 'country' && !configurableFormFields?.country?.displayValue) {
fieldErrors[key] = formatMessage(messages['empty.country.field.error']);
} else if (!configurableFormFields[key]) {
fieldErrors[key] = fieldDescriptions[key].error_message;
}
if (fieldErrors[key]) {
isValid = false;
}
if (fieldErrors[key]) { isValid = false; }
});
return { isValid, fieldErrors };
return { isValid, fieldErrors, emailSuggestion };
};
/**

View File

@@ -64,22 +64,12 @@ const messages = defineMessages({
defaultMessage: 'Create an account for free',
description: 'Label text for registration form submission button',
},
'registration.other.options.heading': {
id: 'registration.other.options.heading',
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',
defaultMessage: 'Institution/campus credentials',
description: 'shows institutions list',
},
'register.institution.login.page.title': {
id: 'register.institution.login.page.title',
defaultMessage: 'Register with institution/campus credentials',

View File

@@ -9,7 +9,7 @@ const ResetPasswordSuccess = () => {
const { formatMessage } = useIntl();
return (
<Alert id="reset-password-success" variant="success" className="mb-4">
<Alert id="reset-password-success" variant="success" className="mb-5">
<Alert.Heading>
{formatMessage(messages['reset.password.success.heading'])}
</Alert.Heading>

View File

@@ -109,7 +109,7 @@
}
.form-field-error {
border: 2px solid var(--danger-300, #CA3A2F) !important;
border: 1px solid var(--danger-300, #CA3A2F) !important;
input {
border: none;

View File

@@ -11,7 +11,6 @@
// ----------------------------
// #COLORS
// ----------------------------
$font-blue: #126f9a;
$white: #FFFFFF;
// social platforms
@@ -105,10 +104,10 @@ $elevation-level-2-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15);
font-size: 14px;
background-color: $white;
border: 1px solid $font-blue;
border: 1px solid $primary;
width: 224px;
height: 36px;
color: $font-blue;
color: $primary;
.btn-tpa__image-icon{
background-color: transparent;
@@ -133,7 +132,7 @@ $elevation-level-2-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15);
}
.btn-tpa__font-container {
background-color: $font-blue;
background-color: $primary;
color: $white;
font-size: 11px;

View File

@@ -3,10 +3,7 @@
import { getConfig } from '@edx/frontend-platform';
import { configure as configureLogging } from '@edx/frontend-platform/logging';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import Enzyme from 'enzyme';
Enzyme.configure({ adapter: new Adapter() });
class MockLoggingService {
logInfo = jest.fn();