Compare commits

..

1 Commits

Author SHA1 Message Date
mubbsharanwar
74fc221674 feat: onboarding component recommendations on authn 2024-07-24 15:57:10 +05:00
30 changed files with 10405 additions and 4240 deletions

1
.github/CODEOWNERS vendored
View File

@@ -1 +0,0 @@
* @openedx/2U-infinity

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-infinity** to do it.
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/2u-vanguards** to do it.
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.

View File

@@ -11,17 +11,16 @@ on:
jobs:
tests:
runs-on: ubuntu-20.04
strategy:
matrix:
node: [18, 20]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
node-version: ${{ env.NODE_VER }}
- name: Install Dependencies
run: npm ci
@@ -42,7 +41,4 @@ jobs:
run: npm run build
- name: Run Code Coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
uses: codecov/codecov-action@v3

View File

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

2
.nvmrc
View File

@@ -1 +1 @@
20
18

View File

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

View File

@@ -187,7 +187,7 @@ All community members are expected to follow the `Open edX Code of Conduct <http
People
======
The assigned maintainers for this component and other project details may be
found in `Backstage <https://backstage.openedx.org/catalog/default/group/2u-infinity>`_. Backstage pulls this data from the ``catalog-info.yaml``
found in `Backstage <https://backstage.openedx.org/catalog/default/group/2u-vanguards>`_. Backstage pulls this data from the ``catalog-info.yaml``
file in this repo.
Reporting Security Issues

View File

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

View File

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

