Compare commits

..

1 Commits

Author SHA1 Message Date
mashal-m
cabc565af3 refactor: replace @edx/paragon and @edx/frontend-build 2023-12-29 13:52:56 +05:00
86 changed files with 9154 additions and 7235 deletions

2
.env
View File

@@ -23,13 +23,11 @@ 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,9 +19,6 @@ 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/2u-vanguards** to do it.
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/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@v4
uses: codecov/codecov-action@v3

View File

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

View File

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

View File

@@ -115,10 +115,6 @@ 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
==================================
@@ -187,7 +183,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/2u-vanguards>`_. Backstage pulls this data from the ``catalog-info.yaml``
found in `Backstage <https://backstage.openedx.org/catalog/default/group/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:2u-vanguards
owner: group:vanguards
type: 'service'
lifecycle: 'production'

View File

@@ -1,39 +0,0 @@
========================================
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.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

View File

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

12579
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,53 +33,54 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.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",
"@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",
"@fortawesome/react-fontawesome": "0.2.0",
"@openedx/paragon": "^22.1.1",
"@optimizely/react-sdk": "^2.9.1",
"@redux-devtools/extension": "3.3.0",
"@redux-devtools/extension": "3.2.5",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"algoliasearch": "^4.14.3",
"algoliasearch-helper": "^3.14.0",
"classnames": "2.5.1",
"core-js": "3.36.0",
"classnames": "2.3.2",
"core-js": "3.32.0",
"fastest-levenshtein": "1.0.16",
"form-urlencoded": "6.1.4",
"form-urlencoded": "6.1.0",
"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.4.0",
"react-loading-skeleton": "3.3.1",
"react-redux": "7.2.9",
"react-responsive": "8.2.0",
"react-router": "6.22.3",
"react-router-dom": "6.22.3",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-zendesk": "^0.1.13",
"redux": "4.2.0",
"redux-logger": "3.0.6",
"redux-mock-store": "1.5.4",
"redux-saga": "1.3.0",
"redux-saga": "1.2.3",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.14.1",
"regenerator-runtime": "0.14.0",
"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",
"@openedx/frontend-build": "13.0.28",
"babel-plugin-formatjs": "10.5.13",
"eslint-plugin-import": "2.29.1",
"@wojtekmaj/enzyme-adapter-react-17": "^0.8.0",
"babel-plugin-formatjs": "10.5.10",
"enzyme": "3.11.0",
"eslint-plugin-import": "2.28.0",
"glob": "7.2.3",
"history": "5.3.0",
"husky": "7.0.4",
"jest": "29.7.0",
"jest": "29.6.2",
"react-test-renderer": "^17.0.2"
}
}

View File

@@ -1,7 +1,7 @@
<!doctype html>
<html lang="en-us">
<head>
<title><%= (process.env.SITE_NAME && process.env.SITE_NAME != 'null') ? 'Authentication | ' + process.env.SITE_NAME : 'Authentication' %></title>
<title>Authn | <%= process.env.SITE_NAME %></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 { render, screen } from '@testing-library/react';
import { mount } from 'enzyme';
import { DefaultLargeLayout, DefaultMediumLayout, DefaultSmallLayout } from './index';
describe('Default Layout tests', () => {
it('should display the form passed as a child in SmallScreenLayout', () => {
render(
const smallScreen = mount(
<IntlProvider locale="en">
<div>
<DefaultSmallLayout />
<form aria-label="form">
<form>
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(screen.getByRole('form')).toBeDefined();
expect(smallScreen.find('form').exists()).toEqual(true);
});
it('should display the form passed as a child in MediumScreenLayout', () => {
render(
const mediumScreen = mount(
<IntlProvider locale="en">
<div>
<DefaultMediumLayout />
<form aria-label="form">
<form>
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(screen.getByRole('form')).toBeDefined();
expect(mediumScreen.find('form').exists()).toEqual(true);
});
it('should display the form passed as a child in LargeScreenLayout', () => {
render(
const largeScreen = mount(
<IntlProvider locale="en">
<div>
<DefaultLargeLayout />
<form aria-label="form">
<form>
<input type="text" />
</form>
</div>
</IntlProvider>,
);
expect(screen.getByRole('form')).toBeDefined();
expect(largeScreen.find('form').exists()).toEqual(true);
});
});

View File

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
import messages from './messages';
const LargeLayout = ({ fullName }) => {
const LargeLayout = ({ username }) => {
const { formatMessage } = useIntl();
return (
@@ -20,7 +20,7 @@ const LargeLayout = ({ fullName }) => {
<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, fullName })}
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, username })}
</h1>
<h2 className="complete-your-profile">
{formatMessage(messages['complete.your.profile.1'])}
@@ -43,7 +43,7 @@ const LargeLayout = ({ fullName }) => {
};
LargeLayout.propTypes = {
fullName: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
};
export default LargeLayout;

View File

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
import messages from './messages';
const MediumLayout = ({ fullName }) => {
const MediumLayout = ({ username }) => {
const { formatMessage } = useIntl();
return (
@@ -22,7 +22,7 @@ const MediumLayout = ({ fullName }) => {
<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, fullName })}
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, username })}
</h1>
<h2 className="display-1">
{formatMessage(messages['complete.your.profile.1'])}
@@ -46,7 +46,7 @@ const MediumLayout = ({ fullName }) => {
};
MediumLayout.propTypes = {
fullName: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
};
export default MediumLayout;

View File

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
import messages from './messages';
const SmallLayout = ({ fullName }) => {
const SmallLayout = ({ username }) => {
const { formatMessage } = useIntl();
return (
@@ -20,7 +20,7 @@ const SmallLayout = ({ fullName }) => {
<div className="small-yellow-line mt-4.5" />
<div>
<h1 className="h5 data-hj-suppress">
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, username })}
</h1>
<h2 className="h1">
{formatMessage(messages['complete.your.profile.1'])}
@@ -35,7 +35,7 @@ const SmallLayout = ({ fullName }) => {
};
SmallLayout.propTypes = {
fullName: PropTypes.string.isRequired,
username: 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}, {fullName}!',
defaultMessage: 'Welcome to {siteName}, {username}!',
description: 'Welcome message that appears on progressive profile page',
},
'complete.your.profile.1': {

View File

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

View File

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

View File

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

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}
{props.showScreenReaderText && <span className="sr-only">{formatMessage(messages['password.sr.only.helping.text'])}</span>}
<span className="sr-only">{formatMessage(messages['password.sr.only.helping.text'])}</span>
</Form.Control.Feedback>
)}
</Form.Group>
@@ -153,7 +153,6 @@ PasswordField.defaultProps = {
handleChange: () => {},
handleErrorChange: null,
showRequirements: true,
showScreenReaderText: true,
autoComplete: null,
};
@@ -169,7 +168,6 @@ PasswordField.propTypes = {
showRequirements: PropTypes.bool,
value: PropTypes.string.isRequired,
autoComplete: PropTypes.string,
showScreenReaderText: PropTypes.bool,
};
export default PasswordField;

View File

