From e7d9255fe50b2a70446faa709108bd1238a0e089 Mon Sep 17 00:00:00 2001 From: Jody Bailey <110463597+JodyBaileyy@users.noreply.github.com> Date: Fri, 30 Jun 2023 12:40:59 +0200 Subject: [PATCH] fix: initial optimizely and segment events (#170) --- .env | 1 + .env.development | 1 + .env.test | 1 + package-lock.json | 151 ++++++++++++++++++ package.json | 1 + .../__snapshots__/index.test.jsx.snap | 18 ++- .../components/ProductCard.test.jsx | 2 +- .../components/ProductCardContainer.jsx | 2 +- .../__snapshots__/LoadedView.test.jsx.snap | 12 +- .../__snapshots__/ProductCard.test.jsx.snap | 2 +- .../ProductCardContainer.test.jsx.snap | 12 +- src/widgets/ProductRecommendations/hooks.js | 10 +- .../ProductRecommendations/hooks.test.js | 18 +++ src/widgets/ProductRecommendations/index.jsx | 10 +- .../ProductRecommendations/index.test.jsx | 34 ++-- .../ProductRecommendations/optimizely.js | 9 ++ .../ProductRecommendations/optimizely.test.js | 13 ++ .../optimizelyExperiment.js | 38 +++++ .../optimizelyExperiment.test.js | 79 +++++++++ .../ProductRecommendations/testData.js | 5 +- src/widgets/ProductRecommendations/track.js | 55 +++++++ .../ProductRecommendations/track.test.js | 98 ++++++++++++ src/widgets/ProductRecommendations/utils.js | 17 ++ 23 files changed, 542 insertions(+), 47 deletions(-) create mode 100644 src/widgets/ProductRecommendations/optimizely.js create mode 100644 src/widgets/ProductRecommendations/optimizely.test.js create mode 100644 src/widgets/ProductRecommendations/optimizelyExperiment.js create mode 100644 src/widgets/ProductRecommendations/optimizelyExperiment.test.js create mode 100644 src/widgets/ProductRecommendations/track.js create mode 100644 src/widgets/ProductRecommendations/track.test.js diff --git a/.env b/.env index 603b66a..cefce3e 100644 --- a/.env +++ b/.env @@ -40,3 +40,4 @@ ACCOUNT_SETTINGS_URL='' ACCOUNT_PROFILE_URL='' ENABLE_NOTICES='' CAREER_LINK_URL='' +OPTIMIZELY_FULL_STACK_SDK_KEY='' diff --git a/.env.development b/.env.development index 83442cb..78e4c40 100644 --- a/.env.development +++ b/.env.development @@ -47,3 +47,4 @@ ACCOUNT_SETTINGS_URL='http://localhost:1997' ACCOUNT_PROFILE_URL='http://localhost:1995' ENABLE_NOTICES='' CAREER_LINK_URL='' +OPTIMIZELY_FULL_STACK_SDK_KEY='' diff --git a/.env.test b/.env.test index 9314c0a..c88e5fc 100644 --- a/.env.test +++ b/.env.test @@ -46,3 +46,4 @@ ACCOUNT_SETTINGS_URL='http://account-settings-url.test' ACCOUNT_PROFILE_URL='http://account-profile-url.test' ENABLE_NOTICES='' CAREER_LINK_URL='' +OPTIMIZELY_FULL_STACK_SDK_KEY='SDK Key' diff --git a/package-lock.json b/package-lock.json index eb68b94..142053b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/react-fontawesome": "^0.1.15", + "@optimizely/react-sdk": "^2.9.2", "@redux-beacon/segment": "^1.1.0", "@reduxjs/toolkit": "^1.6.1", "@testing-library/user-event": "^13.5.0", @@ -4370,6 +4371,135 @@ "@octokit/openapi-types": "^17.2.0" } }, + "node_modules/@optimizely/js-sdk-logging": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-logging/-/js-sdk-logging-0.3.1.tgz", + "integrity": "sha512-K71Jf283FP0E4oXehcXTTM3gvgHZHr7FUrIsw//0mdJlotHJT4Nss4hE0CWPbBxO7LJAtwNnO+VIA/YOcO4vHg==", + "dependencies": { + "@optimizely/js-sdk-utils": "^0.4.0" + } + }, + "node_modules/@optimizely/js-sdk-utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-utils/-/js-sdk-utils-0.4.0.tgz", + "integrity": "sha512-QG2oytnITW+VKTJK+l0RxjaS5VrA6W+AZMzpeg4LCB4Rn4BEKtF+EcW/5S1fBDLAviGq/0TLpkjM3DlFkJ9/Gw==", + "dependencies": { + "uuid": "^3.3.2" + } + }, + "node_modules/@optimizely/js-sdk-utils/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/@optimizely/optimizely-sdk": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@optimizely/optimizely-sdk/-/optimizely-sdk-4.9.4.tgz", + "integrity": "sha512-aYxndR6RahnLdX7SQR1YO2dklfNjbCGUUvRaYJZ50LIsDqhkAu426vxHwYO+V+QJxqipypPG5SVdG1m32AgDvw==", + "dependencies": { + "@optimizely/js-sdk-datafile-manager": "^0.9.5", + "@optimizely/js-sdk-event-processor": "^0.9.2", + "@optimizely/js-sdk-logging": "^0.3.1", + "json-schema": "^0.4.0", + "murmurhash": "0.0.2", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@optimizely/optimizely-sdk/node_modules/@optimizely/js-sdk-datafile-manager": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-datafile-manager/-/js-sdk-datafile-manager-0.9.5.tgz", + "integrity": "sha512-O4ujr1nBBAQBtx8YoKNpzzaEZgsE+aU4dxubT17ePqv/YVUWE+JOY21tSRrqZy/BlbbyzL+ElT8hrGB5ZzVoIQ==", + "dependencies": { + "@optimizely/js-sdk-logging": "^0.3.1", + "@optimizely/js-sdk-utils": "^0.4.0", + "decompress-response": "^4.2.1" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "^1.2.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@optimizely/optimizely-sdk/node_modules/@optimizely/js-sdk-event-processor": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-event-processor/-/js-sdk-event-processor-0.9.5.tgz", + "integrity": "sha512-g5zqAjJuexxgbNvn7dacFkQXQxH3+OtjELfmSswvhxP9EHkyNR0ZdQF/kBxFxr335F2/RRPvAJ9tQBPkwaBg8g==", + "dependencies": { + "@optimizely/js-sdk-logging": "^0.3.1", + "@optimizely/js-sdk-utils": "^0.4.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "^1.2.0", + "@react-native-community/netinfo": "5.9.4" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + }, + "@react-native-community/netinfo": { + "optional": true + } + } + }, + "node_modules/@optimizely/optimizely-sdk/node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@optimizely/optimizely-sdk/node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@optimizely/optimizely-sdk/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/@optimizely/react-sdk": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@optimizely/react-sdk/-/react-sdk-2.9.2.tgz", + "integrity": "sha512-//OozC59dr5Lsss2H9Jnyb35FMTF8Z+CMFi89kVs1U1Fy1sKOXK7Web1hw18DBZctwKfbb8Sl+Yw7Pgmo3P2fA==", + "dependencies": { + "@optimizely/js-sdk-logging": "^0.3.1", + "@optimizely/optimizely-sdk": "^4.9.1", + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.6.2", + "utility-types": "^2.1.0 || ^3.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", @@ -16520,6 +16650,11 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -17573,6 +17708,14 @@ "multicast-dns": "cli.js" } }, + "node_modules/murmurhash": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/murmurhash/-/murmurhash-0.0.2.tgz", + "integrity": "sha512-LKlwdZKWzvCQpMszb2HO5leJ7P9T4m5XuDKku8bM0uElrzqK9cn0+iozwQS8jO4SNjrp4w7olalgd8WgsIjhWA==", + "engines": { + "node": "*" + } + }, "node_modules/nanoid": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", @@ -27075,6 +27218,14 @@ "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", "dev": true }, + "node_modules/utility-types": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.10.0.tgz", + "integrity": "sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==", + "engines": { + "node": ">= 4" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 1244e07..4e4ffa6 100755 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/react-fontawesome": "^0.1.15", + "@optimizely/react-sdk": "^2.9.2", "@redux-beacon/segment": "^1.1.0", "@reduxjs/toolkit": "^1.6.1", "@testing-library/user-event": "^13.5.0", diff --git a/src/widgets/ProductRecommendations/__snapshots__/index.test.jsx.snap b/src/widgets/ProductRecommendations/__snapshots__/index.test.jsx.snap index 0a2c156..ed66322 100644 --- a/src/widgets/ProductRecommendations/__snapshots__/index.test.jsx.snap +++ b/src/widgets/ProductRecommendations/__snapshots__/index.test.jsx.snap @@ -5,10 +5,12 @@ exports[`ProductRecommendations matches snapshot 1`] = ` crossProductCourses={ Array [ Object { + "courseRunKey": "course-v1:Test+Course+2022T2", "courseType": "executive-education-2u", "image": Object { "src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg", }, + "marketingUrl": "https://www.edx.org/course/some-course?utm_source=source", "owners": Array [ Object { "key": "HarvardX", @@ -16,14 +18,15 @@ exports[`ProductRecommendations matches snapshot 1`] = ` "name": "Harvard University", }, ], - "prospectusPath": "course/introduction-to-computer-sceince", "title": "Introduction to Computer Science", }, Object { + "courseRunKey": "course-v1:Test+Course+2022T2", "courseType": "bootcamp-2u", "image": Object { "src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg", }, + "marketingUrl": "https://www.edx.org/course/some-course?utm_source=source", "owners": Array [ Object { "key": "HarvardX", @@ -31,7 +34,6 @@ exports[`ProductRecommendations matches snapshot 1`] = ` "name": "Harvard University", }, ], - "prospectusPath": "course/introduction-to-computer-sceince", "title": "Introduction to Computer Science", }, ] @@ -39,10 +41,12 @@ exports[`ProductRecommendations matches snapshot 1`] = ` openCourses={ Array [ Object { + "courseRunKey": "course-v1:Test+Course+2022T2", "courseType": "verified-audit", "image": Object { "src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg", }, + "marketingUrl": "https://www.edx.org/course/some-course?utm_source=source", "owners": Array [ Object { "key": "HarvardX", @@ -50,14 +54,15 @@ exports[`ProductRecommendations matches snapshot 1`] = ` "name": "Harvard University", }, ], - "prospectusPath": "course/introduction-to-computer-sceince", "title": "Introduction to Computer Science", }, Object { + "courseRunKey": "course-v1:Test+Course+2022T2", "courseType": "audit", "image": Object { "src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg", }, + "marketingUrl": "https://www.edx.org/course/some-course?utm_source=source", "owners": Array [ Object { "key": "HarvardX", @@ -65,14 +70,15 @@ exports[`ProductRecommendations matches snapshot 1`] = ` "name": "Harvard University", }, ], - "prospectusPath": "course/introduction-to-computer-sceince", "title": "Introduction to Computer Science", }, Object { + "courseRunKey": "course-v1:Test+Course+2022T2", "courseType": "verified", "image": Object { "src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg", }, + "marketingUrl": "https://www.edx.org/course/some-course?utm_source=source", "owners": Array [ Object { "key": "HarvardX", @@ -80,14 +86,15 @@ exports[`ProductRecommendations matches snapshot 1`] = ` "name": "Harvard University", }, ], - "prospectusPath": "course/introduction-to-computer-sceince", "title": "Introduction to Computer Science", }, Object { + "courseRunKey": "course-v1:Test+Course+2022T2", "courseType": "course", "image": Object { "src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg", }, + "marketingUrl": "https://www.edx.org/course/some-course?utm_source=source", "owners": Array [ Object { "key": "HarvardX", @@ -95,7 +102,6 @@ exports[`ProductRecommendations matches snapshot 1`] = ` "name": "Harvard University", }, ], - "prospectusPath": "course/introduction-to-computer-sceince", "title": "Introduction to Computer Science", }, ] diff --git a/src/widgets/ProductRecommendations/components/ProductCard.test.jsx b/src/widgets/ProductRecommendations/components/ProductCard.test.jsx index ba643a5..aeba253 100644 --- a/src/widgets/ProductRecommendations/components/ProductCard.test.jsx +++ b/src/widgets/ProductRecommendations/components/ProductCard.test.jsx @@ -20,7 +20,7 @@ describe('ProductRecommendations ProductCard', () => { headerImage, schoolLogo, courseType: courseTypeToProductTypeMap[course.courseType], - url: `https://www.edx.org/${course.prospectusPath}`, + url: `${course.marketingUrl}&linked_from=recommender`, }; it('matches snapshot', () => { diff --git a/src/widgets/ProductRecommendations/components/ProductCardContainer.jsx b/src/widgets/ProductRecommendations/components/ProductCardContainer.jsx index e9ab225..38bec38 100644 --- a/src/widgets/ProductRecommendations/components/ProductCardContainer.jsx +++ b/src/widgets/ProductRecommendations/components/ProductCardContainer.jsx @@ -22,7 +22,7 @@ const ProductCardContainer = ({ finalProductList, courseTypes }) => ( .map((item) => ( @@ -40,7 +40,7 @@ exports[`ProductRecommendations ProductCardContainer matches snapshot 1`] = ` schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png" subtitle="Harvard University" title="Introduction to Computer Science" - url="https://www.edx.org/course/introduction-to-computer-sceince" + url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender" /> @@ -60,7 +60,7 @@ exports[`ProductRecommendations ProductCardContainer matches snapshot 1`] = ` schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png" subtitle="Harvard University" title="Introduction to Computer Science" - url="https://www.edx.org/course/introduction-to-computer-sceince" + url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender" /> diff --git a/src/widgets/ProductRecommendations/hooks.js b/src/widgets/ProductRecommendations/hooks.js index 61b3cff..3d66b38 100644 --- a/src/widgets/ProductRecommendations/hooks.js +++ b/src/widgets/ProductRecommendations/hooks.js @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react'; - import { RequestStates, RequestKeys } from 'data/constants/requests'; import { StrictDict } from 'utils'; import { reduxHooks } from 'hooks'; import { SortKeys } from 'data/constants/app'; +import { useWindowSize, breakpoints } from '@edx/paragon'; import api from './api'; import * as module from './hooks'; @@ -12,11 +12,15 @@ export const state = StrictDict({ data: (val) => useState(val), // eslint-disable-line }); +export const useIsMobile = () => { + const { width } = useWindowSize(); + return width < breakpoints.small.minWidth; +}; + export const useShowRecommendationsFooter = () => { const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards(); const hasRequestCompleted = reduxHooks.useRequestIsCompleted(RequestKeys.initialize); - // Hardcoded to not show until experiment related code is implemented return { shouldShowFooter: false, shouldLoadFooter: hasRequestCompleted && !hasAvailableDashboards, @@ -83,4 +87,4 @@ export const useProductRecommendationsData = () => { }; }; -export default { useProductRecommendationsData, useShowRecommendationsFooter }; +export default { useProductRecommendationsData, useShowRecommendationsFooter, useIsMobile }; diff --git a/src/widgets/ProductRecommendations/hooks.test.js b/src/widgets/ProductRecommendations/hooks.test.js index 8ed332d..dc4d497 100644 --- a/src/widgets/ProductRecommendations/hooks.test.js +++ b/src/widgets/ProductRecommendations/hooks.test.js @@ -4,6 +4,7 @@ import { waitFor } from '@testing-library/react'; import { MockUseState } from 'testUtils'; import { RequestStates } from 'data/constants/requests'; import { reduxHooks } from 'hooks'; +import { useWindowSize } from '@edx/paragon'; import { wait } from './utils'; import api from './api'; @@ -67,6 +68,23 @@ describe('ProductRecommendations hooks', () => { }); }); + describe('useIsMobile', () => { + it('returns false if the width of the window is greater than or equal to 576px', () => { + useWindowSize + .mockReturnValueOnce({ width: 576, height: 943 }) + .mockReturnValueOnce({ width: 1400, height: 943 }); + + expect(hooks.useIsMobile()).toBeFalsy(); + expect(hooks.useIsMobile()).toBeFalsy(); + }); + + it('returns true if the width of the window is less than 576px', () => { + useWindowSize.mockReturnValueOnce({ width: 575, height: 943 }); + + expect(hooks.useIsMobile()).toBeTruthy(); + }); + }); + describe('useShowRecommendationsFooter', () => { // TODO: Update when hardcoded value is removed it('returns whether the footer widget should show and should load', () => { diff --git a/src/widgets/ProductRecommendations/index.jsx b/src/widgets/ProductRecommendations/index.jsx index 67ea88c..f3eb316 100644 --- a/src/widgets/ProductRecommendations/index.jsx +++ b/src/widgets/ProductRecommendations/index.jsx @@ -1,11 +1,10 @@ import React from 'react'; -import { reduxHooks } from 'hooks'; import './index.scss'; -import { useWindowSize, breakpoints } from '@edx/paragon'; +import { reduxHooks } from 'hooks'; import NoCoursesView from 'containers/CourseList/NoCoursesView'; import LoadingView from './components/LoadingView'; import LoadedView from './components/LoadedView'; -import { useProductRecommendationsData } from './hooks'; +import hooks from './hooks'; const ProductRecommendations = () => { const checkEmptyResponse = (obj) => { @@ -14,9 +13,8 @@ const ProductRecommendations = () => { return result.length === values.length; }; - const { productRecommendations, isLoading, isLoaded } = useProductRecommendationsData(); - const { width } = useWindowSize(); - const isMobile = width < breakpoints.small.minWidth; + const { productRecommendations, isLoading, isLoaded } = hooks.useProductRecommendationsData(); + const isMobile = hooks.useIsMobile(); const hasCourses = reduxHooks.useHasCourses(); const shouldShowPlaceholder = checkEmptyResponse(productRecommendations); diff --git a/src/widgets/ProductRecommendations/index.test.jsx b/src/widgets/ProductRecommendations/index.test.jsx index c7c7db2..2e05df3 100644 --- a/src/widgets/ProductRecommendations/index.test.jsx +++ b/src/widgets/ProductRecommendations/index.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { useWindowSize } from '@edx/paragon'; +import { reduxHooks } from 'hooks'; import hooks from './hooks'; import ProductRecommendations from './index'; import LoadingView from './components/LoadingView'; @@ -10,7 +10,13 @@ import { mockCrossProductResponse, mockAmplitudeResponse } from './testData'; jest.mock('./hooks', () => ({ useProductRecommendationsData: jest.fn(), - useHasCourses: jest.fn(), + useIsMobile: jest.fn(), +})); + +jest.mock('hooks', () => ({ + reduxHooks: { + useHasCourses: jest.fn(), + }, })); jest.mock('./components/LoadingView', () => 'LoadingView'); @@ -31,13 +37,10 @@ describe('ProductRecommendations', () => { productRecommendations: mockCrossProductResponse, }; - const desktopWindowSize = { - width: 1400, - height: 943, - }; + beforeEach(() => reduxHooks.useHasCourses.mockReturnValueOnce(true)); it('matches snapshot', () => { - useWindowSize.mockReturnValueOnce(desktopWindowSize); + hooks.useIsMobile.mockReturnValueOnce(false); hooks.useProductRecommendationsData.mockReturnValueOnce({ ...successfullLoadValues, }); @@ -46,7 +49,7 @@ describe('ProductRecommendations', () => { }); it('renders the LoadingView if the request is pending', () => { - useWindowSize.mockReturnValueOnce(desktopWindowSize); + hooks.useIsMobile.mockReturnValueOnce(false); hooks.useProductRecommendationsData.mockReturnValueOnce({ ...defaultValues, isLoading: true, @@ -57,7 +60,7 @@ describe('ProductRecommendations', () => { ); }); it('renders nothing if the request has failed', () => { - useWindowSize.mockReturnValueOnce(desktopWindowSize); + hooks.useIsMobile.mockReturnValueOnce(false); hooks.useProductRecommendationsData.mockReturnValueOnce({ ...defaultValues, hasFailed: true, @@ -67,8 +70,8 @@ describe('ProductRecommendations', () => { expect(wrapper.type()).toBeNull(); }); - it('renders nothing if the width of the screen size is less than 576px (mobile view)', () => { - useWindowSize.mockReturnValueOnce({ width: 575, height: 976 }); + it('renders nothing if the user is on the mobile view', () => { + hooks.useIsMobile.mockReturnValueOnce(true); hooks.useProductRecommendationsData.mockReturnValueOnce({ ...successfullLoadValues, }); @@ -79,7 +82,7 @@ describe('ProductRecommendations', () => { }); it('renders NoCoursesView if the request is loaded, user has courses, and the response is empty', () => { - useWindowSize.mockReturnValueOnce(desktopWindowSize); + hooks.useIsMobile.mockReturnValueOnce(false); hooks.useProductRecommendationsData.mockReturnValueOnce({ ...successfullLoadValues, productRecommendations: { @@ -87,7 +90,6 @@ describe('ProductRecommendations', () => { crossProductCourses: [], }, }); - hooks.useHasCourses.mockReturnValueOnce(true); expect(shallow()).toMatchObject( shallow(), @@ -96,11 +98,10 @@ describe('ProductRecommendations', () => { describe('LoadedView', () => { it('renders with cross product data if the request completed and the user has courses', () => { - useWindowSize.mockReturnValueOnce(desktopWindowSize); + hooks.useIsMobile.mockReturnValueOnce(false); hooks.useProductRecommendationsData.mockReturnValueOnce({ ...successfullLoadValues, }); - hooks.useHasCourses.mockReturnValueOnce(true); expect(shallow()).toMatchObject( shallow( @@ -113,12 +114,11 @@ describe('ProductRecommendations', () => { }); it('renders the LoadedView with Amplitude course data if the request completed', () => { - useWindowSize.mockReturnValueOnce(desktopWindowSize); + hooks.useIsMobile.mockReturnValueOnce(false); hooks.useProductRecommendationsData.mockReturnValueOnce({ ...successfullLoadValues, productRecommendations: mockAmplitudeResponse, }); - hooks.useHasCourses.mockReturnValueOnce(true); expect(shallow()).toMatchObject( shallow( diff --git a/src/widgets/ProductRecommendations/optimizely.js b/src/widgets/ProductRecommendations/optimizely.js new file mode 100644 index 0000000..431f116 --- /dev/null +++ b/src/widgets/ProductRecommendations/optimizely.js @@ -0,0 +1,9 @@ +import { createInstance } from '@optimizely/react-sdk'; + +const OPTIMIZELY_SDK_KEY = process.env.OPTIMIZELY_FULL_STACK_SDK_KEY; + +const optimizelyClient = createInstance({ + sdkKey: OPTIMIZELY_SDK_KEY, +}); + +export default optimizelyClient; diff --git a/src/widgets/ProductRecommendations/optimizely.test.js b/src/widgets/ProductRecommendations/optimizely.test.js new file mode 100644 index 0000000..ae32d95 --- /dev/null +++ b/src/widgets/ProductRecommendations/optimizely.test.js @@ -0,0 +1,13 @@ +import { createInstance } from '@optimizely/react-sdk'; +import optimizelyClient from './optimizely'; + +jest.mock('@optimizely/react-sdk', () => ({ + createInstance: jest.fn(() => 'mockedClient'), +})); + +describe('optimizelyClient', () => { + it('should create an Optimizely client instance with the correct SDK key', () => { + expect(optimizelyClient).toBeDefined(); + expect(createInstance).toHaveBeenCalledWith({ sdkKey: 'SDK Key' }); + }); +}); diff --git a/src/widgets/ProductRecommendations/optimizelyExperiment.js b/src/widgets/ProductRecommendations/optimizelyExperiment.js new file mode 100644 index 0000000..93cb6d1 --- /dev/null +++ b/src/widgets/ProductRecommendations/optimizelyExperiment.js @@ -0,0 +1,38 @@ +import { StrictDict } from 'utils'; +import optimizelyClient from './optimizely'; + +export const PRODUCT_RECOMMENDATIONS_EXP_KEY = 'learner_dashboard_product_recommendations_exp'; +export const PRODUCT_RECOMMENDATIONS_EXP_VARIATION = 'learner_dashboard_product_recommendations_enabled'; + +export const eventNames = StrictDict({ + productRecommendationsViewed: 'product_recommendations_viewed', + productHeaderClicked: 'product_header_clicked', + productCardClicked: 'product_card_clicked', + courseCardClicked: 'course_card_clicked', +}); + +export const activateProductRecommendationsExperiment = (userId, userAttributes) => { + const variation = optimizelyClient?.activate( + PRODUCT_RECOMMENDATIONS_EXP_KEY, + userId, + userAttributes, + ); + + return variation === PRODUCT_RECOMMENDATIONS_EXP_VARIATION; +}; + +export const trackProductRecommendationsViewed = (userId, userAttributes = {}) => { + optimizelyClient.track(eventNames.productRecommendationsViewed, userId, userAttributes); +}; + +export const trackProductHeaderClicked = (userId, userAttributes = {}) => { + optimizelyClient.track(eventNames.productHeaderClicked, userId, userAttributes); +}; + +export const trackProductCardClicked = (userId, userAttributes = {}) => { + optimizelyClient.track(eventNames.productCardClicked, userId, userAttributes); +}; + +export const trackCourseCardClicked = (userId, userAttributes = {}) => { + optimizelyClient.track(eventNames.courseCardClicked, userId, userAttributes); +}; diff --git a/src/widgets/ProductRecommendations/optimizelyExperiment.test.js b/src/widgets/ProductRecommendations/optimizelyExperiment.test.js new file mode 100644 index 0000000..ec12205 --- /dev/null +++ b/src/widgets/ProductRecommendations/optimizelyExperiment.test.js @@ -0,0 +1,79 @@ +import optimizelyClient from './optimizely'; +import { + eventNames, + PRODUCT_RECOMMENDATIONS_EXP_KEY, + PRODUCT_RECOMMENDATIONS_EXP_VARIATION, + activateProductRecommendationsExperiment, + trackProductRecommendationsViewed, + trackProductHeaderClicked, + trackProductCardClicked, + trackCourseCardClicked, +} from './optimizelyExperiment'; + +jest.mock('./optimizely', () => ({ + activate: jest.fn(), + track: jest.fn(), +})); + +const userId = '1'; +const userAttributes = { + is_enterprise_user: false, + location: 'us', + is_mobile_user: false, +}; + +describe('Optimizely events', () => { + describe('activateProductRecommendationsExperiment', () => { + it('activates the experiment and returns in recommendations experiment variant', () => { + optimizelyClient.activate.mockReturnValueOnce(PRODUCT_RECOMMENDATIONS_EXP_VARIATION); + const inRecommendationsVariant = activateProductRecommendationsExperiment(userId, userAttributes); + + expect(inRecommendationsVariant).toBeTruthy(); + expect(optimizelyClient.activate).toHaveBeenCalledWith( + PRODUCT_RECOMMENDATIONS_EXP_KEY, + userId, + userAttributes, + ); + }); + }); + describe('trackProductRecommendationsViewed', () => { + it('sends the productRecommendationsViewed event', () => { + trackProductRecommendationsViewed(userId); + expect(optimizelyClient.track).toHaveBeenCalledWith( + eventNames.productRecommendationsViewed, + userId, + {}, + ); + }); + }); + describe('trackProductHeaderClicked', () => { + it('sends the productHeaderClicked event', () => { + trackProductHeaderClicked(userId); + expect(optimizelyClient.track).toHaveBeenCalledWith( + eventNames.productHeaderClicked, + userId, + {}, + ); + }); + }); + describe('trackProductCardClicked', () => { + it('sends the productCardClicked event', () => { + trackProductCardClicked(userId); + expect(optimizelyClient.track).toHaveBeenCalledWith( + eventNames.productCardClicked, + userId, + {}, + ); + }); + }); + describe('trackCourseCardClicked', () => { + it('sends the courseCardClicked event', () => { + trackCourseCardClicked(userId); + expect(optimizelyClient.track).toHaveBeenCalledWith( + eventNames.courseCardClicked, + userId, + {}, + ); + }); + }); +}); diff --git a/src/widgets/ProductRecommendations/testData.js b/src/widgets/ProductRecommendations/testData.js index ebc9bbd..2f85013 100644 --- a/src/widgets/ProductRecommendations/testData.js +++ b/src/widgets/ProductRecommendations/testData.js @@ -4,10 +4,12 @@ export const getCoursesWithType = (courseTypes) => { courseTypes.forEach((type) => { courses.push({ title: 'Introduction to Computer Science', + courseRunKey: 'course-v1:Test+Course+2022T2', + marketingUrl: 'https://www.edx.org/course/some-course?utm_source=source', + courseType: type, image: { src: 'https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg', }, - prospectusPath: 'course/introduction-to-computer-sceince', owners: [ { key: 'HarvardX', @@ -15,7 +17,6 @@ export const getCoursesWithType = (courseTypes) => { logoImageUrl: 'http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png', }, ], - courseType: type, }); }); diff --git a/src/widgets/ProductRecommendations/track.js b/src/widgets/ProductRecommendations/track.js new file mode 100644 index 0000000..df0cf50 --- /dev/null +++ b/src/widgets/ProductRecommendations/track.js @@ -0,0 +1,55 @@ +import { StrictDict } from 'utils'; +import { createLinkTracker, createEventTracker } from 'data/services/segment/utils'; +import { courseTypeToProductLineMap, convertCourseRunKeyToCourseKey } from './utils'; + +export const eventNames = StrictDict({ + productCardClicked: 'edx.bi.2u-product-card.clicked', + discoveryCardClicked: 'edx.bi.user.discovery.card.click', + recommendationsHeaderClicked: 'edx.bi.link.recommendations.header.clicked', + recommendationsViewed: 'edx.bi.user.recommendations.viewed', +}); + +export const productCardClicked = (courseRunKey, courseTitle, courseType, href) => { + createLinkTracker( + createEventTracker(eventNames.productCardClicked, { + category: 'recommender', + label: courseTitle, + courserun_key: courseRunKey, + page: 'dashboard', + product_line: courseTypeToProductLineMap[courseType], + }), + href, + ); +}; + +export const discoveryCardClicked = (courseRunKey, courseTitle, href) => { + createLinkTracker( + createEventTracker(eventNames.discoveryCardClicked, { + category: 'recommender', + label: courseTitle, + courserun_key: courseRunKey, + page: 'dashboard', + product_line: 'open-course', + }), + href, + ); +}; + +export const recommendationsHeaderClicked = (courseType, href) => { + createLinkTracker( + createEventTracker(eventNames.recommendationsHeaderClicked, { + category: 'recommender', + page: 'dashboard', + product_line: courseTypeToProductLineMap[courseType], + }), + href, + ); +}; + +export const recommendationsViewed = (isControl, courseRunKey) => { + createEventTracker(eventNames.recommendationsViewed, { + is_control: isControl, + page: 'dashboard', + course_key: convertCourseRunKeyToCourseKey(courseRunKey), + }); +}; diff --git a/src/widgets/ProductRecommendations/track.test.js b/src/widgets/ProductRecommendations/track.test.js new file mode 100644 index 0000000..58bf4a8 --- /dev/null +++ b/src/widgets/ProductRecommendations/track.test.js @@ -0,0 +1,98 @@ +import { createLinkTracker, createEventTracker } from 'data/services/segment/utils'; +import { + eventNames, + productCardClicked, + discoveryCardClicked, + recommendationsHeaderClicked, + recommendationsViewed, +} from './track'; + +jest.mock('data/services/segment/utils', () => ({ + createEventTracker: jest.fn((args) => ({ createEventTracker: args })), + createLinkTracker: jest.fn((args) => ({ createLinkTracker: args })), +})); + +const courseKey = 'MITx+5.0.01'; +const courseRunKeyNew = `course-v1:${courseKey}+2022T2`; +const courseRunKeyOld = 'MITx/5.0.01/2022T2/'; +const label = 'Web Design'; +const headerLink = 'https://www.edx.org/search?tab=course?linked_from=recommender'; +const courseUrl = 'https://www.edx.org/course/some-course'; +const category = 'recommender'; + +describe('product recommendations trackers', () => { + describe('recommendationsViewed', () => { + describe('with old course run key format', () => { + it('creates an event tracker for when cross product recommendations are present', () => { + recommendationsViewed(false, courseRunKeyOld); + expect(createEventTracker).toHaveBeenCalledWith( + eventNames.recommendationsViewed, + { + is_control: false, + page: 'dashboard', + course_key: courseKey, + }, + ); + }); + }); + describe('with new course run key format', () => { + it('creates an event tracker for when cross product recommendations are present', () => { + recommendationsViewed(false, courseRunKeyNew); + expect(createEventTracker).toHaveBeenCalledWith( + eventNames.recommendationsViewed, + { + is_control: false, + page: 'dashboard', + course_key: courseKey, + }, + ); + }); + }); + }); + describe('recommendationsHeaderClicked', () => { + it('creates a link tracker for when a recommendations header is clicked', () => { + const attributes = { + category, + product_line: 'open-courses', + page: 'dashboard', + }; + const args = [eventNames.recommendationsHeaderClicked, attributes]; + + recommendationsHeaderClicked('Course', headerLink); + expect(createEventTracker).toHaveBeenCalledWith(...args); + expect(createLinkTracker).toHaveBeenCalledWith(createEventTracker(...args), headerLink); + }); + }); + describe('discoveryCardClicked', () => { + it('creates a link tracker for when a open course card is clicked', () => { + const attributes = { + category, + label, + courserun_key: courseRunKeyNew, + page: 'dashboard', + product_line: 'open-course', + }; + const args = [eventNames.discoveryCardClicked, attributes]; + + discoveryCardClicked(courseRunKeyNew, label, courseUrl); + expect(createEventTracker).toHaveBeenCalledWith(...args); + expect(createLinkTracker).toHaveBeenCalledWith(createEventTracker(...args), courseUrl); + }); + }); + describe('productCardClicked', () => { + it('creates a link tracker for when a cross product course card is clicked', () => { + const attributes = { + category, + label, + courserun_key: courseRunKeyNew, + page: 'dashboard', + product_line: 'boot-camps', + }; + const args = [eventNames.productCardClicked, attributes]; + + productCardClicked(courseRunKeyNew, label, 'Boot Camp', courseUrl); + expect(createEventTracker).toHaveBeenCalledWith(...args); + expect(createLinkTracker).toHaveBeenCalledWith(createEventTracker(...args), courseUrl); + }); + }); +}); diff --git a/src/widgets/ProductRecommendations/utils.js b/src/widgets/ProductRecommendations/utils.js index 12ea711..e4074d3 100644 --- a/src/widgets/ProductRecommendations/utils.js +++ b/src/widgets/ProductRecommendations/utils.js @@ -36,6 +36,23 @@ export const courseTypeToProductTypeMap = { 'masters-verified-audit': "Master's", }; +export const courseTypeToProductLineMap = { + 'Executive Education': 'executive-education', + 'Boot Camp': 'boot-camps', + Course: 'open-courses', +}; + +export const convertCourseRunKeyToCourseKey = (courseRunKey) => { + const newKeyFormat = courseRunKey.includes('+'); + if (newKeyFormat) { + const splitCourseRunKey = courseRunKey.split(':').slice(-1)[0]; + const splitCourseKey = splitCourseRunKey.split('+').slice(0, 2); + return `${splitCourseKey[0]}+${splitCourseKey[1]}`; + } + const splitCourseKey = courseRunKey.split('/').slice(0, 2); + return `${splitCourseKey[0]}+${splitCourseKey[1]}`; +}; + export const wait = (time) => new Promise((resolve) => { setTimeout(resolve, time); });