Recommendations v2 (#1040)

* feat: add personalized recommendations (#1024)

* use Algolia for personalized recommendations
* show personalized recommendations to use that have consented
to functional cookies
* update tests

VAN-1599

* Revert "fix: special characters in redirect url getting decoded to space (#1029)" (#1030)

This reverts commit fc62241332.

* feat: update recommendations page design (#1036)

VAN-1598

* feat: add events for recommendations (#1039)

* feat: remove static recommendations

---------

Co-authored-by: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com>
This commit is contained in:
Zainab Amir
2023-09-08 12:08:41 +05:00
committed by GitHub
parent 37e811d7e5
commit 5e15969f4a
34 changed files with 1203 additions and 351 deletions

2
.env
View File

@@ -25,7 +25,7 @@ SEARCH_CATALOG_URL=''
DISABLE_ENTERPRISE_LOGIN=''
ENABLE_DYNAMIC_REGISTRATION_FIELDS=''
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN=''
ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS=''
ENABLE_POST_REGISTRATION_RECOMMENDATIONS=''
MARKETING_EMAILS_OPT_IN=''
SHOW_CONFIGURABLE_EDX_FIELDS=''
# ***** Zendesk related keys *****

222
package-lock.json generated
View File

@@ -18,7 +18,10 @@
"@fortawesome/react-fontawesome": "0.2.0",
"@optimizely/react-sdk": "^2.9.1",
"@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.3.2",
"core-js": "3.32.0",
"fastest-levenshtein": "1.0.16",
@@ -137,6 +140,11 @@
"@algolia/transporter": "4.19.1"
}
},
"node_modules/@algolia/events": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz",
"integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ=="
},
"node_modules/@algolia/logger-common": {
"version": "4.19.1",
"resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.19.1.tgz",
@@ -5384,6 +5392,158 @@
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@testing-library/dom": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz",
"integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.1.3",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/@testing-library/dom/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/@testing-library/dom/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@testing-library/react": {
"version": "12.1.5",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz",
"integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@testing-library/dom": "^8.0.0",
"@types/react-dom": "<18.0.0"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": "<18.0.0",
"react-dom": "<18.0.0"
}
},
"node_modules/@testing-library/react-hooks": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz",
"integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"react-error-boundary": "^3.1.0"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"@types/react": "^16.9.0 || ^17.0.0",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0",
"react-test-renderer": "^16.9.0 || ^17.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-test-renderer": {
"optional": true
}
}
},
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -5400,6 +5560,11 @@
"node": ">=10.13.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz",
"integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q=="
},
"node_modules/@types/babel__core": {
"version": "7.20.1",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz",
@@ -5665,15 +5830,23 @@
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw=="
},
"node_modules/@types/react": {
"version": "18.2.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.11.tgz",
"integrity": "sha512-+hsJr9hmwyDecSMQAmX7drgbDpyE+EgSF6t7+5QEBAn1tQK7kl1vWZ4iRf6SjQ8lk7dyEULxUmZOIpN0W5baZA==",
"version": "17.0.63",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.63.tgz",
"integrity": "sha512-T+aaG8RlIkgJ4VzWLJYbMW9QX7sIAV8CcuyV6FU6Hm7yu3Bee1YBZQRu2vYEm/dU8kre+/mzl2aGYh5MFgVLaQ==",
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "17.0.20",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.20.tgz",
"integrity": "sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA==",
"dependencies": {
"@types/react": "^17"
}
},
"node_modules/@types/react-redux": {
"version": "7.1.25",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.25.tgz",
@@ -6191,6 +6364,17 @@
"@algolia/transporter": "4.19.1"
}
},
"node_modules/algoliasearch-helper": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.14.0.tgz",
"integrity": "sha512-gXDXzsSS0YANn5dHr71CUXOo84cN4azhHKUbg71vAWnH+1JBiR4jf7to3t3JHXknXkbV0F7f055vUSBKrltHLQ==",
"dependencies": {
"@algolia/events": "^4.0.1"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 6"
}
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@@ -8693,6 +8877,11 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="
},
"node_modules/dom-converter": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -15517,6 +15706,14 @@
"yallist": "^3.0.2"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/mailto-link": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mailto-link/-/mailto-link-2.0.0.tgz",
@@ -18041,6 +18238,21 @@
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-error-boundary": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
"integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=10",
"npm": ">=6"
},
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-error-overlay": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
@@ -18342,7 +18554,7 @@
"version": "16.15.0",
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
"integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==",
"dev": true,
"devOptional": true,
"dependencies": {
"object-assign": "^4.1.1",
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
@@ -18397,7 +18609,7 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz",
"integrity": "sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==",
"dev": true,
"devOptional": true,
"dependencies": {
"object-assign": "^4.1.1",
"react-is": "^17.0.2",

View File

@@ -41,7 +41,10 @@
"@fortawesome/react-fontawesome": "0.2.0",
"@optimizely/react-sdk": "^2.9.1",
"@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.3.2",
"core-js": "3.32.0",
"fastest-levenshtein": "1.0.16",

View File

@@ -6,7 +6,7 @@ const configuration = {
DISABLE_ENTERPRISE_LOGIN: process.env.DISABLE_ENTERPRISE_LOGIN || '',
ENABLE_DYNAMIC_REGISTRATION_FIELDS: process.env.ENABLE_DYNAMIC_REGISTRATION_FIELDS || false,
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: process.env.ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN || false,
ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS: process.env.ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS || 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,
// Links
@@ -26,12 +26,12 @@ const configuration = {
BANNER_IMAGE_EXTRA_SMALL: process.env.BANNER_IMAGE_EXTRA_SMALL || '',
// Recommendation constants
GENERAL_RECOMMENDATIONS: process.env.GENERAL_RECOMMENDATIONS || '[]',
POPULAR_PRODUCTS: process.env.POPULAR_PRODUCTS || '[]',
TRENDING_PRODUCTS: process.env.TRENDING_PRODUCTS || '[]',
// Miscellaneous
INFO_EMAIL: process.env.INFO_EMAIL || '',
ZENDESK_KEY: process.env.ZENDESK_KEY,
ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL,
ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '',
ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '',
};
export default configuration;

20
src/data/algolia.js Normal file
View File

@@ -0,0 +1,20 @@
import { getConfig } from '@edx/frontend-platform';
import algoliasearch from 'algoliasearch';
// initialize Algolia workers
const initializeSearchClient = () => algoliasearch(
getConfig().ALGOLIA_APP_ID,
getConfig().ALGOLIA_SEARCH_API_KEY,
);
const getLocationRestrictionFilter = (userCountry) => {
if (userCountry) {
return `NOT blocked_in:"${userCountry}" AND (allowed_in:"null" OR allowed_in:"${userCountry}")`;
}
return '';
};
export {
initializeSearchClient,
getLocationRestrictionFilter,
};

3
src/data/oneTrust.js Normal file
View File

@@ -0,0 +1,3 @@
const isOneTrustFunctionalCookieEnabled = () => !!window?.OnetrustActiveGroups?.includes('C0003');
export default isOneTrustFunctionalCookieEnabled;

View File

@@ -0,0 +1,16 @@
import { getLocationRestrictionFilter } from '../algolia';
describe('algoliaUtilsTests', () => {
it('test getLocationRestrictionFilter returns filter if country is passed', () => {
const countryCode = 'PK';
const filter = getLocationRestrictionFilter(countryCode);
const expectedFilter = `NOT blocked_in:"${countryCode}" AND (allowed_in:"null" OR allowed_in:"${countryCode}")`;
expect(filter).toEqual(expectedFilter);
});
it('test getLocationRestrictionFilter returns empty string if country is not passed', () => {
const countryCode = '';
const filter = getLocationRestrictionFilter(countryCode);
const expectedFilter = '';
expect(filter).toEqual(expectedFilter);
});
});

View File

@@ -1,5 +1,5 @@
import { updatePathWithQueryParams } from './dataUtils';
import { LOGIN_PAGE } from '../constants';
import { updatePathWithQueryParams } from '../utils/dataUtils';
describe('updatePathWithQueryParams', () => {
it('should append query params into the path', () => {

View File

@@ -1,4 +1,4 @@
import AsyncActionType from './reduxUtils';
import AsyncActionType from '../utils/reduxUtils';
describe('AsyncActionType', () => {
it('should return well formatted action strings', () => {

View File

@@ -35,12 +35,9 @@ import {
FAILURE_STATE,
PENDING_STATE,
} from '../data/constants';
import isOneTrustFunctionalCookieEnabled from '../data/oneTrust';
import { getAllPossibleQueryParams, isHostAvailableInQueryParams } from '../data/utils';
import { FormFieldRenderer } from '../field-renderer';
import {
activateRecommendationsExperiment, RECOMMENDATIONS_EXP_VARIATION,
} from '../recommendations/optimizelyExperiment';
import { trackRecommendationsGroup, trackRecommendationsViewed } from '../recommendations/track';
const ProgressiveProfiling = (props) => {
const { formatMessage } = useIntl();
@@ -56,7 +53,10 @@ const ProgressiveProfiling = (props) => {
const queryParams = getAllPossibleQueryParams();
const authenticatedUser = getAuthenticatedUser() || location.state?.authenticatedUser;
const enablePopularAndTrendingRecommendations = getConfig().ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS;
const functionalCookiesConsent = isOneTrustFunctionalCookieEnabled();
const enablePostRegistrationRecommendations = (
getConfig().ENABLE_POST_REGISTRATION_RECOMMENDATIONS && functionalCookiesConsent
);
const [registrationResult, setRegistrationResult] = useState({ redirectUrl: '' });
const [formFieldData, setFormFieldData] = useState({ fields: {}, extendedProfile: [] });
@@ -102,21 +102,27 @@ const ProgressiveProfiling = (props) => {
}, [authenticatedUser]);
useEffect(() => {
if (!enablePostRegistrationRecommendations) {
sendTrackEvent(
'edx.bi.user.recommendations.not.enabled',
{ functionalCookiesConsent, page: 'authn_recommendations' },
);
return;
}
if (registrationResult.redirectUrl && authenticatedUser?.userId) {
const redirectQueryParams = getAllPossibleQueryParams(registrationResult.redirectUrl);
if (enablePopularAndTrendingRecommendations && !('enrollment_action' in redirectQueryParams) && !queryParams?.next) {
const userIdStr = authenticatedUser.userId.toString();
const variation = activateRecommendationsExperiment(userIdStr);
const showRecommendations = variation === RECOMMENDATIONS_EXP_VARIATION;
trackRecommendationsGroup(variation, authenticatedUser.userId);
setShowRecommendationsPage(showRecommendations);
if (!showRecommendations) {
trackRecommendationsViewed([], '', true, authenticatedUser.userId);
}
if (!('enrollment_action' in redirectQueryParams || queryParams?.next)) {
setShowRecommendationsPage(true);
}
}
}, [authenticatedUser, enablePopularAndTrendingRecommendations, registrationResult.redirectUrl, queryParams?.next]);
}, [
authenticatedUser,
enablePostRegistrationRecommendations,
functionalCookiesConsent,
registrationResult.redirectUrl,
queryParams?.next,
]);
if (
!authenticatedUser

View File

@@ -16,7 +16,6 @@ import {
FAILURE_STATE,
RECOMMENDATIONS,
} from '../../data/constants';
import { activateRecommendationsExperiment } from '../../recommendations/optimizelyExperiment';
import { saveUserProfile } from '../data/actions';
import ProgressiveProfiling from '../ProgressiveProfiling';
@@ -35,11 +34,6 @@ jest.mock('@edx/frontend-platform/auth', () => ({
jest.mock('@edx/frontend-platform/logging', () => ({
getLoggingService: jest.fn(),
}));
jest.mock('../../recommendations/optimizelyExperiment.js', () => ({
activateRecommendationsExperiment: jest.fn(),
trackRecommendationViewedOptimizely: jest.fn(),
RECOMMENDATIONS_EXP_VARIATION: 'welcome_page_recommendations_enabled',
}));
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
@@ -220,8 +214,9 @@ describe('ProgressiveProfilingTests', () => {
});
describe('Recommendations test', () => {
window.OnetrustActiveGroups = 'C0003';
mergeConfig({
ENABLE_POPULAR_AND_TRENDING_RECOMMENDATIONS: true,
ENABLE_POST_REGISTRATION_RECOMMENDATIONS: true,
});
it('should redirect to recommendations page if recommendations are enabled', () => {
@@ -232,41 +227,13 @@ describe('ProgressiveProfilingTests', () => {
success: true,
},
});
activateRecommendationsExperiment.mockImplementation(() => 'welcome_page_recommendations_enabled');
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage />));
expect(progressiveProfilingPage.find('button.btn-brand').text()).toEqual('Next');
expect(mockNavigate).toHaveBeenCalledWith(RECOMMENDATIONS);
});
it('should fire segments recommendations viewed and variation group events', () => {
const viewedEventProperties = {
page: 'authn_recommendations',
products: [],
recommendation_type: '',
is_control: true,
user_id: 3,
};
const groupEventProperties = {
page: 'authn_recommendations',
variation: 'control',
user_id: 3,
};
activateRecommendationsExperiment.mockImplementation(() => 'control');
store = mockStore({
...initialState,
welcomePage: {
...initialState.welcomePage,
success: true,
},
});
mount(reduxWrapper(<IntlProgressiveProfilingPage />));
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.recommendations.group', groupEventProperties);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.recommendations.viewed', viewedEventProperties);
});
it('should not redirect to recommendations page if user is on its way to enroll in a course', () => {
it('should not redirect to recommendations page if user is on its way to enroll in a course', async () => {
const redirectUrl = `${getConfig().LMS_BASE_URL}${DEFAULT_REDIRECT_URL}?enrollment_action=1`;
useLocation.mockReturnValue({
state: {

View File

@@ -15,9 +15,9 @@ const BaseCard = ({
productTypeCopy,
footer,
handleOnClick,
isLoading = false,
isLoading,
}) => (
<div className="mr-4 recommendation-card" key={`container-${uuid}`}>
<div className="recommendation-card" key={`container-${uuid}`}>
<Hyperlink
target="_blank"
className="card-box"

View File

@@ -15,6 +15,7 @@ const ProductCard = ({
product,
userId,
position,
isLoading,
}) => {
const { formatMessage } = useIntl();
@@ -66,7 +67,6 @@ const ProductCard = ({
trackRecommendationClick(
product,
position + 1,
false,
userId,
);
};
@@ -82,6 +82,7 @@ const ProductCard = ({
productTypeCopy={productTypeCopy}
productType={productType}
variant={variant}
isLoading={isLoading}
footer={(
<Footer
quickFacts={product.degree?.quickFacts}
@@ -105,8 +106,10 @@ ProductCard.propTypes = {
]).isRequired,
userId: PropTypes.number.isRequired,
position: PropTypes.number.isRequired,
isLoading: PropTypes.bool,
};
ProductCard.defaultProps = {
isLoading: false,
};
export default ProductCard;

View File

@@ -5,19 +5,19 @@ import PropTypes from 'prop-types';
import ProductCard from './ProductCard';
const RecommendationsList = (props) => {
const { recommendations, userId } = props;
const { recommendations, userId, isLoading } = props;
return (
<div className="d-flex recommendations-container__card-list">
<div className="d-flex flex-wrap mb-3 recommendations-container__card-list">
{
recommendations.map((recommendation, idx) => (
<span key={recommendation.uuid}>
<ProductCard
product={recommendation}
position={idx}
userId={userId}
/>
</span>
<ProductCard
key={recommendation.uuid}
product={recommendation}
position={idx}
userId={userId}
isLoading={isLoading}
/>
))
}
</div>
@@ -29,11 +29,13 @@ RecommendationsList.propTypes = {
uuid: PropTypes.string,
})),
userId: PropTypes.number,
isLoading: PropTypes.bool,
};
RecommendationsList.defaultProps = {
recommendations: [],
userId: null,
isLoading: false,
};
export default RecommendationsList;

View File

@@ -1,36 +1,50 @@
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Container, Hyperlink, Image, StatefulButton, Tab, Tabs,
breakpoints,
Container,
Hyperlink,
Image, Skeleton,
StatefulButton,
useMediaQuery,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useLocation } from 'react-router-dom';
import { POPULAR, TRENDING } from './data/constants';
import useProducts from './data/hooks/useProducts';
import { EDUCATION_LEVEL_MAPPING, PERSONALIZED } from './data/constants';
import useAlgoliaRecommendations from './data/hooks/useAlgoliaRecommendations';
import messages from './messages';
import RecommendationsList from './RecommendationsList';
import { trackRecommendationsViewed } from './track';
import RecommendationsLargeLayout from './RecommendationsPageLayouts/LargeLayout';
import RecommendationsSmallLayout from './RecommendationsPageLayouts/SmallLayout';
import { trackRecommendationsViewed, trackSkipButtonClicked } from './track';
import { DEFAULT_REDIRECT_URL } from '../data/constants';
const RecommendationsPage = ({ countryCode }) => {
const RecommendationsPage = () => {
const { formatMessage } = useIntl();
const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth - 1 });
const location = useLocation();
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 { popularProducts, trendingProducts, isLoading } = useProducts(countryCode);
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
const userCountry = useSelector((state) => state.register.backendCountryCode);
const {
recommendations: algoliaRecommendations,
isLoading,
} = useAlgoliaRecommendations(userCountry, educationLevel);
useEffect(() => {
trackRecommendationsViewed(popularProducts, POPULAR, false, userId);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (!isLoading && algoliaRecommendations.length > 0) {
trackRecommendationsViewed(algoliaRecommendations, PERSONALIZED, userId);
}
}, [isLoading, algoliaRecommendations, userId]);
const handleRedirection = () => {
const handleSkipRecommendationPage = () => {
window.history.replaceState(location.state, null, '');
if (registrationResponse) {
window.location.href = registrationResponse.redirectUrl;
@@ -41,7 +55,8 @@ const RecommendationsPage = ({ countryCode }) => {
const handleSkip = (e) => {
e.preventDefault();
handleRedirection();
trackSkipButtonClicked(userId);
handleSkipRecommendationPage();
};
if (!registrationResponse) {
@@ -49,15 +64,10 @@ const RecommendationsPage = ({ countryCode }) => {
return null;
}
if (!isLoading && (!popularProducts.length || !trendingProducts.length)) {
handleRedirection();
if (!isLoading && !algoliaRecommendations.length) {
handleSkipRecommendationPage();
}
const handleOnSelect = (tabKey) => {
const recommendations = tabKey === POPULAR ? popularProducts : trendingProducts;
trackRecommendationsViewed(recommendations, tabKey, false, userId);
};
return (
<>
<Helmet>
@@ -65,64 +75,55 @@ const RecommendationsPage = ({ countryCode }) => {
{ siteName: getConfig().SITE_NAME })}
</title>
</Helmet>
<div className="d-flex flex-column vh-100 bg-light-200">
<div className="d-flex flex-column bg-light-200 min-vh-100">
<div className="mb-2">
<div className="col-md-12 small-screen-top-stripe medium-screen-top-stripe extra-large-screen-top-stripe" />
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
</Hyperlink>
</div>
<div className="d-flex flex-column align-items-center justify-content-center flex-grow-1 p-1">
<Container id="course-recommendations" size="lg" className="recommendations-container">
<h2 className="text-sm-center mb-4 text-left recommendations-container__heading">
{formatMessage(messages['recommendation.page.heading'])}
</h2>
<Tabs
variant="tabs"
defaultActiveKey={POPULAR}
id="recommendations-selection"
onSelect={handleOnSelect}
>
<Tab tabClassName="mb-3" eventKey={POPULAR} title={formatMessage(messages['recommendation.option.popular'])}>
<RecommendationsList
recommendations={popularProducts}
userId={userId}
<div className="d-flex flex-column align-items-center justify-content-center flex-grow-1">
<Container
id="course-recommendations"
size="lg"
className="pr-4 pl-4 mt-4.5 mb-4.5 mb-md-5"
>
{isExtraSmall ? (
<RecommendationsSmallLayout
userId={userId}
isLoading={isLoading}
personalizedRecommendations={algoliaRecommendations}
/>
) : (
<RecommendationsLargeLayout
userId={userId}
isLoading={isLoading}
personalizedRecommendations={algoliaRecommendations}
/>
)}
<div className="mt-3 mt-sm-4.5 text-center">
{isLoading && (
<Skeleton height={40} width={140} />
)}
{!isLoading && algoliaRecommendations.length && (
<StatefulButton
className="font-weight-500"
type="submit"
variant="outline-brand"
labels={{
default: formatMessage(messages['recommendation.skip.button']),
}}
onClick={handleSkip}
/>
</Tab>
<Tab tabClassName="mb-3" eventKey={TRENDING} title={formatMessage(messages['recommendation.option.trending'])}>
<RecommendationsList
recommendations={trendingProducts}
userId={userId}
/>
</Tab>
</Tabs>
)}
</div>
</Container>
<div className="text-center">
<StatefulButton
className="font-weight-500"
type="submit"
variant="brand"
labels={{
default: formatMessage(messages['recommendation.skip.button']),
}}
onClick={handleSkip}
/>
</div>
</div>
</div>
</>
);
};
RecommendationsPage.propTypes = {
countryCode: PropTypes.string.isRequired,
};
RecommendationsPage.propTypes = {};
const mapStateToProps = state => ({
countryCode: state.register.backendCountryCode,
});
export default connect(
mapStateToProps,
null,
)(RecommendationsPage);
export default RecommendationsPage;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Skeleton } from '@edx/paragon';
import PropTypes from 'prop-types';
import loadingCoursesPlaceholders from '../data/loadingCoursesPlaceholders';
import messages from '../messages';
import RecommendationsList from '../RecommendationsList';
const RecommendationsLargeLayout = (props) => {
const {
userId,
isLoading,
personalizedRecommendations,
} = props;
const { formatMessage } = useIntl();
if (isLoading) {
return (
<>
<Skeleton height={32} width={300} className="mb-5" />
<RecommendationsList
recommendations={loadingCoursesPlaceholders}
userId={userId}
isLoading
/>
</>
);
}
if (personalizedRecommendations.length) {
return (
<span id="recommendations-large-layout">
<h1 className="h2 text-sm-center mb-5 mb-sm-4.5 text-left recommendations-container__heading">
{formatMessage(messages['recommendation.page.heading'])}
</h1>
<RecommendationsList
recommendations={personalizedRecommendations}
userId={userId}
/>
</span>
);
}
return null;
};
RecommendationsLargeLayout.propTypes = {
userId: PropTypes.number.isRequired,
isLoading: PropTypes.bool,
personalizedRecommendations: PropTypes.arrayOf(PropTypes.shape({})),
};
RecommendationsLargeLayout.defaultProps = {
isLoading: true,
personalizedRecommendations: [],
};
export default RecommendationsLargeLayout;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Skeleton } from '@edx/paragon';
import PropTypes from 'prop-types';
import loadingCoursesPlaceholders from '../data/loadingCoursesPlaceholders';
import messages from '../messages';
import RecommendationsList from '../RecommendationsList';
const RecommendationsSmallLayout = (props) => {
const {
userId,
isLoading,
personalizedRecommendations,
} = props;
const { formatMessage } = useIntl();
if (isLoading) {
return (
<>
<Skeleton height={36} className="mb-3" />
<RecommendationsList
recommendations={loadingCoursesPlaceholders}
userId={userId}
isLoading
/>
</>
);
}
if (personalizedRecommendations.length) {
return (
<span id="recommendations-small-layout">
<h1 className="h3 text-sm-center mb-4.5 text-left recommendations-container__heading">
{formatMessage(messages['recommendation.page.heading'])}
</h1>
<RecommendationsList
recommendations={personalizedRecommendations}
userId={userId}
/>
</span>
);
}
return null;
};
RecommendationsSmallLayout.propTypes = {
userId: PropTypes.number.isRequired,
isLoading: PropTypes.bool,
personalizedRecommendations: PropTypes.arrayOf(PropTypes.shape({})),
};
RecommendationsSmallLayout.defaultProps = {
isLoading: true,
personalizedRecommendations: [],
};
export default RecommendationsSmallLayout;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import SmallLayout from './SmallLayout';
import mockedRecommendedProducts from '../data/tests/mockedData';
const IntlRecommendationsSmallLayoutPage = injectIntl(SmallLayout);
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock('@edx/paragon', () => ({
...jest.requireActual('@edx/paragon'),
useMediaQuery: jest.fn(),
}));
describe('RecommendationsPageTests', () => {
let props = {};
const reduxWrapper = children => (
<IntlProvider locale="en">
{children}
</IntlProvider>
);
beforeEach(() => {
props = {
userId: 123,
personalizedRecommendations: mockedRecommendedProducts,
isLoading: false,
};
});
it('should render recommendations when recommendations are not loading', () => {
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsSmallLayoutPage {...props} />));
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeFalsy();
});
it('should render loading state when recommendations are loading', () => {
props = {
...props,
isLoading: true,
};
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsSmallLayoutPage {...props} />));
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeTruthy();
});
});

View File

@@ -0,0 +1,29 @@
const { camelCaseObject } = require('@edx/frontend-platform');
const processCourseSearchResult = (searchResultCourse) => {
const camelCasedResult = camelCaseObject(searchResultCourse);
return {
activeCourseRun: {
key: camelCasedResult.activeRunKey,
type: camelCasedResult.activeRunType,
marketingUrl: camelCasedResult.marketingUrl,
},
allowedIn: camelCasedResult.allowedIn,
blockedIn: camelCasedResult.blockedIn,
cardType: 'course',
courseType: 'course',
image: {
src: camelCasedResult.cardImageUrl,
},
owners: camelCasedResult.owners,
title: camelCasedResult.title,
uuid: camelCasedResult.uuid,
objectID: `course-${camelCasedResult.uuid}`,
productSource: {
slug: camelCasedResult.productSource,
},
};
};
export default processCourseSearchResult;

View File

@@ -10,5 +10,9 @@ export const EDUCATION_LEVEL_MAPPING = {
jhs: 'Introductory',
};
export const POPULAR = 'popular';
export const TRENDING = 'trending';
export const PERSONALIZED = 'personalized';
export const LEVEL_FACET = 'level';
export const PRODUCT_FACET = 'product';
export const PRODUCT_TYPE_COURSE = 'course';
export const MAX_RECOMMENDATIONS = 4;

View File

@@ -0,0 +1,77 @@
import { useEffect, useState } from 'react';
import algoliasearchHelper from 'algoliasearch-helper';
import {
getLocationRestrictionFilter,
initializeSearchClient,
} from '../../../data/algolia';
import isOneTrustFunctionalCookieEnabled from '../../../data/oneTrust';
import processCourseSearchResult from '../algoliaResultsParser';
import {
LEVEL_FACET, MAX_RECOMMENDATIONS, PRODUCT_FACET, PRODUCT_TYPE_COURSE,
} from '../constants';
const INDEX_NAME = process.env.ALGOLIA_AUTHN_RECOMMENDATIONS_INDEX;
/**
* This hooks returns Algolia recommendations only if functional cookies are enabled. * @param userCountry
* @param userCountry
* @param educationLevel
* @returns {{isLoading: boolean, recommendations: *[]}}
*/
const useAlgoliaRecommendations = (userCountry, educationLevel) => {
const functionalCookiesEnabled = isOneTrustFunctionalCookieEnabled();
const [recommendations, setRecommendations] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!functionalCookiesEnabled) {
setIsLoading(false);
return;
}
const getSearchFiltersQueryString = () => getLocationRestrictionFilter(userCountry);
const algoliaSearchParams = {
facets: [LEVEL_FACET, PRODUCT_FACET],
filters: getSearchFiltersQueryString(),
aroundLatLngViaIP: true,
};
const searchClient = initializeSearchClient();
const searchHelper = algoliasearchHelper(
searchClient,
INDEX_NAME,
algoliaSearchParams,
);
searchHelper.addFacetRefinement(PRODUCT_FACET, PRODUCT_TYPE_COURSE);
if (educationLevel) {
searchHelper.addFacetRefinement(LEVEL_FACET, educationLevel);
}
const searchIndex = () => {
searchHelper.search();
};
searchIndex();
searchHelper.on('result', ({ results }) => {
const parsedCourses = results.hits.slice(0, MAX_RECOMMENDATIONS).map(
(course) => processCourseSearchResult(course),
);
setRecommendations(parsedCourses);
setIsLoading(false);
});
searchHelper.on('error', () => setIsLoading(false));
}, [educationLevel, functionalCookiesEnabled, userCountry]);
return {
recommendations,
isLoading,
};
};
export default useAlgoliaRecommendations;

View File

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

View File

@@ -0,0 +1,36 @@
const placeholderCourse = {
activeCourseRun: {
key: 'course',
marketingUrl: '/',
type: 'Verified and Audit',
},
cardType: 'course',
image: {
src: './',
},
inProspectus: true,
objectID: 'skeleton',
owners: [{
key: 'skeleton',
logoImageUrl: './',
name: 'skeleton',
}],
position: 0,
prospectusPath: './',
queryID: 'skeleton',
recentEnrollmentCount: 0,
title: 'skeleton',
topics: [{
topic: 'skeleton',
}],
uuid: 'skeleton',
};
const loadingCoursesPlaceHolders = [
{ ...placeholderCourse, uuid: '294ea4rtn2117', courseType: 'course' },
{ ...placeholderCourse, uuid: '294fga4681117', courseType: 'course' },
{ ...placeholderCourse, uuid: '294ea4278e117', courseType: 'course' },
{ ...placeholderCourse, uuid: '294eax2rtg117', courseType: 'course' },
];
export default loadingCoursesPlaceHolders;

View File

@@ -1,22 +0,0 @@
import { camelCaseObject } from '@edx/frontend-platform';
import algoliasearch from 'algoliasearch/lite';
const INDEX_NAME = process.env.ALGOLIA_AUTHN_RECOMMENDATIONS_INDEX;
const getPersonalizedRecommendations = async (educationLevel) => {
const facetFilters = ['product:Course', 'availability:Available now'];
if (educationLevel) {
facetFilters.push(`level:${educationLevel}`);
}
const client = algoliasearch(process.env.ALGOLIA_APP_ID, process.env.ALGOLIA_SEARCH_KEY);
const index = client.initIndex(INDEX_NAME);
const { hits } = await index.search('', {
aroundLatLngViaIP: true,
facetFilters,
});
return camelCaseObject(hits);
};
export default getPersonalizedRecommendations;

View File

@@ -0,0 +1,56 @@
import { renderHook } from '@testing-library/react-hooks';
import algoliasearchHelper from 'algoliasearch-helper';
import mockedRecommendedProducts from './mockedData';
import CreateAlgoliaSearchHelperMock from './test_utils/test_utils';
import isOneTrustFunctionalCookieEnabled from '../../../data/oneTrust';
import useAlgoliaRecommendations from '../hooks/useAlgoliaRecommendations';
jest.mock('algoliasearch-helper');
jest.mock('../../../data/oneTrust');
jest.mock('../../../data/algolia', () => ({
initializeSearchClient: jest.fn(),
getLocationRestrictionFilter: jest.fn((countryCode) => `NOT BLOCKED IN ${countryCode}`),
}));
jest.mock('../algoliaResultsParser', () => jest.fn((course) => course));
describe('useAlgoliaRecommendations Tests', () => {
const MockSearchHelperWithData = new CreateAlgoliaSearchHelperMock(mockedRecommendedProducts);
const MockSearchHelperWithoutData = new CreateAlgoliaSearchHelperMock();
it('should fetch recommendations only if functional cookies are set', async () => {
isOneTrustFunctionalCookieEnabled.mockImplementation(() => true);
algoliasearchHelper.mockImplementation(() => MockSearchHelperWithData);
const { result } = renderHook(
() => useAlgoliaRecommendations('PK', 'Introductory'),
);
expect(result.current.recommendations).toEqual(mockedRecommendedProducts);
expect(result.current.isLoading).toBe(false);
});
it('should not fetch recommendations if functional cookies are not set', async () => {
isOneTrustFunctionalCookieEnabled.mockImplementation(() => false);
algoliasearchHelper.mockImplementation(() => MockSearchHelperWithData);
const { result } = renderHook(
() => useAlgoliaRecommendations('PK', 'Introductory'),
);
expect(result.current.recommendations).toEqual([]);
expect(result.current.isLoading).toBe(false);
});
it('should return empty list if no recommendations returned from Algolia', async () => {
isOneTrustFunctionalCookieEnabled.mockImplementation(() => true);
algoliasearchHelper.mockImplementation(() => MockSearchHelperWithoutData);
const { result } = renderHook(
() => useAlgoliaRecommendations('PK', 'Introductory'),
);
expect(result.current.recommendations).toEqual([]);
expect(result.current.isLoading).toBe(false);
});
});

View File

@@ -0,0 +1,88 @@
const mockedRecommendedProducts = [
{
activeCourseRun: {
key: 'course-v1:TEST_COURSE_RUN',
type: 'test_course_run_type',
marketingUrl: 'test_marketingUrl',
},
allowedIn: [],
blockedIn: [],
cardType: 'course',
courseType: 'course',
image: {
src: 'test_src',
},
owners: [],
title: 'test_title',
uuid: 'test_uuid',
objectID: 'course-test_uuid',
productSource: {
slug: 'test_source',
},
},
{
activeCourseRun: {
key: 'course-v1:TEST_COURSE_RUN',
type: 'test_course_run_type',
marketingUrl: 'test_marketingUrl',
},
allowedIn: [],
blockedIn: [],
cardType: 'course',
courseType: 'course',
image: {
src: 'test_src',
},
owners: [],
title: 'test_title',
uuid: 'test_uuid2',
objectID: 'course-test_uuid',
productSource: {
slug: 'test_source',
},
},
{
activeCourseRun: {
key: 'course-v1:TEST_COURSE_RUN',
type: 'test_course_run_type',
marketingUrl: 'test_marketingUrl',
},
allowedIn: [],
blockedIn: [],
cardType: 'course',
courseType: 'course',
image: {
src: 'test_src',
},
owners: [],
title: 'test_title',
uuid: 'test_uuid3',
objectID: 'course-test_uuid',
productSource: {
slug: 'test_source',
},
},
{
activeCourseRun: {
key: 'course-v1:TEST_COURSE_RUN',
type: 'test_course_run_type',
marketingUrl: 'test_marketingUrl',
},
allowedIn: [],
blockedIn: [],
cardType: 'course',
courseType: 'course',
image: {
src: 'test_src',
},
owners: [],
title: 'test_title',
uuid: 'test_uuid4',
objectID: 'course-test_uuid',
productSource: {
slug: 'test_source',
},
},
];
export default mockedRecommendedProducts;

View File

@@ -0,0 +1,26 @@
import mockedRecommendedProducts from './mockedData';
import processCourseSearchResult from '../algoliaResultsParser';
describe('AlgoliaResultsParserTests', () => {
const dataToBeProcessed = {
activeRunKey: 'course-v1:TEST_COURSE_RUN',
activeRunType: 'test_course_run_type',
marketingUrl: 'test_marketingUrl',
minEffort: 1,
maxEffort: 2,
weeksToComplete: 3,
allowedIn: [],
blockedIn: [],
cardImageUrl: 'test_src',
owners: [],
title: 'test_title',
uuid: 'test_uuid',
recentEnrollmentCount: 1,
productSource: 'test_source',
};
it('should parse results returned by Algolia', async () => {
const parsedData = processCourseSearchResult(dataToBeProcessed);
expect(parsedData).toEqual(mockedRecommendedProducts[0]);
});
});

View File

@@ -0,0 +1,63 @@
// Algolia Search Helper mock data
class CreateAlgoliaSearchHelperMock {
hits = [];
eventCb = () => {};
derived = [];
maxLimit = 0;
refineProp = null;
refineVal = null;
constructor(mockData = [], limit = 0) {
this.hits = mockData;
this.maxLimit = limit;
}
onMock(eventName, callback) {
if (eventName === 'result') {
const hitData = this.hits.slice(0, this.maxLimit > 0 ? this.maxLimit : this.hits.length);
callback({
results: {
hits: hitData,
},
});
} else if (eventName === 'error') {
callback({});
}
}
setQueryMock = () => {};
searchMock = () => {
this.eventCb();
};
clearRefinementsMock = () => this;
refinementMock(refineBy, value) {
// addDisjunctiveFacetRefinement // addFacetRefinement
this.refineProp = refineBy;
this.refineVal = value;
return this;
}
on = this.onMock;
setQuery = this.setQueryMock;
search = this.searchMock;
derive = this.deriveMock;
clearRefinements = this.clearRefinementsMock;
addDisjunctiveFacetRefinement = this.refinementMock;
addFacetRefinement = this.refinementMock;
}
export default CreateAlgoliaSearchHelperMock;

View File

@@ -18,7 +18,7 @@ const messages = defineMessages({
},
'recommendation.option.trending': {
id: 'recommendation.option.trending',
defaultMessage: 'Trending',
defaultMessage: 'Trending Now',
description: 'Title for trending products',
},
'recommendation.option.popular': {
@@ -26,6 +26,11 @@ const messages = defineMessages({
defaultMessage: 'Most Popular',
description: 'Title for popular products',
},
'recommendation.option.recommended.for.you': {
id: 'recommendation.option.recommended.for.you',
defaultMessage: 'Recommended For You',
description: 'Title for personalized products',
},
});
export const cardBadgesMessages = defineMessages({

View File

@@ -1,42 +0,0 @@
import optimizelyInstance from '../data/optimizely';
const RECOMMENDATIONS_EXP_KEY = 'popular_and_trending_recommendations_exp';
const RECOMMENDATIONS_EXP_VARIATION = 'popular_and_trending_recommendations';
export const eventNames = {
recommendedCourseClicked: 'welcome_page_recommendation_card_click',
recommendationsViewed: 'welcome_page_recommendations_viewed',
};
/**
* Activate the post registration recommendations optimizely experiment
* and return the true if the user is in variation else false.
* @param {String} userId user id of authenticated user.
* @return {string} variation the user belong in
*/
const activateRecommendationsExperiment = (userId) => optimizelyInstance?.activate(RECOMMENDATIONS_EXP_KEY, userId);
/**
* Fire an optimizely track event for post registration recommended course card clicked.
* @param {String} userId user id of authenticated user.
* @param {Object} userAttributes Dictionary of user attributes (optional).
*/
const trackRecommendationCardClickOptimizely = (userId, userAttributes = {}) => {
optimizelyInstance?.track(eventNames.recommendedCourseClicked, userId, userAttributes);
};
/**
* Fire an optimizely track event for post registration recommendation viewed.
* @param {String} userId user id of authenticated user.
* @param {Object} userAttributes Dictionary of user attributes (optional).
*/
const trackRecommendationViewedOptimizely = (userId, userAttributes = {}) => {
optimizelyInstance?.track(eventNames.recommendationsViewed, userId, userAttributes);
};
export {
RECOMMENDATIONS_EXP_VARIATION,
activateRecommendationsExperiment,
trackRecommendationCardClickOptimizely,
trackRecommendationViewedOptimizely,
};

View File

@@ -1,14 +1,20 @@
import React from 'react';
import { Provider } from 'react-redux';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { useMediaQuery } from '@edx/paragon';
import { mount } from 'enzyme';
import { useLocation } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import { DEFAULT_REDIRECT_URL } from '../../data/constants';
import { PERSONALIZED } from '../data/constants';
import useAlgoliaRecommendations from '../data/hooks/useAlgoliaRecommendations';
import mockedRecommendedProducts from '../data/tests/mockedData';
import RecommendationsPage from '../RecommendationsPage';
import { eventNames, getProductMapping } from '../track';
const IntlRecommendationsPage = injectIntl(RecommendationsPage);
const mockStore = configureStore();
@@ -16,22 +22,20 @@ const mockStore = configureStore();
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
jest.mock('../data/service', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
describe('RecommendationsPageTests', () => {
mergeConfig({
GENERAL_RECOMMENDATIONS: '[]',
POPULAR_PRODUCTS: '[]',
TRENDING_PRODUCTS: '[]',
});
jest.mock('@edx/paragon', () => ({
...jest.requireActual('@edx/paragon'),
useMediaQuery: jest.fn(),
}));
jest.mock('../data/hooks/useAlgoliaRecommendations', () => jest.fn());
describe('RecommendationsPageTests', () => {
let store = {};
const dashboardUrl = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
@@ -65,6 +69,11 @@ describe('RecommendationsPageTests', () => {
useLocation.mockReturnValue({
state: {},
});
useAlgoliaRecommendations.mockReturnValue({
recommendations: mockedRecommendedProducts,
isLoading: false,
});
});
it('should redirect to dashboard if user is not coming from registration workflow', () => {
@@ -72,10 +81,13 @@ describe('RecommendationsPageTests', () => {
expect(window.location.href).toEqual(dashboardUrl);
});
it('should redirect if either popular or trending recommendations are not configured', () => {
mockUseLocation();
it('should redirect user if no personalized recommendations are available', () => {
useAlgoliaRecommendations.mockReturnValue({
recommendations: [],
isLoading: false,
});
mount(reduxWrapper(<IntlRecommendationsPage />));
expect(window.location.href).toEqual(redirectUrl);
expect(window.location.href).toEqual(dashboardUrl);
});
it('should redirect user if they click "Skip for now" button', () => {
@@ -86,9 +98,67 @@ describe('RecommendationsPageTests', () => {
expect(window.location.href).toEqual(redirectUrl);
});
it('displays popular products as default recommendations', () => {
it('should display recommendations small layout for small screen', () => {
mockUseLocation();
useMediaQuery.mockReturnValue(true);
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
expect(recommendationsPage.find('.nav-link .active a').text()).toEqual('Most Popular');
expect(recommendationsPage.find('#recommendations-small-layout').exists()).toBeTruthy();
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeFalsy();
});
it('should display recommendations large layout for large screen', () => {
mockUseLocation();
useMediaQuery.mockReturnValue(false);
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
expect(recommendationsPage.find('.pgn_collapsible').exists()).toBeFalsy();
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeFalsy();
});
it('should display skeletons if recommendations are loading for large screen', () => {
mockUseLocation();
useMediaQuery.mockReturnValue(false);
useAlgoliaRecommendations.mockReturnValueOnce({
recommendations: [],
isLoading: true,
});
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeTruthy();
});
it('should display skeletons if recommendations are loading for small screen', () => {
mockUseLocation();
useMediaQuery.mockReturnValue(true);
useAlgoliaRecommendations.mockReturnValueOnce({
recommendations: [],
isLoading: true,
});
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage />));
expect(recommendationsPage.find('.react-loading-skeleton').exists()).toBeTruthy();
});
it('should fire recommendations viewed event', () => {
mockUseLocation();
useAlgoliaRecommendations.mockReturnValue({
recommendations: mockedRecommendedProducts,
isLoading: false,
});
useMediaQuery.mockReturnValue(false);
mount(reduxWrapper(<IntlRecommendationsPage />));
expect(sendTrackEvent).toBeCalled();
expect(sendTrackEvent).toHaveBeenCalledWith(
eventNames.recommendationsViewed,
{
page: 'authn_recommendations',
recommendation_type: PERSONALIZED,
products: getProductMapping(mockedRecommendedProducts),
user_id: 111,
},
);
});
});

View File

@@ -0,0 +1,131 @@
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
eventNames, trackRecommendationClick, trackRecommendationsViewed, trackSkipButtonClicked,
} from '../track';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
describe('SegmentEventTrackingTest', () => {
global.open = jest.fn();
const userId = 1;
it('test click event is fired properly and correct link opens for program product type', async () => {
jest.useFakeTimers();
const program = {
cardType: 'program',
title: 'test program',
uuid: 'test_uuid',
productSource: {
name: 'test_source',
},
recommendationType: 'static',
url: 'test_url',
};
const position = 0;
trackRecommendationClick(program, position, userId);
jest.advanceTimersByTime(300);
expect(sendTrackEvent).toBeCalled();
expect(sendTrackEvent).toHaveBeenCalledWith(
eventNames.recommendedProductClicked,
{
page: 'authn_recommendations',
position,
product_key: `${program.title} [${program.uuid}]`,
product_line: program.cardType,
product_source: program.productSource.name,
recommendation_type: program.recommendationType,
user_id: userId,
},
);
expect(global.open).toBeCalled();
expect(global.open).toHaveBeenCalledWith(program.url, '_blank');
});
it('test click event is fired properly and correct link opens for course product type', async () => {
jest.useFakeTimers();
const course = {
cardType: 'course',
activeRunKey: 'test_key',
productSource: {
name: 'test_source',
},
recommendationType: 'static',
activeCourseRun: {
marketingUrl: 'test_url',
},
};
const position = 0;
trackRecommendationClick(course, position, userId);
jest.advanceTimersByTime(300);
expect(sendTrackEvent).toBeCalled();
expect(sendTrackEvent).toHaveBeenCalledWith(
eventNames.recommendedProductClicked,
{
page: 'authn_recommendations',
position,
product_key: course.activeRunKey,
product_line: course.cardType,
product_source: course.productSource.name,
recommendation_type: course.recommendationType,
user_id: userId,
},
);
expect(global.open).toBeCalled();
expect(global.open).toHaveBeenCalledWith(course.activeCourseRun.marketingUrl, '_blank');
});
it('test viewed events are fired properly', () => {
const productList = [
{
title: 'Test Program',
uuid: '1234-5678-9101-1213',
cardType: 'program',
productSource: {
name: 'org name',
},
},
];
const recommendationsType = 'static';
const expectedProductList = [
{
product_key: 'Test Program [1234-5678-9101-1213]',
product_line: 'program',
product_source: 'org name',
},
];
trackRecommendationsViewed(productList, recommendationsType, userId);
expect(sendTrackEvent).toBeCalled();
expect(sendTrackEvent).toHaveBeenCalledWith(
eventNames.recommendationsViewed,
{
page: 'authn_recommendations',
products: expectedProductList,
recommendation_type: recommendationsType,
user_id: userId,
},
);
});
it('test skip button event is fired with correct properties', () => {
trackSkipButtonClicked(userId);
expect(sendTrackEvent).toBeCalled();
expect(sendTrackEvent).toHaveBeenCalledWith(
eventNames.skipButtonClicked,
{
page: 'authn_recommendations',
user_id: userId,
},
);
});
});

View File

@@ -4,25 +4,21 @@ export const LINK_TIMEOUT = 300;
export const eventNames = {
recommendedProductClicked: 'edx.bi.user.recommended.product.clicked',
recommendationsGroup: 'edx.bi.user.recommendations.group',
recommendationsViewed: 'edx.bi.user.recommendations.viewed',
skipButtonClicked: 'edx.bi.user.recommendations.skip.btn.clicked',
};
export const createLinkTracker = (tracker, href, openInNewTab = false) => (e) => {
e.preventDefault();
tracker();
if (openInNewTab) {
return setTimeout(() => { global.open(href, '_blank'); }, LINK_TIMEOUT);
}
return setTimeout(() => { global.location.href = href; }, LINK_TIMEOUT);
};
const generateProductKey = (product) => (
product.cardType === 'program' ? `${product.title} [${product.uuid}]` : product.activeRunKey
);
const generateProductKey = (product) => {
const productKey = product.cardType === 'program' ? `${product.title} [${product.uuid}]` : product.activeRunKey;
return productKey;
};
export const getProductMapping = (recommendedProducts) => recommendedProducts.map((product) => ({
product_key: generateProductKey(product),
product_line: product.cardType,
product_source: product.productSource.name,
}));
export const trackRecommendationClick = (product, position, isControl, userId) => {
export const trackRecommendationClick = (product, position, userId) => {
sendTrackEvent(eventNames.recommendedProductClicked, {
page: 'authn_recommendations',
position,
@@ -30,33 +26,31 @@ export const trackRecommendationClick = (product, position, isControl, userId) =
product_key: generateProductKey(product),
product_line: product.cardType,
product_source: product.productSource.name,
is_control: isControl,
user_id: userId,
});
return setTimeout(() => { global.open(product.url, '_blank'); }, LINK_TIMEOUT);
const productUrl = product.url || product?.activeCourseRun?.marketingUrl;
return setTimeout(() => { global.open(productUrl, '_blank'); }, LINK_TIMEOUT);
};
export const trackRecommendationsViewed = (recommendedProducts, type, isControl, userId) => {
const viewedProductsList = recommendedProducts.map((product) => ({
product_key: generateProductKey(product),
product_line: product.cardType,
product_source: product.productSource.name,
}));
sendTrackEvent(
eventNames.recommendationsViewed, {
page: 'authn_recommendations',
recommendation_type: type,
products: viewedProductsList,
is_control: isControl,
user_id: userId,
},
);
export const trackRecommendationsViewed = (recommendedProducts, type, userId) => {
const viewedProductsList = getProductMapping(recommendedProducts);
if (viewedProductsList && viewedProductsList.length) {
sendTrackEvent(
eventNames.recommendationsViewed, {
page: 'authn_recommendations',
recommendation_type: type,
products: viewedProductsList,
user_id: userId,
},
);
}
};
export const trackRecommendationsGroup = (variation, userId) => {
export const trackSkipButtonClicked = (userId) => {
sendTrackEvent(
eventNames.recommendationsGroup, {
variation,
eventNames.skipButtonClicked, {
page: 'authn_recommendations',
user_id: userId,
},
@@ -65,6 +59,6 @@ export const trackRecommendationsGroup = (variation, userId) => {
export default {
trackRecommendationClick,
trackRecommendationsGroup,
trackRecommendationsViewed,
trackSkipButtonClicked,
};

View File

@@ -1,78 +1,31 @@
.nav-tabs {
border-bottom: 2px solid transparent;
}
$card-gap: 24px;
.recommendations-container__card-list {
padding-left: 0.0625rem;
padding-bottom: 0.125rem;
gap: $card-gap $card-gap;
@include media-breakpoint-down(xl) {
overflow-x: scroll;
overflow-y: hidden;
@include media-breakpoint-down(sm) {
margin-bottom: 0 !important;
}
.recommendation-card {
flex: 0 1 100%;
cursor: pointer;
@include media-breakpoint-up(sm) {
flex: 0 1 calc(50% - #{$card-gap - 12});
}
@include media-breakpoint-up(md) {
flex: 0 1 calc(33.333% - #{$card-gap - 8});
}
@include media-breakpoint-up(lg) {
flex: 0 1 calc(25% - #{$card-gap - 6});
}
}
}
.recommendations-container__heading {
overflow-wrap: break-word;
}
.recommendations-container {
padding: 0 1rem;
margin: 0 0 1.875rem 0;
@include media-breakpoint-up(lg) {
max-width: $max-width-sm + 5 * $grid-gutter-width !important;
}
@include media-breakpoint-up(xl) {
max-width: $max-width-md + $grid-gutter-width !important;
}
@include media-breakpoint-up(xxl) {
max-width: $max-width-lg + $grid-gutter-width !important;
}
}
.recommendation-card {
cursor: pointer;
.pgn__hyperlink {
display: block;
}
.pgn__card {
width: 281px;
height: 332px;
margin: 0 !important;
}
.pgn__card-image-cap {
height: 6.5rem;
object-position: top;
}
.pgn__card-header-title-md {
font-weight: 700;
font-size: 1.125rem;
line-height: 1.5rem;
}
.pgn__card-header-subtitle-md{
font-weight: 400;
font-size:0.875rem;
line-height: 1.5rem;
color: $gray-700;
}
.pgn__card-footer {
bottom: 0;
position: absolute;
padding-bottom: 1rem !important;
}
.pgn__card__footer-text{
font-weight: 400;
font-size: 0.75rem;
line-height: 1.25rem;
}
}
.footer-icon{
height: 16px;
width: 16px;
}