@@ -112,26 +112,6 @@ 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,14 +4,12 @@ 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 { fireEvent, render } from '@testing-library/react';
import { mount } from 'enzyme';
import FieldRenderer from '../FieldRenderer';
@@ -28,14 +28,13 @@ describe('FieldRendererTests', () => {
options: [['1997', '1997'], ['1998', '1998']],
};
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 } });
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
const field = fieldRenderer.find('select#yob-field');
field.simulate('change', { target: { value: 1997 } });
expect(input.type).toEqual('select-one');
expect(label.textContent).toContain(fieldData.label);
expect(value).toEqual('1997');
expect(field.type()).toEqual('select');
expect(fieldRenderer.find('label').text()).toEqual('Year of Birth');
expect(value).toEqual(1997);
});
it('should return null if no options are provided for select field', () => {
@@ -45,8 +44,8 @@ describe('FieldRendererTests', () => {
name: 'yob-field',
};
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
expect(container.innerHTML).toEqual('');
const fieldRenderer = mount(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
expect(fieldRenderer.html()).toBeNull();
});
it('should render textarea field', () => {
@@ -56,13 +55,12 @@ describe('FieldRendererTests', () => {
name: 'goals-field',
};
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.' } });
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.' } });
expect(input.type).toEqual(fieldData.type);
expect(label.textContent).toContain('Why do you want to join this platform?');
expect(field.type()).toEqual('textarea');
expect(fieldRenderer.find('label').text()).toEqual('Why do you want to join this platform?');
expect(value).toEqual('These are my goals.');
});
@@ -73,13 +71,12 @@ describe('FieldRendererTests', () => {
name: 'company-field',
};
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' } });
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
const field = fieldRenderer.find('#company-field').last();
field.simulate('change', { target: { value: 'ABC' } });
expect(input.type).toEqual(fieldData.type);
expect(label.textContent).toContain(fieldData.label);
expect(field.type()).toEqual('input');
expect(fieldRenderer.find('label').text()).toEqual('Company');
expect(value).toEqual('ABC');
});
@@ -90,13 +87,12 @@ describe('FieldRendererTests', () => {
name: 'marketing-emails-opt-in-field',
};
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);
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' } });
expect(input.type).toEqual(fieldData.type);
expect(label.textContent).toContain(fieldData.label);
expect(field.prop('type')).toEqual('checkbox');
expect(fieldRenderer.find('label').text()).toEqual(fieldData.label);
expect(value).toEqual(true);
});
@@ -105,8 +101,8 @@ describe('FieldRendererTests', () => {
type: 'unknown',
};
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
expect(container.innerHTML).toContain('');
const fieldRenderer = mount(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
expect(fieldRenderer.html()).toBeNull();
});
it('should run onBlur and onFocus functions for a field if given', () => {
@@ -121,7 +117,7 @@ describe('FieldRendererTests', () => {
functionValue = `${e.target.name} focussed`;
};
const { container } = render(
const fieldRenderer = mount(
<FieldRenderer
handleFocus={onFocus}
handleBlur={onBlur}
@@ -130,19 +126,19 @@ describe('FieldRendererTests', () => {
onChangeHandler={changeHandler}
/>,
);
const input = container.querySelector('#test-field');
const field = fieldRenderer.find('#test-field').last();
fireEvent.focus(input);
field.simulate('focus');
expect(functionValue).toEqual('test-field focussed');
fireEvent.blur(input);
field.simulate('blur');
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 { container } = render(
const fieldRenderer = mount(
<FieldRenderer
isRequired
fieldData={fieldData}
@@ -151,7 +147,7 @@ describe('FieldRendererTests', () => {
/>,
);
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('Enter your first name');
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Enter your first name');
});
it('should render error message for required select fields', () => {
@@ -159,7 +155,7 @@ describe('FieldRendererTests', () => {
type: 'select', label: 'Preference', name: 'preference-field', options: [['a', 'Opt 1'], ['b', 'Opt 2']],
};
const { container } = render(
const fieldRenderer = mount(
<FieldRenderer
isRequired
fieldData={fieldData}
@@ -168,13 +164,13 @@ describe('FieldRendererTests', () => {
/>,
);
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('Select your preference');
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Select your preference');
});
it('should render error message for required textarea fields', () => {
const fieldData = { type: 'textarea', label: 'Goals', name: 'goals-field' };
const { container } = render(
const fieldRenderer = mount(
<FieldRenderer
isRequired
fieldData={fieldData}
@@ -183,13 +179,13 @@ describe('FieldRendererTests', () => {
/>,
);
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('Tell us your goals');
expect(fieldRenderer.find('.form-text-size').last().text()).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 { container } = render(
const fieldRenderer = mount(
<FieldRenderer
isRequired
fieldData={fieldData}
@@ -198,6 +194,6 @@ describe('FieldRendererTests', () => {
/>,
);
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('You must agree to our Honor Code');
expect(fieldRenderer.find('.form-text-size').last().text()).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": "يجب أن يحتوي اسم المستخدم أو البريد الإلكتروني على 2 أحرف على الأقل.",
"username.or.email.format.validation.less.chars.message": "يجب أن يحتوي اسم المستخدم أو البريد الإلكتروني على 3 أحرف على الأقل.",
"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 2 characters.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 3 characters.",
"email.validation.message": "Enter your username or email",
"password.validation.message": "Password criteria has not been met",
"account.activation.success.message.title": "Success! You have activated your account.",
@@ -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 2 Zeichen lang sein.",
"username.or.email.format.validation.less.chars.message": "Benutzername oder E-Mail müssen mindestens 3 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 2 caracteres.",
"username.or.email.format.validation.less.chars.message": "El nombre de usuario o el correo electrónico deben tener al menos 3 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": "نام کاربری یا نشانی رایانامه حداقل باید 2 نویسه داشته باشد",
"username.or.email.format.validation.less.chars.message": "نام کاربری یا نشانی رایانامه حداقل باید 3 نویسه داشته باشد",
"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 2 caractères.",
"username.or.email.format.validation.less.chars.message": "Le nom d'utilisateur ou l'adresse courriel doit comporter au moins 3 caractères.",
"email.validation.message": "Entrez votre nom d'utilisateur ou votre adresse courriel",
"password.validation.message": "Les critères de mot de passe n'ont pas été remplis",
"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 2 caractères.",
"username.or.email.format.validation.less.chars.message": "Le nom d'utilisateur ou l'adresse courriel doit comporter au moins 3 caractères.",
"email.validation.message": "Entrez votre nom d'utilisateur ou votre adresse courriel",
"password.validation.message": "Les critères de mot de passe n'ont pas été remplis",
"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 2 characters.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 3 characters.",
"email.validation.message": "Enter your username or email",
"password.validation.message": "Password criteria has not been met",
"account.activation.success.message.title": "Success! You have activated your account.",
@@ -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 2 characters.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 3 characters.",
"email.validation.message": "Enter your username or email",
"password.validation.message": "Password criteria has not been met",
"account.activation.success.message.title": "Success! You have activated your account.",
@@ -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 2 caratteri.",
"username.or.email.format.validation.less.chars.message": "Il nome utente o l&#39;e-mail deve contenere almeno 3 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 2 characters.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 3 characters.",
"email.validation.message": "Enter your username or email",
"password.validation.message": "Password criteria has not been met",
"account.activation.success.message.title": "Success! You have activated your account.",
@@ -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 2 carateres.",
"username.or.email.format.validation.less.chars.message": "O nome de utilizador ou email deve ter pelo menos 3 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 2 characters.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 3 characters.",
"email.validation.message": "Enter your username or email",
"password.validation.message": "Password criteria has not been met",
"account.activation.success.message.title": "Success! You have activated your account.",
@@ -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 2 characters.",
"username.or.email.format.validation.less.chars.message": "Username or email must have at least 3 characters.",
"email.validation.message": "Enter your username or email",
"password.validation.message": "Password criteria has not been met",
"account.activation.success.message.title": "Success! You have activated your account.",
@@ -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": "用户名或电子邮件必须至少包含 2 个字符。",
"username.or.email.format.validation.less.chars.message": "用户名或电子邮件必须至少包含 3 个字符。",
"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,30 +9,29 @@ import PropTypes from 'prop-types';
import { ACCOUNT_ACTIVATION_MESSAGE } from './data/constants';
import messages from './messages';
const AccountActivationMessage = ({ messageType }) => {
const AccountActivationMessage = (props) => {
const { formatMessage } = useIntl();
if (!messageType) {
return null;
}
const { messageType } = props;
const variant = messageType === ACCOUNT_ACTIVATION_MESSAGE.ERROR ? 'danger' : messageType;
const activationOrConfirmation = getConfig().MARKETING_EMAILS_OPT_IN ? 'confirmation' : 'activation';
const activationOrVerification = getConfig().MARKETING_EMAILS_OPT_IN ? 'confirmation' : 'activation';
let activationMessage;
let heading;
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.${activationOrConfirmation}.success.message.title`]);
activationMessage = <span>{formatMessage(messages[`account.${activationOrConfirmation}.success.message`])}</span>;
heading = formatMessage(messages[`account.${activationOrVerification}.success.message.title`]);
activationMessage = <span>{formatMessage(messages[`account.${activationOrVerification}.success.message`])}</span>;
break;
}
case ACCOUNT_ACTIVATION_MESSAGE.INFO: {
activationMessage = formatMessage(messages[`account.${activationOrConfirmation}.info.message`]);
activationMessage = formatMessage(messages[`account.${activationOrVerification}.info.message`]);
break;
}
case ACCOUNT_ACTIVATION_MESSAGE.ERROR: {
@@ -42,7 +41,7 @@ const AccountActivationMessage = ({ messageType }) => {
</Alert.Link>
);
heading = formatMessage(messages[`account.${activationOrConfirmation}.error.message.title`]);
heading = formatMessage(messages[`account.${activationOrVerification}.error.message.title`]);
activationMessage = (
<FormattedMessage
id="account.activation.error.message"
@@ -60,7 +59,7 @@ const AccountActivationMessage = ({ messageType }) => {
return activationMessage ? (
<Alert
id="account-activation-message"
className="mb-5"
className="mb-4"
variant={variant}
icon={iconMapping[messageType]}
>
@@ -71,11 +70,7 @@ const AccountActivationMessage = ({ messageType }) => {
};
AccountActivationMessage.propTypes = {
messageType: PropTypes.string,
};
AccountActivationMessage.defaultProps = {
messageType: null,
messageType: PropTypes.string.isRequired,
};
export default AccountActivationMessage;

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getAuthService } from '@edx/frontend-platform/auth';
@@ -23,35 +23,22 @@ 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();
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 errorList;
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: {
errorMessage = (
errorList = (
<>
<strong>{formatMessage(messages['non.compliant.password.title'])}</strong>
<p>{formatMessage(messages['non.compliant.password.message'])}</p>
@@ -60,7 +47,7 @@ const LoginFailureMessage = (props) => {
break;
}
case FORBIDDEN_REQUEST:
errorMessage = <p>{formatMessage(messages['login.rate.limit.reached.message'])}</p>;
errorList = <p>{formatMessage(messages['login.rate.limit.reached.message'])}</p>;
break;
case INACTIVE_USER: {
const supportLink = (
@@ -68,7 +55,7 @@ const LoginFailureMessage = (props) => {
{formatMessage(messages['contact.support.link'], { platformName: context.platformName })}
</a>
);
errorMessage = (
errorList = (
<p>
<FormattedMessage
id="login.inactive.user.error"
@@ -77,7 +64,7 @@ const LoginFailureMessage = (props) => {
check your spam folders or {supportLink}."
values={{
lineBreak: <br />,
email: <strong className="data-hj-suppress">{context.email}</strong>,
email: <strong className="data-hj-suppress">{props.loginError.email}</strong>,
supportLink,
}}
/>
@@ -92,7 +79,7 @@ const LoginFailureMessage = (props) => {
{formatMessage(messages['tpa.account.link'], { provider: context.provider })}
</a>
);
errorMessage = (
errorList = (
<p>
<FormattedMessage
id="allowed.domain.login.error"
@@ -105,7 +92,7 @@ const LoginFailureMessage = (props) => {
break;
}
case INVALID_FORM:
errorMessage = <p>{formatMessage(messages['login.form.invalid.error.message'])}</p>;
errorList = <p>{formatMessage(messages['login.form.invalid.error.message'])}</p>;
break;
case FAILED_LOGIN_ATTEMPT: {
resetLink = (
@@ -113,7 +100,7 @@ const LoginFailureMessage = (props) => {
{formatMessage(messages['login.incorrect.credentials.error.before.account.blocked.text'])}
</Hyperlink>
);
errorMessage = (
errorList = (
<>
<p>
<FormattedMessage
@@ -137,7 +124,7 @@ const LoginFailureMessage = (props) => {
break;
}
case ACCOUNT_LOCKED_OUT: {
errorMessage = (
errorList = (
<>
<p>{formatMessage(messages['account.locked.out.message.1'])}</p>
<p>
@@ -154,9 +141,9 @@ const LoginFailureMessage = (props) => {
}
case INCORRECT_EMAIL_PASSWORD:
if (context.failureCount <= 1) {
errorMessage = <p>{formatMessage(messages['login.incorrect.credentials.error'])}</p>;
errorList = <p>{formatMessage(messages['login.incorrect.credentials.error'])}</p>;
} else if (context.failureCount === 2) {
errorMessage = (
errorList = (
<p>
<FormattedMessage
id="login.incorrect.credentials.error.with.reset.link"
@@ -174,56 +161,60 @@ const LoginFailureMessage = (props) => {
}
return (
<ChangePasswordPrompt
redirectUrl={context.redirectUrl}
redirectUrl={props.loginError.redirectUrl}
variant="nudge"
/>
);
case REQUIRE_PASSWORD_CHANGE:
return <ChangePasswordPrompt />;
case TPA_AUTHENTICATION_FAILURE:
errorMessage = (
<p>
{formatMessage(messages['login.tpa.authentication.failure'], {
platform_name: getConfig().SITE_NAME,
lineBreak: <br />,
errorMessage: context.errorMessage,
})}
errorList = (
<p>{formatMessage(messages['login.tpa.authentication.failure'], {
platform_name: getConfig().SITE_NAME,
lineBreak: <br />,
errorMessage: context.errorMessage,
})}
</p>
);
break;
case INTERNAL_SERVER_ERROR:
default:
errorMessage = <p>{formatMessage(messages['internal.server.error.message'])}</p>;
errorList = <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>
{ errorMessage }
{ errorList }
</Alert>
);
};
LoginFailureMessage.defaultProps = {
context: {},
loginError: {
redirectUrl: null,
errorCode: null,
errorMessage: null,
},
};
LoginFailureMessage.propTypes = {
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,
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,
}),
email: PropTypes.string,
errorCode: PropTypes.string,
redirectUrl: PropTypes.string,
}),
errorCode: PropTypes.string.isRequired,
errorCount: PropTypes.number.isRequired,
};
export default LoginFailureMessage;

View File

@@ -1,12 +1,13 @@
import React, { useEffect, useMemo, useState } from 'react';
import React from 'react';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl } from '@edx/frontend-platform/i18n';
import {
Form, StatefulButton,
Form, Hyperlink, Icon, 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';
@@ -14,26 +15,21 @@ import { Link } from 'react-router-dom';
import AccountActivationMessage from './AccountActivationMessage';
import {
backupLoginFormBegin,
dismissPasswordResetBanner,
loginRequest,
loginRemovePasswordResetBanner, loginRequest, loginRequestFailure, loginRequestReset, setLoginFormData,
} 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,
ThirdPartyAuthAlert,
FormGroup, InstitutionLogistration, PasswordField, RedirectLogistration,
RenderInstitutionButton, SocialAuthProviders, 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, PENDING_STATE, RESET_PAGE,
DEFAULT_STATE, ENTERPRISE_LOGIN_URL, PENDING_STATE, RESET_PAGE,
} from '../data/constants';
import {
getActivationStatus,
@@ -41,313 +37,324 @@ import {
getTpaHint,
getTpaProvider,
updatePathWithQueryParams,
windowScrollTo,
} from '../data/utils';
import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess';
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(), []);
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 [formFields, setFormFields] = useState({ ...backedUpFormData.formFields });
const [errorCode, setErrorCode] = useState({ type: '', count: 0, context: {} });
const [errors, setErrors] = useState({ ...backedUpFormData.errors });
const tpaHint = getTpaHint();
useEffect(() => {
componentDidMount() {
sendPageEvent('login_and_registration', 'login');
}, []);
const payload = { ...this.queryParams };
useEffect(() => {
const payload = { ...queryParams };
if (tpaHint) {
payload.tpa_hint = tpaHint;
if (this.tpaHint) {
payload.tpa_hint = this.tpaHint;
}
getTPADataFromBackend(payload);
}, [getTPADataFromBackend, queryParams, tpaHint]);
/**
* Backup the login form in redux when login page is toggled.
*/
useEffect(() => {
if (shouldBackupState) {
backupFormState({
formFields: { ...formFields },
errors: { ...errors },
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,
});
return false;
}
}, [shouldBackupState, formFields, errors, backupFormState]);
return true;
}
useEffect(() => {
if (loginErrorCode) {
setErrorCode(prevState => ({
type: loginErrorCode,
count: prevState.count + 1,
context: { ...loginErrorContext },
}));
componentWillUnmount() {
if (this.props.resetPassword) {
this.props.loginRemovePasswordResetBanner();
}
}, [loginErrorCode, loginErrorContext]);
}
useEffect(() => {
if (thirdPartyErrorMessage) {
setErrorCode((prevState) => ({
type: TPA_AUTHENTICATION_FAILURE,
count: prevState.count + 1,
context: {
errorMessage: thirdPartyErrorMessage,
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,
},
}));
}
}, [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: {} }));
});
this.props.loginRequestFailure({
errorCode: INVALID_FORM,
});
return;
}
// add query params to the payload
const payload = {
email_or_username: formData.emailOrUsername,
password: formData.password,
...queryParams,
email_or_username: emailOrUsername, password, ...this.queryParams,
};
props.loginRequest(payload);
this.props.loginRequest(payload);
};
const handleOnChange = (event) => {
const { name, value } = event.target;
setFormFields(prevState => ({ ...prevState, [name]: value }));
handleOnFocus = (e) => {
const { errors } = this.state;
errors[e.target.name] = '';
this.props.setLoginFormData({
errors,
});
};
const handleOnFocus = (event) => {
const { name } = event.target;
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
handleOnBlur = (e) => {
const payload = {
[e.target.name]: e.target.value,
};
this.props.setLoginFormData(payload);
};
const trackForgotPasswordLinkClick = () => {
handleForgotPasswordLinkClickEvent = () => {
sendTrackEvent('edx.bi.password-reset_form.toggled', { category: 'user-engagement' });
};
const { provider, skipHintedLogin } = getTpaProvider(tpaHint, providers, secondaryProviders);
validateEmail(email) {
const { errors } = this.state;
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} />;
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 = '';
}
return errors.emailOrUsername;
}
if (institutionLogin) {
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;
return (
<InstitutionLogistration
secondaryProviders={secondaryProviders}
headingTitle={formatMessage(messages['institution.login.page.title'])}
/>
<>
{(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>
)}
</>
)}
</>
);
}
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}
/>
<ThirdPartyAuthAlert
currentProvider={currentProvider}
platformName={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'])}
/>
<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>
</>
);
};
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,
};
};
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'])}
/>
);
}
const tpaAuthenticationError = {};
if (thirdPartyAuthContext.errorMessage) {
tpaAuthenticationError.context = {
errorMessage: thirdPartyAuthContext.errorMessage,
};
tpaAuthenticationError.errorCode = TPA_AUTHENTICATION_FAILURE;
}
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,
};
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}
/>
<div className="mw-xs mt-3">
<ThirdPartyAuthAlert
currentProvider={thirdPartyAuthContext.currentProvider}
platformName={thirdPartyAuthContext.platformName}
/>
{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>
</>
);
}
render() {
const {
intl, submitState, thirdPartyAuthContext, thirdPartyAuthApiStatus,
} = this.props;
const { currentProvider, providers, secondaryProviders } = this.props.thirdPartyAuthContext;
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.defaultProps = {
backedUpFormData: {
formFields: {
emailOrUsername: '', password: '',
},
loginResult: null,
loginError: null,
loginFormData: {
emailOrUsername: '',
password: '',
errors: {
emailOrUsername: '', password: '',
emailOrUsername: '',
password: '',
},
},
loginErrorCode: null,
loginErrorContext: {},
loginResult: {},
shouldBackupState: false,
showResetPasswordSuccessBanner: false,
resetPassword: false,
submitState: DEFAULT_STATE,
thirdPartyAuthApiStatus: PENDING_STATE,
thirdPartyAuthApiStatus: 'pending',
thirdPartyAuthContext: {
currentProvider: null,
errorMessage: null,
@@ -357,12 +364,68 @@ 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,
{
backupFormState: backupLoginFormBegin,
dismissPasswordResetBanner,
getThirdPartyAuthContext,
loginRequest,
getTPADataFromBackend: getThirdPartyAuthContext,
loginRequestFailure,
loginRequestReset,
setLoginFormData,
loginRemovePasswordResetBanner,
},
)(injectIntl(LoginPage));

View File

@@ -1,18 +1,8 @@
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 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 },
});
export const LOGIN_PERSIST_FORM_DATA = 'LOGIN_PERSIST_FORM_DATA';
export const LOGIN_REMOVE_PASSWORD_RESET_BANNER = 'LOGIN_REMOVE_PASSWORD_RESET_BANNER';
// Login
export const loginRequest = creds => ({
@@ -34,6 +24,15 @@ export const loginRequestFailure = (loginError) => ({
payload: { loginError },
});
export const dismissPasswordResetBanner = () => ({
type: DISMISS_PASSWORD_RESET_BANNER,
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,
});

View File

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

View File

@@ -0,0 +1,20 @@
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,154 +1,57 @@
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 {
LOGIN_PERSIST_FORM_DATA, LOGIN_REMOVE_PASSWORD_RESET_BANNER,
} from '../actions';
import reducer from '../reducers';
describe('login reducer', () => {
const defaultState = {
loginErrorCode: '',
loginErrorContext: {},
loginResult: {},
loginFormData: {
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
shouldBackupState: false,
showResetPasswordSuccessBanner: false,
submitState: DEFAULT_STATE,
};
it('should update state to show reset password success banner', () => {
const action = {
type: RESET_PASSWORD.SUCCESS,
};
expect(
reducer(defaultState, action),
).toEqual(
{
...defaultState,
showResetPasswordSuccessBanner: true,
},
);
});
it('should set the flag which keeps the login form data in redux state', () => {
const action = {
type: BACKUP_LOGIN_DATA.BASE,
};
expect(
reducer(defaultState, action),
).toEqual(
{
...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,
it('should set loginFormData', () => {
const state = {
loginFormData: {
password: '',
emailOrUsername: '',
errors: {
emailOrUsername: '',
password: '',
},
},
email: 'test@example.com',
redirectUrl: '',
resetPassword: false,
};
const formData = {
password: 'johndoe',
emailOrUsername: 'john@gmail.com',
};
const action = {
type: LOGIN_REQUEST.FAILURE,
payload,
type: LOGIN_PERSIST_FORM_DATA,
payload: { formData },
};
expect(reducer(defaultState, action)).toEqual(
expect(
reducer(state, action),
).toEqual(
{
...defaultState,
loginErrorCode: payload.loginError.errorCode,
loginErrorContext: { ...payload.loginError.context, email: payload.email, redirectUrl: payload.redirectUrl },
submitState: DEFAULT_STATE,
loginFormData: {
...state.loginFormData,
password: 'johndoe',
emailOrUsername: 'john@gmail.com',
},
resetPassword: false,
},
);
});
it('should set resetPassword', () => {
const state = {
resetPassword: true,
};
const action = {
type: LOGIN_REMOVE_PASSWORD_RESET_BANNER,
};
expect(
reducer(state, action),
).toEqual(
{
resetPassword: false,
},
);
});

View File

@@ -1,5 +1,4 @@
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,6 +42,11 @@ 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',
@@ -59,10 +64,15 @@ 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 2 characters.',
description: 'Validation message that appears when username or email address is less than 2 characters',
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',
},
'email.validation.message': {
id: 'email.validation.message',

View File

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

View File

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

View File

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

View File

@@ -2,18 +2,19 @@ import React from 'react';
import { Provider } from 'react-redux';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { sendPageEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
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 { backupLoginFormBegin, dismissPasswordResetBanner, loginRequest } from '../data/actions';
import {
loginRemovePasswordResetBanner, loginRequest, loginRequestFailure, loginRequestReset, setLoginFormData,
} from '../data/actions';
import { INTERNAL_SERVER_ERROR } from '../data/constants';
import LoginFailureMessage from '../LoginFailure';
import LoginPage from '../LoginPage';
jest.mock('@edx/frontend-platform/analytics', () => ({
@@ -24,14 +25,15 @@ 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>
@@ -81,151 +83,81 @@ 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} />));
render(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');
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' }));
expect(store.dispatch).toHaveBeenCalledWith(loginRequest({ email_or_username: 'test@example.com', password: 'password' }));
});
it('should not dispatch loginRequest on empty form submission', () => {
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlLoginPage {...props} />));
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
loginPage.find('button.btn-brand').simulate('click');
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 for invalid email (less than 2 characters), on form submission', () => {
it('should match state on empty form submission', () => {
const errorState = { emailOrUsername: 'Enter your username or email', password: 'Enter your password' };
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlLoginPage {...props} />));
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
loginPage.find('button.btn-brand').simulate('click');
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();
// Check that loginRequestFailure was dispatched and state is updated
expect(loginPage.state('errors')).toEqual(errorState);
expect(store.dispatch).toHaveBeenCalledWith(loginRequestFailure({ errorCode: 'invalid-form' }));
});
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' },
));
expect(container.querySelector('div[feedback-for="emailOrUsername"]').textContent).toEqual(emptyFieldValidation.emailOrUsername);
expect(container.querySelector('div[feedback-for="password"]').textContent).toEqual(emptyFieldValidation.password);
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 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 () => {
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);
render(reduxWrapper(<IntlLoginPage {...props} />));
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
await act(async () => {
// clicking submit button with empty fields to make the errors appear
fireEvent.click(screen.getByText(
'',
{ selector: '.btn-brand' },
));
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');
// focusing the fields to verify that the errors are cleared
fireEvent.focus(screen.getByText(
'',
{ selector: '#password' },
));
fireEvent.focus(screen.getByText(
'',
{ selector: '#emailOrUsername' },
));
});
expect(loginPage.state('errors')).toEqual(errorState);
});
// verifying that the errors are cleared
await waitFor(() => {
expect(screen.queryByText('Enter your username or email')).toBeNull();
});
it('should reset field related error messages on onFocus event', () => {
const errorState = { emailOrUsername: '', password: '' };
store.dispatch = jest.fn(store.dispatch);
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
loginPage.find('button.btn-brand').simulate('click');
loginPage.find('input#emailOrUsername').simulate('focus');
loginPage.find('input#password').simulate('focus');
expect(loginPage.state('errors')).toEqual(errorState);
});
// ******** test form buttons and links ********
it('should match default button state', () => {
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText('Sign in')).toBeDefined();
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('button[type="submit"] span').first().text()).toEqual('Sign in');
});
it('should match pending button state', () => {
@@ -237,105 +169,97 @@ describe('LoginPage', () => {
},
});
render(reduxWrapper(<IntlLoginPage {...props} />));
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
const button = loginPage.find('button[type="submit"] span').first();
expect(screen.getByText(
'pending',
).textContent).toEqual('pending');
expect(button.find('.sr-only').text()).toEqual('pending');
});
it('should show forgot password link', () => {
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'Forgot password',
{ selector: '#forgot-password' },
).textContent).toEqual('Forgot password');
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('a#forgot-password').text()).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,
}],
},
},
});
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: `#${ssoProvider.id}` },
)).toBeDefined();
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find(`button#${ssoProvider.id}`).length).toEqual(1);
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,
},
},
});
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 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 hide sign-in header and enterprise login upon successful SSO authentication', () => {
it('should not show sign-in header and enterprise login once user authenticated through SSO', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
providers: [{
...ssoProvider,
}],
currentProvider: 'Apple',
},
},
});
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Company or school credentials')).toBeNull();
expect(queryByText('Or sign in with:')).toBeNull();
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);
});
// ******** test enterprise login enabled scenarios ********
it('should show sign-in header providers (ENABLE ENTERPRISE LOGIN)', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
it('should show sign-in header for enterprise login', () => {
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
providers: [{
...ssoProvider,
}],
},
},
});
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();
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);
});
// ******** test enterprise login disabled scenarios ********
it('should show sign-in header for institution login if enterprise login is disabled', () => {
it('should show sign-in header with providers (DISABLE ENTERPRISE LOGIN)', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: true,
});
@@ -346,16 +270,63 @@ describe('LoginPage', () => {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [ssoProvider],
secondaryProviders: [secondaryProviders],
providers: [{
...ssoProvider,
}],
},
},
});
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();
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);
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
@@ -380,48 +351,40 @@ describe('LoginPage', () => {
},
});
const { queryByText } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(queryByText('Or sign in with:')).toBeDefined();
expect(queryByText('Institution/campus credentials')).toBeDefined();
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);
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
});
it('should not show sign-in header without primary or secondary providers', () => {
it('should show sign-in header with Providers and secondary Providers', () => {
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: true,
});
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
providers: [{
...ssoProvider,
}],
secondaryProviders: [{
...secondaryProviders,
}],
},
},
});
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();
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);
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
@@ -430,22 +393,30 @@ describe('LoginPage', () => {
// ******** test alert messages ********
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.';
it('should match login error message', () => {
const errorMessage = 'An error has occurred. Try refreshing the page, or check your internet connection.';
store = mockStore({
...initialState,
login: {
...initialState.login,
loginErrorCode: INTERNAL_SERVER_ERROR,
loginError: { errorCode: INTERNAL_SERVER_ERROR },
},
});
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toEqual(`${expectedMessage}`);
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);
});
it('should match third party auth alert', () => {
@@ -465,14 +436,11 @@ describe('LoginPage', () => {
+ 'linked '}${ getConfig().SITE_NAME } account. To link your accounts, sign in now using your ${
getConfig().SITE_NAME } password.`;
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#tpa-alert' },
).textContent).toEqual(expectedMessage);
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('#tpa-alert').find('p').text()).toEqual(expectedMessage);
});
it('should show third party authentication failure message', () => {
it('should show tpa authentication fails error message', () => {
store = mockStore({
...initialState,
commonComponents: {
@@ -484,11 +452,9 @@ describe('LoginPage', () => {
},
},
});
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toContain('An error occurred');
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('#login-failure-alert').find('p').text()).toContain('An error occurred');
});
it('should match invalid login form error message', () => {
@@ -497,36 +463,33 @@ describe('LoginPage', () => {
...initialState,
login: {
...initialState.login,
loginErrorCode: 'invalid-form',
loginError: { errorCode: 'invalid-form' },
},
});
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'',
{ selector: '#login-failure-alert' },
).textContent).toContain(errorMessage);
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('#login-failure-alert p').first().text()).toEqual(errorMessage);
});
// ******** test redirection ********
it('should redirect to url returned by login endpoint after successful authentication', () => {
const dashboardURL = 'https://test.com/testing-dashboard/';
it('should redirect to url returned by login endpoint', () => {
const dashboardUrl = 'http://localhost:18000/enterprise/select/active/?success_url=/dashboard';
store = mockStore({
...initialState,
login: {
...initialState.login,
loginResult: {
success: true,
redirectUrl: dashboardURL,
redirectUrl: dashboardUrl,
},
},
});
delete window.location;
window.location = { href: getConfig().BASE_URL };
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(window.location.href).toBe(dashboardURL);
renderer.create(reduxWrapper(<IntlLoginPage {...props} />));
expect(window.location.href).toBe(dashboardUrl);
});
it('should redirect to finishAuthUrl upon successful login via SSO', () => {
@@ -552,18 +515,26 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL };
render(reduxWrapper(<IntlLoginPage {...props} />));
renderer.create(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],
providers: [{
...ssoProvider,
loginUrl,
}],
},
},
});
@@ -571,37 +542,14 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL };
render(reduxWrapper(<IntlLoginPage {...props} />));
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByText(
'',
{ selector: '#oa2-apple-id' },
));
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + ssoProvider.loginUrl);
});
loginPage.find('button#oa2-apple-id').simulate('click');
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + loginUrl);
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,
},
},
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
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 ********
@@ -622,35 +570,9 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` };
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();
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);
});
it('should render tpa button for tpa_hint id matching one of the secondary providers', () => {
@@ -671,11 +593,15 @@ describe('LoginPage', () => {
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` };
secondaryProviders.iconImage = null;
render(reduxWrapper(<IntlLoginPage {...props} />));
mount(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: {
@@ -691,15 +617,15 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: '?next=/dashboard&tpa_hint=invalid' };
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(container.querySelector(`#${ssoProvider.id}`).querySelector('#provider-name').textContent).toEqual(`${ssoProvider.name}`);
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find(`button#${ssoProvider.id}`).find('span#provider-name').text()).toEqual(`${ssoProvider.name}`);
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
});
});
it('should render "other ways to sign in" button on the tpa_hint page', () => {
it('should render other ways to sign in button', () => {
store = mockStore({
...initialState,
commonComponents: {
@@ -715,17 +641,14 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?tpa_hint=${ssoProvider.id}` };
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'Show me other ways to sign in or register',
).textContent).toBeDefined();
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');
});
it('should render other ways to sign in button when public account creation is disabled', () => {
it('should render other ways to sign in button when public account creation disabled', () => {
mergeConfig({
ALLOW_PUBLIC_ACCOUNT_CREATION: false,
});
store = mockStore({
...initialState,
commonComponents: {
@@ -741,93 +664,106 @@ describe('LoginPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL.concat(LOGIN_PAGE), search: `?tpa_hint=${ssoProvider.id}` };
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(screen.getByText(
'Show me other ways to sign in',
).textContent).toBeDefined();
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('button#other-ways-to-sign-in').text()).toEqual('Show me other ways to sign in');
});
// ******** miscellaneous tests ********
it('should send page event when login page is rendered', () => {
render(reduxWrapper(<IntlLoginPage {...props} />));
mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'login');
});
it('tests that form is in invalid state when it is submitted', () => {
store = mockStore({
...initialState,
login: {
...initialState.login,
shouldBackupState: true,
},
});
it('tests that form is only scrollable on form submission', () => {
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(store.dispatch).toHaveBeenCalledWith(backupLoginFormBegin(
{
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
},
));
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 send track event when forgot password link is clicked', () => {
render(reduxWrapper(<IntlLoginPage {...props} />));
fireEvent.click(screen.getByText(
'Forgot password',
{ selector: '#forgot-password' },
));
it('should reset login form errors', () => {
const errorState = { emailOrUsername: '', password: '' };
store.dispatch = jest.fn(store.dispatch);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.password-reset_form.toggled', { category: 'user-engagement' });
const loginPage = (mount(reduxWrapper(<IntlLoginPage {...props} />))).find('LoginPage');
expect(store.dispatch).toHaveBeenCalledWith(loginRequestReset());
expect(loginPage.state('errors')).toEqual(errorState);
});
it('should backup the login form state when shouldBackupState is true', () => {
store = mockStore({
...initialState,
login: {
...initialState.login,
shouldBackupState: true,
},
});
// persists form data tests
store.dispatch = jest.fn(store.dispatch);
render(reduxWrapper(<IntlLoginPage {...props} />));
expect(store.dispatch).toHaveBeenCalledWith(backupLoginFormBegin(
{
formFields: {
emailOrUsername: '', password: '',
},
errors: {
emailOrUsername: '', password: '',
},
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', () => {
store = mockStore({
...initialState,
login: {
...initialState.login,
loginFormData: {
formFields: {
emailOrUsername: 'john_doe', password: 'test-password',
},
errors: {
emailOrUsername: '', password: '',
},
},
resetPassword: true,
},
});
const { container } = render(reduxWrapper(<IntlLoginPage {...props} />));
expect(container.querySelector('input#emailOrUsername').value).toEqual('john_doe');
expect(container.querySelector('input#password').value).toEqual('test-password');
store.dispatch = jest.fn(store.dispatch);
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.unmount();
expect(store.dispatch).toHaveBeenCalledWith(loginRemovePasswordResetBanner());
});
});

View File

@@ -25,7 +25,6 @@ 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';
@@ -66,16 +65,11 @@ const Logistration = (props) => {
setInstitutionLogin(!institutionLogin);
};
const handleOnSelect = (tabKey, currentTab) => {
if (tabKey === currentTab) {
return;
}
const handleOnSelect = (tabKey) => {
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);
};
@@ -124,7 +118,7 @@ const Logistration = (props) => {
</Tabs>
)
: (!isValidTpaHint() && !hideRegistrationLink && (
<Tabs defaultActiveKey={selectedPage} id="controlled-tab" onSelect={(tabKey) => handleOnSelect(tabKey, selectedPage)}>
<Tabs defaultActiveKey={selectedPage} id="controlled-tab" onSelect={handleOnSelect}>
<Tab title={formatMessage(messages['logistration.register'])} eventKey={REGISTER_PAGE} />
<Tab title={formatMessage(messages['logistration.sign.in'])} eventKey={LOGIN_PAGE} />
</Tabs>
@@ -156,7 +150,6 @@ const Logistration = (props) => {
Logistration.propTypes = {
selectedPage: PropTypes.string,
backupLoginForm: PropTypes.func.isRequired,
backupRegistrationForm: PropTypes.func.isRequired,
clearThirdPartyAuthContextErrorMessage: PropTypes.func.isRequired,
tpaProviders: PropTypes.shape({
@@ -183,7 +176,6 @@ 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 { fireEvent, render, screen } from '@testing-library/react';
import { mount } from 'enzyme';
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,29 +94,21 @@ 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 { container } = render(reduxWrapper(<IntlLogistration />));
const logistration = mount(reduxWrapper(<IntlLogistration />));
expect(container.querySelector('RegistrationPage')).toBeDefined();
expect(logistration.find('#main-content').find('RegistrationPage').exists()).toBeTruthy();
});
it('should render login page', () => {
const props = { selectedPage: LOGIN_PAGE };
const { container } = render(reduxWrapper(<IntlLogistration {...props} />));
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
expect(container.querySelector('LoginPage')).toBeDefined();
expect(logistration.find('#main-content').find('LoginPage').exists()).toBeTruthy();
});
it('should render login/register headings when show registration links is disabled', () => {
@@ -125,18 +117,18 @@ describe('Logistration', () => {
});
let props = { selectedPage: LOGIN_PAGE };
const { rerender } = render(reduxWrapper(<IntlLogistration {...props} />));
let logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
// verifying sign in heading
expect(screen.getByRole('heading', { level: 3 }).textContent).toEqual('Sign in');
expect(logistration.find('#main-content').find('h3').text()).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 };
rerender(reduxWrapper(<IntlLogistration {...props} />));
logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
// verifying register heading
expect(screen.getByRole('heading', { level: 3 }).textContent).toEqual('Register');
expect(logistration.find('#main-content').find('h3').text()).toEqual('Register');
});
it('should render only login page when public account creation is disabled', () => {
@@ -160,14 +152,14 @@ describe('Logistration', () => {
});
const props = { selectedPage: LOGIN_PAGE };
const { container } = render(reduxWrapper(<IntlLogistration {...props} />));
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
// verifying sign in heading for institution login false
expect(screen.getByRole('heading', { level: 3 }).textContent).toEqual('Sign in');
expect(logistration.find('#main-content').find('h3').text()).toEqual('Sign in');
// verifying tabs heading for institution login true
fireEvent.click(screen.getByRole('link'));
expect(container.querySelector('#controlled-tab')).toBeDefined();
logistration.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
expect(logistration.find('#controlled-tab').exists()).toBeTruthy();
});
it('should display institution login option when secondary providers are present', () => {
@@ -190,12 +182,12 @@ describe('Logistration', () => {
});
const props = { selectedPage: LOGIN_PAGE };
render(reduxWrapper(<IntlLogistration {...props} />));
expect(screen.getByText('Institution/campus credentials')).toBeDefined();
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
expect(logistration.text().includes('Institution/campus credentials')).toBe(true);
// on clicking "Institution/campus credentials" button, it should display institution login page
fireEvent.click(screen.getByText('Institution/campus credentials'));
expect(screen.getByText('Test University')).toBeDefined();
logistration.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
expect(logistration.text().includes('Test University')).toBe(true);
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
@@ -221,8 +213,8 @@ describe('Logistration', () => {
});
const props = { selectedPage: LOGIN_PAGE };
render(reduxWrapper(<IntlLogistration {...props} />));
fireEvent.click(screen.getByText('Institution/campus credentials'));
const logistration = mount(reduxWrapper(<IntlLogistration {...props} />));
logistration.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.institution_login_form.toggled', { category: 'user-engagement' });
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login');
@@ -253,9 +245,9 @@ describe('Logistration', () => {
delete window.location;
window.location = { hostname: getConfig().SITE_NAME, href: getConfig().BASE_URL };
render(reduxWrapper(<IntlLogistration />));
fireEvent.click(screen.getByText('Institution/campus credentials'));
expect(screen.getByText('Test University')).toBeDefined();
const root = mount(reduxWrapper(<IntlLogistration />));
root.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
expect(root.text().includes('Test University')).toBe(true);
mergeConfig({
DISABLE_ENTERPRISE_LOGIN: '',
@@ -264,23 +256,15 @@ describe('Logistration', () => {
it('should fire action to backup registration form on tab click', () => {
store.dispatch = jest.fn(store.dispatch);
const { container } = render(reduxWrapper(<IntlLogistration />));
fireEvent.click(container.querySelector('a[data-rb-event-key="/login"]'));
const logistration = mount(reduxWrapper(<IntlLogistration />));
logistration.find('a[data-rb-event-key="/login"]').simulate('click');
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 { container } = render(reduxWrapper(<IntlLogistration />));
fireEvent.click(container.querySelector('a[data-rb-event-key="/login"]'));
const logistration = mount(reduxWrapper(<IntlLogistration />));
logistration.find('a[data-rb-event-key="/login"]').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(clearThirdPartyAuthContextErrorMessage());
});
});

View File

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

View File

@@ -173,7 +173,6 @@ describe('ProgressiveProfilingTests', () => {
isGenderSelected: false,
isYearOfBirthSelected: false,
isLevelOfEducationSelected: false,
isWorkExperienceSelected: false,
host: '',
};
delete window.location;
@@ -347,7 +346,6 @@ 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 d-inline"
className="card-box"
showLaunchIcon={false}
onClick={handleOnClick}
>

View File

@@ -31,13 +31,6 @@ 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(() => {
@@ -56,11 +49,6 @@ 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
@@ -72,12 +60,18 @@ const CountryField = (props) => {
const { value } = event.target;
const { error } = validateCountryField(
const { countryCode, displayValue, 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'));
@@ -85,19 +79,11 @@ const CountryField = (props) => {
};
const handleOnChange = (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 } });
}
onChangeHandler({ target: { name: 'country' } }, { countryCode: '', displayValue: value });
};
const getCountryList = () => countryList.map((country) => (
<FormAutosuggestOption key={country[COUNTRY_CODE_KEY]} id={country[COUNTRY_CODE_KEY]}>
<FormAutosuggestOption key={country[COUNTRY_CODE_KEY]}>
{country[COUNTRY_DISPLAY_KEY]}
</FormAutosuggestOption>
));
@@ -108,8 +94,9 @@ const CountryField = (props) => {
floatingLabel={formatMessage(messages['registration.country.label'])}
aria-label="form autosuggest"
name="country"
value={countryFieldValue || {}}
value={selectedCountry.displayValue || ''}
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 { fireEvent, render } from '@testing-library/react';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -82,13 +82,8 @@ describe('CountryField', () => {
};
it('should run country field validation when onBlur is fired', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const countryInput = container.querySelector('input[name="country"]');
fireEvent.blur(countryInput, {
target: { value: '', name: 'country' },
});
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('blur', { target: { value: '', name: 'country' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'country',
@@ -97,13 +92,8 @@ describe('CountryField', () => {
});
it('should run country field validation when country name is invalid', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const countryInput = container.querySelector('input[name="country"]');
fireEvent.blur(countryInput, {
target: { value: 'Pak', name: 'country' },
});
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('blur', { target: { value: 'Pak', name: 'country' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'country',
@@ -112,36 +102,34 @@ describe('CountryField', () => {
});
it('should not run country field validation when onBlur is fired by drop-down arrow icon click', () => {
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, {
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('blur', {
target: { value: '', name: 'country' },
relatedTarget: dropdownArrowIcon,
relatedTarget: { type: 'button', className: 'btn-icon pgn__form-autosuggest__icon-button' },
});
expect(props.handleErrorChange).toHaveBeenCalledTimes(0);
});
it('should update errors for frontend validations', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const countryInput = container.querySelector('input[name="country"]');
fireEvent.blur(countryInput, { target: { value: '', name: 'country' } });
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('blur', { target: { value: '', name: 'country' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith('country', emptyFieldValidation.country);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'country',
emptyFieldValidation.country,
);
});
it('should clear error on focus', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const countryInput = container.querySelector('input[name="country"]');
fireEvent.focus(countryInput);
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate('focus', { target: { value: '', name: 'country' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith('country', '');
expect(props.handleErrorChange).toHaveBeenCalledWith(
'country',
'',
);
});
it('should update state from country code present in redux store', () => {
@@ -153,9 +141,7 @@ describe('CountryField', () => {
},
});
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
container.querySelector('input[name="country"]');
mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
expect(props.onChangeHandler).toHaveBeenCalledTimes(1);
expect(props.onChangeHandler).toHaveBeenCalledWith(
{ target: { name: 'country' } },
@@ -164,15 +150,12 @@ describe('CountryField', () => {
});
it('should set option on dropdown menu item click', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const dropdownButton = container.querySelector('.pgn__form-autosuggest__icon-button');
fireEvent.click(dropdownButton);
countryField.find('.pgn__form-autosuggest__icon-button').first().simulate('click');
countryField.find('.dropdown-item').first().simulate('click');
const dropdownItem = container.querySelector('.dropdown-item');
fireEvent.click(dropdownItem);
expect(props.onChangeHandler).toHaveBeenCalledTimes(2);
expect(props.onChangeHandler).toHaveBeenCalledTimes(1);
expect(props.onChangeHandler).toHaveBeenCalledWith(
{ target: { name: 'country' } },
{ countryCode: 'PK', displayValue: 'Pakistan' },
@@ -180,14 +163,13 @@ describe('CountryField', () => {
});
it('should set value on change', () => {
const { container } = render(
routerWrapper(reduxWrapper(<IntlCountryField {...props} />)),
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
countryField.find('input[name="country"]').simulate(
'change', { target: { value: 'pak', name: 'country' } },
);
const countryInput = container.querySelector('input[name="country"]');
fireEvent.change(countryInput, { target: { value: 'pak', name: 'country' } });
expect(props.onChangeHandler).toHaveBeenCalledTimes(2);
expect(props.onChangeHandler).toHaveBeenCalledTimes(1);
expect(props.onChangeHandler).toHaveBeenCalledWith(
{ target: { name: 'country' } },
{ countryCode: '', displayValue: 'pak' },
@@ -200,11 +182,9 @@ describe('CountryField', () => {
errorMessage: 'country error message',
};
const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const countryField = mount(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
const feedbackElement = container.querySelector('div[feedback-for="country"]');
expect(feedbackElement).toBeTruthy();
expect(feedbackElement.textContent).toEqual('country error message');
expect(countryField.find('div[feedback-for="country"]').text()).toEqual('country error message');
});
});
});

View File

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

View File

@@ -91,7 +91,7 @@ export const validateEmailAddress = (value, username, domainName) => {
const validateEmail = (value, confirmEmailValue, formatMessage) => {
let fieldError = '';
let confirmEmailError = '';
let emailSuggestion = { suggestion: '', type: '' };
let emailSuggestion = {};
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 { render } from '@testing-library/react';
import { mount } from 'enzyme';
import { HonorCode } from '../index';
@@ -13,7 +13,6 @@ 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) => {
@@ -26,7 +25,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 { container } = render(
const honorCode = mount(
<IntlProvider locale="en">
<IntlHonorCode
errorMessage={errorMessage}
@@ -34,27 +33,24 @@ describe('HonorCodeTest', () => {
/>
</IntlProvider>,
);
const errorElement = container.querySelector('.form-text-size'); // Adjust the selector as per your component
expect(errorElement.textContent).toEqual(errorMessage);
expect(honorCode.find('.form-text-size').last().text()).toEqual(errorMessage);
});
it('should render Honor code field', () => {
const expectedMsg = 'I agree to the Your Platform Name Here\u00a0Honor Codein a new tab';
const { container } = render(
const honorCode = mount(
<IntlProvider locale="en">
<IntlHonorCode onChangeHandler={changeHandler} />
</IntlProvider>,
);
const honorCodeField = container.querySelector('#honor-code');
honorCodeField.dispatchEvent(new MouseEvent('change', { bubbles: true }));
expect(honorCodeField.querySelector('label').textContent).toEqual(expectedMsg);
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);
});
it('should render Terms of Service and Honor code field', () => {
const { container } = render(
const HonorCodeProps = mount(
<IntlProvider locale="en">
<IntlHonorCode fieldType="tos_and_honor_code" onChangeHandler={changeHandler} />
</IntlProvider>,
@@ -62,7 +58,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 honorCodeField = container.querySelector('#honor-code');
expect(honorCodeField.textContent).toEqual(expectedMsg);
const field = HonorCodeProps.find('#honor-code');
expect(field.text()).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 { fireEvent, render } from '@testing-library/react';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -69,11 +69,9 @@ describe('NameField', () => {
const fieldValidation = { name: 'Enter your full name' };
it('should run name field validation when onBlur is fired', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const nameInput = container.querySelector('input#name');
fireEvent.blur(nameInput, { target: { value: '', name: 'name' } });
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
nameField.find('input#name').simulate('blur', { target: { value: '', name: 'name' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'name',
@@ -82,11 +80,11 @@ describe('NameField', () => {
});
it('should update errors for frontend validations', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const nameInput = container.querySelector('input#name');
fireEvent.blur(nameInput, { target: { value: 'https://invalid-name.com', name: 'name' } });
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
nameField.find('input#name').simulate(
'blur', { target: { value: 'https://invalid-name.com', name: 'name' } },
);
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'name',
@@ -95,11 +93,9 @@ describe('NameField', () => {
});
it('should clear error on focus', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const nameInput = container.querySelector('input#name');
fireEvent.focus(nameInput, { target: { value: '', name: 'name' } });
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
nameField.find('input#name').simulate('focus', { target: { value: '', name: 'name' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'name',
@@ -113,12 +109,10 @@ describe('NameField', () => {
...props,
shouldFetchUsernameSuggestions: true,
};
const { container } = render(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const nameInput = container.querySelector('input#name');
// Enter a valid name so that frontend validations are passed
fireEvent.blur(nameInput, { target: { value: 'test', name: 'name' } });
nameField.find('input#name').simulate('blur', { target: { value: 'test', name: 'name' } });
expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations({ name: 'test' }));
});
@@ -135,12 +129,8 @@ describe('NameField', () => {
});
store.dispatch = jest.fn(store.dispatch);
const { container } = render(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
const nameInput = container.querySelector('input#name');
fireEvent.focus(nameInput, { target: { value: 'test', name: 'name' } });
const nameField = mount(routerWrapper(reduxWrapper(<IntlNameField {...props} />)));
nameField.find('input#name').simulate('focus', { target: { value: 'test', name: 'name' } });
expect(store.dispatch).toHaveBeenCalledWith(clearRegistrationBackendError('name'));
});
});

View File

@@ -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 { fireEvent, render } from '@testing-library/react';
import { mount } from 'enzyme';
import { TermsOfService } from '../index';
@@ -21,38 +21,33 @@ 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 { container } = render(
const termsOfService = mount(
<IntlProvider locale="en">
<IntlTermsOfService errorMessage={errorMessage} onChangeHandler={changeHandler} />
</IntlProvider>,
);
const errorElement = container.querySelector('.form-text-size');
expect(errorElement.textContent).toEqual(errorMessage);
expect(termsOfService.find('.form-text-size').last().text()).toEqual(errorMessage);
});
it('should render Terms of Service field', () => {
const { container } = render(
const termsOfService = mount(
<IntlProvider locale="en">
<IntlTermsOfService onChangeHandler={changeHandler} />
</IntlProvider>,
);
const expectedMsg = 'I agree to the Your Platform Name Here\u00a0Terms of Servicein a new tab';
const termsOfServiceLabel = container.querySelector('#terms-of-service label');
expect(termsOfServiceLabel.textContent).toEqual(expectedMsg);
expect(termsOfService.find('#terms-of-service').find('label').text()).toEqual(expectedMsg);
expect(value).toEqual(false);
});
it('should change value when Terms of Service field is checked', () => {
const { container } = render(
const termsOfService = mount(
<IntlProvider locale="en">
<IntlTermsOfService onChangeHandler={changeHandler} />
</IntlProvider>,
);
const field = container.querySelector('input#tos');
fireEvent.click(field);
const field = termsOfService.find('input#tos');
field.simulate('change', { target: { checked: true, type: 'checkbox' } });
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 { fireEvent, render } from '@testing-library/react';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -73,11 +73,9 @@ describe('UsernameField', () => {
};
it('should run username field validation when onBlur is fired', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.blur(usernameField, { target: { value: '', name: 'username' } });
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('blur', { target: { value: '', name: 'username' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'username',
@@ -86,11 +84,9 @@ describe('UsernameField', () => {
});
it('should update errors for frontend validations', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.blur(usernameField, { target: { value: 'user#', name: 'username' } });
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('blur', { target: { value: 'user#', name: 'username' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'username',
@@ -99,11 +95,9 @@ describe('UsernameField', () => {
});
it('should clear error on focus', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.focus(usernameField, { target: { value: '', name: 'username' } });
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('focus', { target: { value: '', name: 'username' } });
expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
expect(props.handleErrorChange).toHaveBeenCalledWith(
'username',
@@ -112,11 +106,9 @@ describe('UsernameField', () => {
});
it('should remove space from field on focus if space exists', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.focus(usernameField, { target: { value: ' ', name: 'username' } });
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('focus', { target: { value: ' ', name: 'username' } });
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'username', value: '' } },
@@ -125,19 +117,18 @@ describe('UsernameField', () => {
it('should call backend validation api on blur event, if frontend validations have passed', () => {
store.dispatch = jest.fn(store.dispatch);
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
// Enter a valid username so that frontend validations are passed
fireEvent.blur(usernameField, { target: { value: 'test', name: 'username' } });
usernameField.find('input#username').simulate('blur', { target: { value: 'test', name: 'username' } });
expect(store.dispatch).toHaveBeenCalledWith(fetchRealtimeValidations({ username: 'test' }));
});
it('should remove space from the start of username on change', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.change(usernameField, { target: { value: ' test-user', name: 'username' } });
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate(
'change', { target: { value: ' test-user', name: 'username' } },
);
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
@@ -146,10 +137,10 @@ describe('UsernameField', () => {
});
it('should not set username if it is more than 30 character long', () => {
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' } });
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate(
'change', { target: { value: 'why_this_is_not_valid_username_', name: 'username' } },
);
expect(props.handleChange).toHaveBeenCalledTimes(0);
});
@@ -157,10 +148,8 @@ describe('UsernameField', () => {
it('should clear username suggestions when username field is focused in', () => {
store.dispatch = jest.fn(store.dispatch);
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.focus(usernameField);
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('focus');
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
});
@@ -179,9 +168,8 @@ describe('UsernameField', () => {
errorMessage: 'It looks like this username is already taken',
};
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameSuggestions = container.querySelectorAll('button.username-suggestions--chip');
expect(usernameSuggestions.length).toEqual(3);
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(usernameField.find('button.username-suggestions--chip').length).toEqual(3);
});
it('should show username suggestions when they are populated in redux', () => {
@@ -198,9 +186,8 @@ describe('UsernameField', () => {
value: ' ',
};
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameSuggestions = container.querySelectorAll('button.username-suggestions--chip');
expect(usernameSuggestions.length).toEqual(3);
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(usernameField.find('button.username-suggestions--chip').length).toEqual(3);
});
it('should show username suggestions even if there is an error in field', () => {
@@ -218,9 +205,8 @@ describe('UsernameField', () => {
errorMessage: 'username error',
};
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameSuggestions = container.querySelectorAll('button.username-suggestions--chip');
expect(usernameSuggestions.length).toEqual(3);
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(usernameField.find('button.username-suggestions--chip').length).toEqual(3);
});
it('should put space in username field if suggestions are populated in redux', () => {
@@ -232,7 +218,7 @@ describe('UsernameField', () => {
},
});
render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'username', value: ' ' } },
@@ -253,9 +239,8 @@ describe('UsernameField', () => {
value: ' ',
};
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameSuggestion = container.querySelector('.username-suggestions--chip');
fireEvent.click(usernameSuggestion);
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('.username-suggestions--chip').first().simulate('click');
expect(props.handleChange).toHaveBeenCalledTimes(1);
expect(props.handleChange).toHaveBeenCalledWith(
{ target: { name: 'username', value: 'test_1' } },
@@ -277,9 +262,8 @@ describe('UsernameField', () => {
value: ' ',
};
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
let closeButton = container.querySelector('button.username-suggestions__close__button');
fireEvent.click(closeButton);
let usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('button.username-suggestions__close__button').at(0).simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
props = {
@@ -287,9 +271,8 @@ describe('UsernameField', () => {
errorMessage: 'username error',
};
render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
closeButton = container.querySelector('button.username-suggestions__close__button');
fireEvent.click(closeButton);
usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('button.username-suggestions__close__button').at(0).simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(clearUsernameSuggestions());
});
@@ -308,12 +291,8 @@ describe('UsernameField', () => {
});
store.dispatch = jest.fn(store.dispatch);
const { container } = render(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
const usernameField = container.querySelector('input#username');
fireEvent.focus(usernameField, { target: { value: 'test', name: 'username' } });
const usernameField = mount(routerWrapper(reduxWrapper(<IntlUsernameField {...props} />)));
usernameField.find('input#username').simulate('focus', { target: { value: 'test', name: 'username' } });
expect(store.dispatch).toHaveBeenCalledWith(clearRegistrationBackendError('username'));
});
});

View File

@@ -14,31 +14,25 @@ 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';
@@ -59,7 +53,6 @@ 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,
@@ -192,8 +185,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 }));
};
@@ -225,12 +218,9 @@ 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, emailSuggestion } = isFormValid(
const { isValid, fieldErrors } = isFormValid(
payload,
registrationEmbedded ? temporaryErrors : errors,
configurableFormFields,
@@ -238,7 +228,6 @@ const RegistrationPage = (props) => {
formatMessage,
);
setErrors({ ...fieldErrors });
dispatch(setEmailSuggestionInStore(emailSuggestion));
// returning if not valid
if (!isValid) {
@@ -337,18 +326,16 @@ const RegistrationPage = (props) => {
helpText={[formatMessage(messages['help.text.email'])]}
floatingLabel={formatMessage(messages['registration.email.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'])}
/>
)}
<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,8 +6,9 @@ import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'
import {
configure, getLocale, injectIntl, IntlProvider,
} from '@edx/frontend-platform/i18n';
import { fireEvent, render } from '@testing-library/react';
import { mount } from 'enzyme';
import { mockNavigate, BrowserRouter as Router } from 'react-router-dom';
import renderer from 'react-test-renderer';
import configureStore from 'redux-mock-store';
import {
@@ -100,7 +101,6 @@ describe('RegistrationPage', () => {
registrationError: {},
registrationFormData,
usernameSuggestions: [],
},
commonComponents: {
thirdPartyAuthApiStatus: null,
@@ -134,23 +134,16 @@ describe('RegistrationPage', () => {
jest.clearAllMocks();
});
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' } });
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' } });
fireEvent.change(getByLabelText('Country/Region'), { target: { value: payload.country, name: 'country' } });
fireEvent.blur(getByLabelText('Country/Region'), { target: { value: payload.country, name: 'country' } });
registrationPage.find('input[name="country"]').simulate('change', { target: { value: payload.country, name: 'country' } });
registrationPage.find('input[name="country"]').simulate('blur', { target: { value: payload.country, name: 'country' } });
if (!isThirdPartyAuth) {
fireEvent.change(getByLabelText('Password'), { target: { value: payload.password, name: 'password' } });
registrationPage.find('input#password').simulate('change', { target: { value: payload.password, name: 'password' } });
}
};
@@ -188,11 +181,9 @@ describe('RegistrationPage', () => {
};
store.dispatch = jest.fn(store.dispatch);
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(getByLabelText, payload);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(registrationPage, payload);
registrationPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
});
@@ -220,62 +211,13 @@ describe('RegistrationPage', () => {
},
});
store.dispatch = jest.fn(store.dispatch);
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(getByLabelText, formPayload, true);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
populateRequiredFields(registrationPage, formPayload, true);
registrationPage.find('button.btn-brand').simulate('click');
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',
@@ -295,10 +237,9 @@ describe('RegistrationPage', () => {
};
store.dispatch = jest.fn(store.dispatch);
const { getByLabelText, container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(getByLabelText, payload);
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(registrationPage, payload);
registrationPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
mergeConfig({
@@ -306,71 +247,28 @@ 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 { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const button = container.querySelector('button.btn-brand');
fireEvent.click(button);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).not.toHaveBeenCalledWith(registerNewUser({}));
});
// ******** test registration form validations ********
it('should show error messages for required fields on empty form submission', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
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);
});
expect(registrationPage.find('div[feedback-for="name"]').text()).toEqual(emptyFieldValidation.name);
expect(registrationPage.find('div[feedback-for="username"]').text()).toEqual(emptyFieldValidation.username);
expect(registrationPage.find('div[feedback-for="email"]').text()).toEqual(emptyFieldValidation.email);
expect(registrationPage.find('div[feedback-for="password"]').text()).toContain(emptyFieldValidation.password);
expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(emptyFieldValidation.country);
const alertBanner = 'We couldn\'t create your account.Please check your responses and try again.';
const validationErrors = container.querySelector('#validation-errors');
expect(validationErrors.textContent).toContain(alertBanner);
expect(registrationPage.find('#validation-errors').first().text()).toEqual(alertBanner);
});
it('should set errors with validations returned by registration api', () => {
@@ -386,28 +284,23 @@ describe('RegistrationPage', () => {
},
},
});
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);
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);
});
it('should clear error on focus', () => {
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
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);
const passwordField = container.querySelector('input#password');
fireEvent.focus(passwordField);
const isFeedbackPresent = container.contains(passwordFeedback);
expect(isFeedbackPresent).toBeFalsy();
registrationPage.find('input#password').simulate('focus');
expect(registrationPage.find('div[feedback-for="password"]').exists()).toBeFalsy();
});
it('should clear registration backend error on change', () => {
@@ -423,21 +316,19 @@ describe('RegistrationPage', () => {
});
store.dispatch = jest.fn(store.dispatch);
const { container } = render(routerWrapper(reduxWrapper(
const registrationPage = mount(routerWrapper(reduxWrapper(
<IntlRegistrationPage {...props} />,
)));
))).find('RegistrationPage');
const emailInput = container.querySelector('input#email');
fireEvent.change(emailInput, { target: { value: 'test1@gmail.com', name: 'email' } });
registrationPage.find('input#email').simulate('change', { 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 { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const button = container.querySelector('button[type="submit"] span');
expect(button.textContent).toEqual('Create an account for free');
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('button[type="submit"] span').first().text()).toEqual('Create an account for free');
});
it('should match pending button state', () => {
@@ -449,10 +340,10 @@ describe('RegistrationPage', () => {
},
});
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const button = registrationPage.find('button[type="submit"] span').first();
const button = container.querySelector('button[type="submit"] span.sr-only');
expect(button.textContent).toEqual('pending');
expect(button.find('.sr-only').text()).toEqual('pending');
});
it('should display opt-in/opt-out checkbox', () => {
@@ -460,9 +351,8 @@ describe('RegistrationPage', () => {
MARKETING_EMAILS_OPT_IN: 'true',
});
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const checkboxDivs = container.querySelectorAll('div.form-field--checkbox');
expect(checkboxDivs.length).toEqual(1);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('div.form-field--checkbox').length).toEqual(1);
mergeConfig({
MARKETING_EMAILS_OPT_IN: '',
@@ -473,12 +363,8 @@ describe('RegistrationPage', () => {
const buttonLabel = 'Register';
delete window.location;
window.location = { href: getConfig().BASE_URL, search: `?cta=${buttonLabel}` };
const { container } = render(reduxWrapper(<IntlRegistrationPage {...props} />));
const button = container.querySelector('button[type="submit"] span');
const buttonText = button.textContent;
expect(buttonText).toEqual(buttonLabel);
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
expect(registrationPage.find('button[type="submit"] span').first().text()).toEqual(buttonLabel);
});
it('should check user retention cookie', () => {
@@ -492,7 +378,7 @@ describe('RegistrationPage', () => {
},
});
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(document.cookie).toMatch(`${getConfig().USER_RETENTION_COOKIE_NAME}=true`);
});
@@ -510,7 +396,7 @@ describe('RegistrationPage', () => {
});
delete window.location;
window.location = { href: getConfig().BASE_URL };
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(window.location.href).toBe(dashboardURL);
});
@@ -537,7 +423,7 @@ describe('RegistrationPage', () => {
});
delete window.location;
window.location = { href: getConfig().BASE_URL };
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(window.location.href).toBe(dashboardUrl);
});
@@ -566,11 +452,12 @@ describe('RegistrationPage', () => {
},
});
render(reduxWrapper(
const progressiveProfilingPage = mount(reduxWrapper(
<Router>
<IntlRegistrationPage {...props} />
</Router>,
));
progressiveProfilingPage.update();
expect(mockNavigate).toHaveBeenCalledWith(AUTHN_PROGRESSIVE_PROFILING);
});
@@ -586,12 +473,12 @@ describe('RegistrationPage', () => {
});
store.dispatch = jest.fn(store.dispatch);
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(store.dispatch).toHaveBeenCalledWith(backupRegistrationFormBegin({ ...registrationFormData }));
});
it('should send page event when register page is rendered', () => {
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register');
});
@@ -609,7 +496,7 @@ describe('RegistrationPage', () => {
delete window.location;
window.location = { href: getConfig().BASE_URL };
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
renderer.create(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.account.registered.client', {});
});
@@ -633,17 +520,10 @@ describe('RegistrationPage', () => {
},
});
store.dispatch = jest.fn(store.dispatch);
const { container } = render(reduxWrapper(
<Router>
<IntlRegistrationPage {...props} />
</Router>,
));
const emailInput = container.querySelector('input#email');
const usernameInput = container.querySelector('input#username');
expect(emailInput.value).toEqual('test@example.com');
expect(usernameInput.value).toEqual('test');
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />))).find('RegistrationPage');
expect(registrationPage.find('input#email').props().value).toEqual('test@example.com');
expect(registrationPage.find('input#username').props().value).toEqual('test');
expect(store.dispatch).toHaveBeenCalledWith(setUserPipelineDataLoaded(true));
});
@@ -658,9 +538,8 @@ describe('RegistrationPage', () => {
},
});
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const validationErrors = container.querySelector('div#validation-errors');
expect(validationErrors.textContent).toContain(
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.',
);
});
@@ -685,19 +564,15 @@ describe('RegistrationPage', () => {
},
});
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const registrationPage = mount(routerWrapper(reduxWrapper(
<IntlRegistrationPage {...props} />,
))).find('RegistrationPage');
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');
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');
});
// ********* Embedded experience tests *********/
@@ -731,22 +606,23 @@ describe('RegistrationPage', () => {
},
},
});
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const progressiveProfilingPage = mount(reduxWrapper(
<IntlRegistrationPage {...props} />,
));
progressiveProfilingPage.update();
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 { container } = render(reduxWrapper(<IntlRegistrationPage {...props} />));
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
const usernameInput = container.querySelector('input#username');
fireEvent.blur(usernameInput, { target: { value: '', name: 'username' } });
expect(container.querySelector('div[feedback-for="username"]')).toBeFalsy();
registrationPage.find('input#username').simulate('blur', { target: { value: '', name: 'username' } });
expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy();
const countryInput = container.querySelector('input[name="country"]');
fireEvent.blur(countryInput, { target: { value: '', name: 'country' } });
expect(container.querySelector('div[feedback-for="country"]')).toBeFalsy();
registrationPage.find('input[name="country"]').simulate('blur', { target: { value: '', name: 'country' } });
expect(registrationPage.find('div[feedback-for="country"]').exists()).toBeFalsy();
});
it('should set errors in temporary state when validations are returned by registration api', () => {
@@ -765,15 +641,12 @@ describe('RegistrationPage', () => {
},
},
});
const { container } = render(routerWrapper(reduxWrapper(
const registrationPage = mount(routerWrapper(reduxWrapper(
<IntlRegistrationPage {...props} />),
));
)).find('RegistrationPage');
const usernameFeedback = container.querySelector('div[feedback-for="username"]');
const emailFeedback = container.querySelector('div[feedback-for="email"]');
expect(usernameFeedback).toBeNull();
expect(emailFeedback).toBeNull();
expect(registrationPage.find('div[feedback-for="username"]').exists()).toBeFalsy();
expect(registrationPage.find('div[feedback-for="email"]').exists()).toBeFalsy();
});
it('should clear error on focus for embedded experience also', () => {
@@ -783,18 +656,13 @@ describe('RegistrationPage', () => {
search: '?host=http://localhost/host-website',
};
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const submitButton = container.querySelector('button.btn-brand');
fireEvent.click(submitButton);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
const passwordFeedback = container.querySelector('div[feedback-for="password"]');
expect(passwordFeedback.textContent).toContain(emptyFieldValidation.password);
expect(registrationPage.find('div[feedback-for="password"]').text()).toContain(emptyFieldValidation.password);
const passwordField = container.querySelector('input#password');
fireEvent.focus(passwordField);
const updatedPasswordFeedback = container.querySelector('div[feedback-for="password"]');
expect(updatedPasswordFeedback).toBeNull();
registrationPage.find('input#password').simulate('focus');
expect(registrationPage.find('div[feedback-for="password"]').exists()).toBeFalsy();
});
it('should show spinner instead of form while registering if autoSubmitRegForm is true', () => {
@@ -824,12 +692,9 @@ describe('RegistrationPage', () => {
});
store.dispatch = jest.fn(store.dispatch);
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();
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('#tpa-spinner').exists()).toBeTruthy();
expect(registrationPage.find('#registration-form').exists()).toBeFalsy();
});
it('should auto register if autoSubmitRegForm is true and pipeline details are loaded', () => {
@@ -875,7 +740,7 @@ describe('RegistrationPage', () => {
});
store.dispatch = jest.fn(store.dispatch);
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({
name: 'John Doe',
username: 'john_doe',

View File

@@ -46,6 +46,12 @@ 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 { fireEvent, render } from '@testing-library/react';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { registerNewUser } from '../../data/actions';
import { FIELDS } from '../../data/constants';
import RegistrationPage from '../../RegistrationPage';
import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm';
import ConfigurableRegistrationForm from './ConfigurableRegistrationForm';
import { registerNewUser } from '../data/actions';
import { FIELDS } from '../data/constants';
import RegistrationPage from '../RegistrationPage';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendPageEvent: jest.fn(),
@@ -127,16 +127,16 @@ describe('ConfigurableRegistrationForm', () => {
jest.clearAllMocks();
});
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' } });
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' } });
fireEvent.change(getByLabelText('Country/Region'), { target: { value: payload.country, name: 'country' } });
fireEvent.blur(getByLabelText('Country/Region'), { target: { value: payload.country, name: 'country' } });
registrationPage.find('input[name="country"]').simulate('change', { target: { value: payload.country, name: 'country' } });
registrationPage.find('input[name="country"]').simulate('blur', { target: { value: payload.country, name: 'country' } });
if (!isThirdPartyAuth) {
fireEvent.change(getByLabelText('Password'), { target: { value: payload.password, name: 'password' } });
registrationPage.find('input#password').simulate('change', { target: { value: payload.password, name: 'password' } });
}
};
@@ -157,12 +157,12 @@ describe('ConfigurableRegistrationForm', () => {
},
};
render(routerWrapper(reduxWrapper(
const configurableRegistrationForm = mount(routerWrapper(reduxWrapper(
<IntlConfigurableRegistrationForm {...props} />,
)));
expect(document.querySelector('#profession')).toBeTruthy();
expect(document.querySelector('#tos')).toBeTruthy();
expect(configurableRegistrationForm.find('#profession').exists()).toBeTruthy();
expect(configurableRegistrationForm.find('#tos').exists()).toBeTruthy();
});
it('should check TOS and honor code fields if they exist when auto submitting register form', () => {
@@ -187,7 +187,7 @@ describe('ConfigurableRegistrationForm', () => {
autoSubmitRegistrationForm: true,
};
render(routerWrapper(reduxWrapper(
mount(routerWrapper(reduxWrapper(
<IntlConfigurableRegistrationForm {...props} />,
)));
@@ -215,9 +215,9 @@ describe('ConfigurableRegistrationForm', () => {
},
},
});
render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(document.querySelector('#profession')).toBeTruthy();
expect(document.querySelector('#tos')).toBeTruthy();
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
expect(registrationPage.find('#profession').exists()).toBeTruthy();
expect(registrationPage.find('#tos').exists()).toBeTruthy();
});
it('should submit form with fields returned by backend in payload', () => {
@@ -249,17 +249,11 @@ describe('ConfigurableRegistrationForm', () => {
};
store.dispatch = jest.fn(store.dispatch);
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);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(registrationPage, payload);
registrationPage.find('input#profession').simulate('change', { target: { value: 'Engineer', name: 'profession' } });
registrationPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
});
@@ -284,18 +278,12 @@ describe('ConfigurableRegistrationForm', () => {
},
});
const { container } = render(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
const submitButton = container.querySelector('button.btn-brand');
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('button.btn-brand').simulate('click');
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);
expect(registrationPage.find('#profession-error').last().text()).toEqual(professionError);
expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(countryError);
expect(registrationPage.find('#confirm_email-error').last().text()).toEqual(confirmEmailError);
});
it('should show country field validation when country name is invalid', () => {
@@ -310,17 +298,11 @@ describe('ConfigurableRegistrationForm', () => {
},
},
});
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' } });
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('input[name="country"]').simulate('blur', { target: { value: 'Pak', name: 'country' } });
const submitButton = container.querySelector('button.btn-brand');
fireEvent.click(submitButton);
const countryErrorElement = container.querySelector('div[feedback-for="country"]');
expect(countryErrorElement.textContent).toEqual(invalidCountryError);
registrationPage.find('button.btn-brand').simulate('click');
expect(registrationPage.find('div[feedback-for="country"]').text()).toEqual(invalidCountryError);
});
it('should show error if email and confirm email fields do not match', () => {
@@ -335,60 +317,10 @@ describe('ConfigurableRegistrationForm', () => {
},
},
});
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.",
);
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.');
});
it('should run validations for configurable focused field on form submission', () => {
@@ -405,19 +337,11 @@ describe('ConfigurableRegistrationForm', () => {
},
});
const { getByLabelText, container } = render(
routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)),
);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
registrationPage.find('input#profession').simulate('focus', { target: { value: '', name: 'profession' } });
registrationPage.find('button.btn-brand').simulate('click');
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);
expect(registrationPage.find('#profession-error').last().text()).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 { render, screen } from '@testing-library/react';
import { mount } from 'enzyme';
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';
import RegistrationFailureMessage from '../RegistrationFailure';
} from '../data/constants';
import RegistrationPage from '../RegistrationPage';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendPageEvent: jest.fn(),
@@ -137,13 +137,9 @@ describe('RegistrationFailure', () => {
failureCount: 0,
};
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);
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage);
});
it('should match registration api rate limit error message', () => {
@@ -153,13 +149,9 @@ describe('RegistrationFailure', () => {
failureCount: 0,
};
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);
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage);
});
it('should match tpa session expired error message', () => {
@@ -172,13 +164,9 @@ describe('RegistrationFailure', () => {
failureCount: 0,
};
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);
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toEqual(expectedMessage);
});
it('should match tpa authentication failed error message', () => {
@@ -191,13 +179,9 @@ describe('RegistrationFailure', () => {
failureCount: 0,
};
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);
const registrationPage = mount(reduxWrapper(<IntlRegistrationFailure {...props} />));
expect(registrationPage.find('div.alert-heading').length).toEqual(1);
expect(registrationPage.find('div.alert').first().text()).toContain(expectedMessageSubstring);
});
it('should display error message based on the error code returned by API', () => {
@@ -211,10 +195,10 @@ describe('RegistrationFailure', () => {
},
});
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();
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.',
);
});
});
});

View File

@@ -2,23 +2,17 @@ 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 './index';
} from '../../common-components';
import {
PENDING_STATE, REGISTER_PAGE,
} from '../../data/constants';
import messages from '../messages';
/**
* This component renders the Single sign-on (SSO) buttons for the providers passed.
@@ -26,59 +20,33 @@ import {
const ThirdPartyAuth = (props) => {
const { formatMessage } = useIntl();
const {
providers,
secondaryProviders,
currentProvider,
handleInstitutionLogin,
thirdPartyAuthApiStatus,
isLoginPage,
providers, secondaryProviders, currentProvider, handleInstitutionLogin, thirdPartyAuthApiStatus,
} = 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">
{isLoginPage
? formatMessage(messages['login.other.options.heading'])
: formatMessage(messages['registration.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 ? (
<div className="mt-4">
<Skeleton className="tpa-skeleton" height={36} count={2} />
</div>
<Skeleton className="tpa-skeleton" height={36} count={2} />
) : (
<>
{(isEnterpriseLoginDisabled && isInstitutionAuthActive) && (
<RenderInstitutionButton
onSubmitHandler={handleInstitutionLogin}
buttonTitle={formatMessage(messages['institution.login.button'])}
buttonTitle={formatMessage(messages['register.institution.login.button'])}
/>
)}
{isSocialAuthActive && (
<div className="row m-0">
<SocialAuthProviders
socialAuthProviders={providers}
referrer={isLoginPage ? LOGIN_PAGE : REGISTER_PAGE}
/>
<SocialAuthProviders socialAuthProviders={providers} referrer={REGISTER_PAGE} />
</div>
)}
</>
@@ -91,8 +59,7 @@ ThirdPartyAuth.defaultProps = {
currentProvider: null,
providers: [],
secondaryProviders: [],
thirdPartyAuthApiStatus: PENDING_STATE,
isLoginPage: false,
thirdPartyAuthApiStatus: 'pending',
};
ThirdPartyAuth.propTypes = {
@@ -119,7 +86,6 @@ ThirdPartyAuth.propTypes = {
}),
),
thirdPartyAuthApiStatus: PropTypes.string,
isLoginPage: PropTypes.bool,
};
export default ThirdPartyAuth;

View File

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

View File

@@ -7,7 +7,6 @@ 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 = () => ({
@@ -38,12 +37,6 @@ 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,9 +3,7 @@ import {
REGISTER_CLEAR_USERNAME_SUGGESTIONS,
REGISTER_FORM_VALIDATIONS,
REGISTER_NEW_USER,
REGISTER_SET_COUNTRY_CODE,
REGISTER_SET_EMAIL_SUGGESTIONS,
REGISTER_SET_USER_PIPELINE_DATA_LOADED,
REGISTER_SET_COUNTRY_CODE, REGISTER_SET_USER_PIPELINE_DATA_LOADED,
REGISTRATION_CLEAR_BACKEND_ERROR,
} from './actions';
import {
@@ -50,7 +48,7 @@ const reducer = (state = defaultState, action = {}) => {
};
case BACKUP_REGISTRATION_DATA.BEGIN:
return {
...state,
...defaultState,
usernameSuggestions: state.usernameSuggestions,
registrationFormData: { ...action.payload },
userPipelineDataLoaded: state.userPipelineDataLoaded,
@@ -121,14 +119,6 @@ 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,7 +7,6 @@ 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';
@@ -65,29 +64,6 @@ 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,10 +1,7 @@
import { snakeCaseObject } from '@edx/frontend-platform';
import { getConfig, 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
@@ -38,61 +35,36 @@ export const isFormValid = (
) => {
const fieldErrors = { ...errors };
let isValid = true;
let emailSuggestion = { suggestion: '', type: '' };
Object.keys(payload).forEach(key => {
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 (!payload[key]) {
fieldErrors[key] = formatMessage(messages[`empty.${key}.field.error`]);
}
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 (fieldErrors[key]) {
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;
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;
}
}
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, emailSuggestion };
return { isValid, fieldErrors };
};
/**

View File

@@ -64,12 +64,22 @@ 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-5">
<Alert id="reset-password-success" variant="success" className="mb-4">
<Alert.Heading>
{formatMessage(messages['reset.password.success.heading'])}
</Alert.Heading>

View File

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

View File

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

View File

@@ -3,7 +3,10 @@
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();