14360
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,12 +33,12 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-platform": "^8.0.0",
"@edx/frontend-platform": "7.1.3",
"@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "6.6.0",
"@fortawesome/free-brands-svg-icons": "6.6.0",
"@fortawesome/free-solid-svg-icons": "6.6.0",
"@fortawesome/react-fontawesome": "0.2.2",
"@fortawesome/fontawesome-svg-core": "6.5.2",
"@fortawesome/free-brands-svg-icons": "6.5.2",
"@fortawesome/free-solid-svg-icons": "6.5.2",
"@fortawesome/react-fontawesome": "0.2.0",
"@openedx/paragon": "^22.1.1",
"@optimizely/react-sdk": "^2.9.1",
"@redux-devtools/extension": "3.3.0",
@@ -47,9 +47,9 @@
"algoliasearch": "^4.14.3",
"algoliasearch-helper": "^3.14.0",
"classnames": "2.5.1",
"core-js": "3.38.1",
"core-js": "3.36.1",
"fastest-levenshtein": "1.0.16",
"form-urlencoded": "6.1.5",
"form-urlencoded": "6.1.4",
"prop-types": "15.8.1",
"query-string": "7.1.3",
"react": "^17.0.2",
@@ -58,10 +58,10 @@
"react-loading-skeleton": "3.4.0",
"react-redux": "7.2.9",
"react-responsive": "8.2.0",
"react-router": "6.26.1",
"react-router-dom": "6.26.1",
"react-router": "6.22.3",
"react-router-dom": "6.22.3",
"react-zendesk": "^0.1.13",
"redux": "4.2.1",
"redux": "4.2.0",
"redux-logger": "3.0.6",
"redux-mock-store": "1.5.4",
"redux-saga": "1.3.0",
@@ -73,8 +73,8 @@
"devDependencies": {
"@edx/browserslist-config": "^1.1.1",
"@edx/reactifex": "1.1.0",
"@openedx/frontend-build": "^14.0.3",
"babel-plugin-formatjs": "10.5.16",
"@openedx/frontend-build": "13.1.4",
"babel-plugin-formatjs": "10.5.14",
"eslint-plugin-import": "2.29.1",
"glob": "7.2.3",
"history": "5.3.0",

View File

@@ -5,8 +5,8 @@
<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"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.4.4/iframeResizer.contentWindow.min.js"
integrity="sha512-IWwZFBvHzN41wNI6etRLLuLrDDj/6AwJcPt7cmKJAzluYTIHHQ1PF8wh0rSy05jxEvvjflVvH2MxeV6riyEEXg=="
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.9/iframeResizer.contentWindow.min.js"
integrity="sha512-mdT/HQRzoRP4laVz49Mndx6rcCGA3IhuyhP3gaY0E9sZPkwbtDk9ttQIq9o8qGCf5VvJv1Xsy3k2yTjfUoczqw=="
crossorigin="anonymous"
referrerpolicy="no-referrer">
</script>

View File

@@ -7,7 +7,6 @@ import PropTypes from 'prop-types';
import messages from './messages';
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
import setCookie from '../data/utils/cookies';
const ThirdPartyAuthAlert = (props) => {
const { formatMessage } = useIntl();
@@ -21,10 +20,7 @@ const ThirdPartyAuthAlert = (props) => {
message = formatMessage(messages['register.third.party.auth.account.not.linked'], { currentProvider, platformName });
}
if (currentProvider) {
// Setting this cookie to capture marketingEmailsOptIn for SSO flow on the onboarding component
setCookie('ssoPipelineRedirectionDone', true);
} else {
if (!currentProvider) {
return null;
}

View File

@@ -4,8 +4,9 @@ import { getConfig } from '@edx/frontend-platform';
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import PropTypes from 'prop-types';
import { RESET_PAGE } from '../data/constants';
import { updatePathWithQueryParams } from '../data/utils';
import {
DEFAULT_REDIRECT_URL,
} from '../data/constants';
/**
* This wrapper redirects the requester to our default redirect url if they are
@@ -24,12 +25,7 @@ const UnAuthOnlyRoute = ({ children }) => {
if (isReady) {
if (authUser && authUser.username) {
const updatedPath = updatePathWithQueryParams(window.location.pathname);
if (updatedPath.startsWith(RESET_PAGE)) {
global.location.href = getConfig().LMS_BASE_URL;
return null;
}
global.location.href = getConfig().LMS_BASE_URL.concat(updatedPath);
global.location.href = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
return null;
}

View File

@@ -5,13 +5,14 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import {
MemoryRouter, Route, BrowserRouter as Router, Routes,
} from 'react-router-dom';
import { PAGE_NOT_FOUND, REGISTER_EMBEDDED_PAGE } from '../../data/constants';
import EmbeddedRegistrationRoute from '../EmbeddedRegistrationRoute';
import {
MemoryRouter, Route, BrowserRouter as Router, Routes,
} from 'react-router-dom';
const RRD = require('react-router-dom');
// Just render plain div with its children
// eslint-disable-next-line react/prop-types

View File

@@ -5,13 +5,14 @@ import React from 'react';
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import {
MemoryRouter, Route, BrowserRouter as Router, Routes,
} from 'react-router-dom';
import { UnAuthOnlyRoute } from '..';
import { REGISTER_PAGE } from '../../data/constants';
import {
MemoryRouter, Route, BrowserRouter as Router, Routes,
} from 'react-router-dom';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(),
fetchAuthenticatedUser: jest.fn(),

View File

@@ -66,14 +66,14 @@ exports[`SocialAuthProviders should match social auth provider with iconClass sn
data-prefix="fab"
focusable="false"
role="img"
style={{}}
style={Object {}}
viewBox="0 0 488 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
fill="currentColor"
style={{}}
style={Object {}}
/>
</svg>
</div>
@@ -93,7 +93,7 @@ exports[`SocialAuthProviders should match social auth provider with iconClass sn
`;
exports[`SocialAuthProviders should match social auth provider with iconImage snapshot 1`] = `
[
Array [
<button
className="btn-social btn-oa2-apple-id mr-3"
data-provider-url="/auth/login/apple-id/?auth_entry=login&next=/dashboard"

View File

@@ -21,7 +21,7 @@ exports[`ThirdPartyAuthAlert should match login page third party auth alert mess
`;
exports[`ThirdPartyAuthAlert should match register page third party auth alert message snapshot 1`] = `
[
Array [
<div
className="fade alert-content alert-success mt-n2 mb-5 alert show"
id="tpa-alert"

View File

@@ -5,23 +5,23 @@ exports[`Zendesk Help should match login page third party auth alert message sna
cookies={true}
defer={true}
webWidget={
{
"answerBot": {
"avatar": {
"name": {
Object {
"answerBot": Object {
"avatar": Object {
"name": Object {
"*": "edX Support",
},
"url": undefined,
},
"contactOnlyAfterQuery": true,
"suppress": false,
"title": {
"title": Object {
"*": "edX Support",
},
},
"chat": {
"departments": {
"enabled": [
"chat": Object {
"departments": Object {
"enabled": Array [
"account settings",
"billing and payments",
"certificates",
@@ -33,17 +33,17 @@ exports[`Zendesk Help should match login page third party auth alert message sna
},
"suppress": false,
},
"contactForm": {
"contactForm": Object {
"attachments": true,
"selectTicketForm": {
"selectTicketForm": Object {
"*": "Please choose your request type:",
},
"ticketForms": [
{
"fields": [
{
"ticketForms": Array [
Object {
"fields": Array [
Object {
"id": "description",
"prefill": {
"prefill": Object {
"*": "",
},
},
@@ -53,10 +53,10 @@ exports[`Zendesk Help should match login page third party auth alert message sna
},
],
},
"contactOptions": {
"contactOptions": Object {
"enabled": false,
},
"helpCenter": {
"helpCenter": Object {
"originalArticleButton": true,
},
}

View File

@@ -35,6 +35,6 @@ export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+
// Query string parameters that can be passed to LMS to manage
// things like auto-enrollment upon login and registration.
export const AUTH_PARAMS = ['course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow', 'next', 'register_for_free', 'track', 'is_account_recovery', 'variant', 'host', 'cta'];
export const AUTH_PARAMS = ['course_id', 'enrollment_action', 'course_mode', 'country', 'email_opt_in', 'purchase_workflow', 'next', 'register_for_free', 'track', 'is_account_recovery', 'variant', 'host', 'cta', 'levelOfEducation', 'finishAuthUrl'];
export const REDIRECT = 'redirect';
export const APP_NAME = 'authn_mfe';

View File

@@ -41,7 +41,6 @@ import {
getTpaProvider,
updatePathWithQueryParams,
} from '../data/utils';
import { removeCookie } from '../data/utils/cookies';
import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess';
import {
trackForgotPasswordLinkClick, trackLoginPageViewed, trackLoginSuccess,
@@ -87,9 +86,6 @@ const LoginPage = (props) => {
useEffect(() => {
if (loginResult.success) {
trackLoginSuccess();
// Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component
removeCookie('ssoPipelineRedirectionDone');
}
}, [loginResult]);

View File

@@ -1,7 +1,8 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
breakpoints,
@@ -21,18 +22,35 @@ import RecommendationsLargeLayout from './RecommendationsPageLayouts/LargeLayout
import RecommendationsSmallLayout from './RecommendationsPageLayouts/SmallLayout';
import { LINK_TIMEOUT, trackRecommendationsViewed, trackSkipButtonClicked } from './track';
import { DEFAULT_REDIRECT_URL } from '../data/constants';
import { getAllPossibleQueryParams } from '../data/utils';
const RecommendationsPage = () => {
const { formatMessage } = useIntl();
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth - 1 });
const location = useLocation();
const queryParams = getAllPossibleQueryParams();
// flag to show recommendations for onboarding component experience
const showRecommendations = !!queryParams?.finalRedirectUrl && !!queryParams?.country;
const backendCountryCode = useSelector((state) => state.register.backendCountryCode);
const registrationResponse = location.state?.registrationResult;
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
const educationLevel = EDUCATION_LEVEL_MAPPING[location.state?.educationLevel];
const userId = location.state?.userId;
const [redirectUrl, setRedirectUrl] = useState(location.state?.registrationResult?.redirectUrl);
const [educationLevel, setEducationLevel] = useState(EDUCATION_LEVEL_MAPPING[location.state?.educationLevel]);
const [userId, setUserId] = useState(location.state?.userId || null);
const [userCountry, setUserCountry] = useState(backendCountryCode);
useEffect(() => {
if (showRecommendations) {
const authenticatedUser = getAuthenticatedUser();
if (authenticatedUser) {
setRedirectUrl(queryParams.finalRedirectUrl);
setEducationLevel(EDUCATION_LEVEL_MAPPING[queryParams?.levelOfEducation]);
setUserCountry(queryParams.country);
setUserId(authenticatedUser?.userId);
}
}
}, [showRecommendations, queryParams]);
const userCountry = useSelector((state) => state.register.backendCountryCode);
const {
recommendations: algoliaRecommendations,
isLoading,
@@ -46,8 +64,8 @@ const RecommendationsPage = () => {
const handleSkipRecommendationPage = () => {
window.history.replaceState(location.state, null, '');
if (registrationResponse) {
window.location.href = registrationResponse.redirectUrl;
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
window.location.href = DASHBOARD_URL;
}
@@ -59,7 +77,7 @@ const RecommendationsPage = () => {
setTimeout(() => { handleSkipRecommendationPage(); }, LINK_TIMEOUT);
};
if (!registrationResponse) {
if (!redirectUrl && !showRecommendations) {
window.location.href = DASHBOARD_URL;
return null;
}

View File

@@ -15,7 +15,7 @@ const generateProductKey = (product) => (
export const getProductMapping = (recommendedProducts) => recommendedProducts.map((product) => ({
product_key: generateProductKey(product),
product_line: product.cardType,
product_source: product?.productSource?.name,
product_source: product.productSource.name,
}));
export const trackRecommendationClick = (product, position, userId) => {
@@ -25,7 +25,7 @@ export const trackRecommendationClick = (product, position, userId) => {
recommendation_type: product.recommendationType,
product_key: generateProductKey(product),
product_line: product.cardType,
product_source: product?.productSource?.name,
product_source: product.productSource.name,
user_id: userId,
});

View File

@@ -11,7 +11,7 @@ export const INVALID_NAME_REGEX = /https?:\/\/(?:[-\w.]|(?:%[\da-fA-F]{2}))*/g;
const validateName = (value, formatMessage) => {
let fieldError = '';
if (!value || (value && !value.trim())) {
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)) {
fieldError = formatMessage(messages['name.validation.message']);

View File

@@ -188,10 +188,8 @@ const RegistrationPage = (props) => {
// This is used by the "User Retention Rate Event" on GTM
setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true);
// Remove marketingEmailsOptIn cookie that was set on SSO registration flow
// remove marketingEmailsOptIn cookie that was set on SSO registration flow
removeCookie('marketingEmailsOptIn');
// Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component
removeCookie('ssoPipelineRedirectionDone');
}
}, [registrationResult]);

View File

@@ -188,7 +188,7 @@ describe('RegistrationPage', () => {
password: 'password1',
country: 'Pakistan',
honor_code: true,
total_registration_time: 0,
totalRegistrationTime: 0,
next: '/course/demo-course-url',
app_name: APP_NAME,
};
@@ -212,7 +212,7 @@ describe('RegistrationPage', () => {
country: 'Pakistan',
honor_code: true,
social_auth_provider: 'Apple',
total_registration_time: 0,
totalRegistrationTime: 0,
app_name: APP_NAME,
};
@@ -246,7 +246,7 @@ describe('RegistrationPage', () => {
password: 'password1',
country: 'Ukraine',
honor_code: true,
total_registration_time: 0,
totalRegistrationTime: 0,
};
store.dispatch = jest.fn(store.dispatch);
@@ -271,7 +271,7 @@ describe('RegistrationPage', () => {
password: 'password1',
country: 'Ukraine',
honor_code: true,
total_registration_time: 0,
totalRegistrationTime: 0,
};
store.dispatch = jest.fn(store.dispatch);
@@ -297,7 +297,7 @@ describe('RegistrationPage', () => {
password: 'password1',
country: 'Pakistan',
honor_code: true,
total_registration_time: 0,
totalRegistrationTime: 0,
marketing_emails_opt_in: true,
app_name: APP_NAME,
};
@@ -325,7 +325,7 @@ describe('RegistrationPage', () => {
password: 'password1',
country: 'Pakistan',
honor_code: true,
total_registration_time: 0,
totalRegistrationTime: 0,
app_name: APP_NAME,
};
@@ -891,7 +891,7 @@ describe('RegistrationPage', () => {
email: 'john.doe@example.com',
country: 'PK',
social_auth_provider: 'Apple',
total_registration_time: 0,
totalRegistrationTime: 0,
app_name: APP_NAME,
}));
});

View File

@@ -40,10 +40,7 @@ const ConfigurableRegistrationForm = (props) => {
States' instead of 'United States of America' which does not exist in country dropdown list and gets the user
confused and unable to create an account. So we added the United States entry in the dropdown list.
*/
const countryList = useMemo(() => (
getCountryList(getLocale()).concat([{ code: 'US', name: 'United States' }]).filter(country => country.code !== 'RU')
), []);
const countryList = useMemo(() => getCountryList(getLocale()).concat([{ code: 'US', name: 'United States' }]), []);
let showTermsOfServiceAndHonorCode = false;
let showCountryField = false;

View File

@@ -251,7 +251,7 @@ describe('ConfigurableRegistrationForm', () => {
country: 'Pakistan',
honor_code: true,
profession: 'Engineer',
total_registration_time: 0,
totalRegistrationTime: 0,
};
store.dispatch = jest.fn(store.dispatch);
@@ -362,7 +362,7 @@ describe('ConfigurableRegistrationForm', () => {
password: 'password1',
country: 'Ukraine',
honor_code: true,
total_registration_time: 0,
totalRegistrationTime: 0,
};
store = mockStore({

View File

@@ -1,77 +0,0 @@
import { isFormValid } from '../utils';
describe('Payload validation', () => {
let formatMessage;
let configurableFormFields;
let fieldDescriptions;
beforeEach(() => {
formatMessage = jest.fn(msg => msg);
configurableFormFields = {
confirm_email: true,
};
fieldDescriptions = {};
});
test('validates name field correctly', () => {
const payload = { name: ' ' };
const errors = {};
const { isValid, fieldErrors } = isFormValid(
payload,
errors,
configurableFormFields,
fieldDescriptions,
formatMessage);
expect(fieldErrors.name).toBeDefined();
expect(isValid).toBe(false);
});
test('validates email field correctly', () => {
const payload = { email: 'invalid-email' };
const errors = {};
const { isValid, fieldErrors } = isFormValid(
payload, errors, configurableFormFields, fieldDescriptions, formatMessage);
expect(fieldErrors.email).toBeDefined();
expect(isValid).toBe(false);
});
test('validates username field correctly', () => {
const payload = { username: 'invalid username' };
const errors = {};
const { isValid, fieldErrors } = isFormValid(
payload, errors, configurableFormFields, fieldDescriptions, formatMessage);
expect(fieldErrors.username).toBeDefined();
expect(isValid).toBe(false);
});
test('validates password field correctly', () => {
const payload = { password: 'short' };
const errors = {};
const { isValid, fieldErrors } = isFormValid(
payload, errors, configurableFormFields, fieldDescriptions, formatMessage);
expect(fieldErrors.password).toBeDefined();
expect(isValid).toBe(false);
});
test('validates multiple fields correctly', () => {
const payload = {
name: 'InvalidName!',
email: 'invalid-email',
username: 'invalid username',
password: 'short',
};
const errors = {};
const { isValid, fieldErrors } = isFormValid(
payload, errors, configurableFormFields, fieldDescriptions, formatMessage);
expect(fieldErrors.name).toBeDefined();
expect(fieldErrors.email).toBeDefined();
expect(fieldErrors.username).toBeDefined();
expect(fieldErrors.password).toBeDefined();
expect(isValid).toBe(false);
});
});

View File

@@ -126,8 +126,8 @@ export const prepareRegistrationPayload = (
delete payload.marketingEmailsOptIn;
}
payload.totalRegistrationTime = totalRegistrationTime;
payload = snakeCaseObject(payload);
payload.totalRegistrationTime = totalRegistrationTime;
// add query params to the payload
payload = { ...payload, ...queryParams };