From 9a57f9de13a7dd560e0dafb58bea030785695c50 Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Wed, 30 Nov 2022 11:01:39 -0500 Subject: [PATCH] Bw/segment (#76) Co-authored-by: Leangseu Kim --- package-lock.json | 21 +- package.json | 6 +- src/__snapshots__/index.test.jsx.snap | 10 +- src/config/index.js | 2 +- .../__snapshots__/index.test.jsx.snap | 68 +++++- .../CourseCardActions/UpgradeButton.jsx | 22 +- .../CourseCardActions/UpgradeButton.test.jsx | 21 +- .../CourseCardActions/ViewCourseButton.jsx | 10 +- .../ViewCourseButton.test.jsx | 22 +- .../__snapshots__/UpgradeButton.test.jsx.snap | 12 +- .../ViewCourseButton.test.jsx.snap | 22 +- .../components/CourseCardActions/hooks.js | 18 -- .../CourseCardActions/hooks.test.js | 21 -- .../components/CourseCardContent.jsx | 74 ------- .../components/CourseCardContent.test.jsx | 54 ----- .../CourseCard/components/CourseCardImage.jsx | 64 ++++++ .../components/CourseCardLayout.jsx | 33 --- .../components/CourseCardLayout.test.jsx | 29 --- .../__snapshots__/index.test.jsx.snap | 2 + .../components/CourseCardMenu/hooks.js | 12 ++ .../components/CourseCardMenu/index.jsx | 29 ++- .../components/CourseCardMenu/index.test.jsx | 3 + .../CourseCard/components/CourseCardTitle.jsx | 33 +++ .../CourseCardContent.test.jsx.snap | 189 ----------------- .../CourseCardLayout.test.jsx.snap | 63 ------ src/containers/CourseCard/index.jsx | 23 +- src/containers/CourseCard/index.test.jsx | 7 +- .../__snapshots__/index.test.jsx.snap | 7 +- .../EnterpriseDashboardModal/hooks.js | 31 ++- .../EnterpriseDashboardModal/hooks.test.js | 27 ++- .../EnterpriseDashboardModal/index.jsx | 10 +- .../EnterpriseDashboardModal/index.test.jsx | 9 +- src/containers/SelectSessionModal/hooks.js | 18 +- src/containers/UnenrollConfirmModal/hooks.js | 111 ---------- .../UnenrollConfirmModal/hooks.test.js | 197 ------------------ .../UnenrollConfirmModal/hooks/index.js | 64 ++++++ .../UnenrollConfirmModal/hooks/index.test.js | 99 +++++++++ .../UnenrollConfirmModal/hooks/reasons.js | 43 ++++ .../hooks/reasons.test.js | 76 +++++++ src/data/redux/hooks.js | 5 + src/data/redux/thunkActions/app.js | 26 +-- src/data/redux/thunkActions/app.test.js | 47 +---- src/data/services/lms/api.js | 53 +++-- src/data/services/lms/api.test.js | 72 +++++-- src/data/services/lms/urls.js | 10 +- src/data/services/segment/constants.js | 21 -- src/data/services/segment/utils.js | 28 ++- src/data/services/segment/utils.test.js | 66 +++--- src/index.jsx | 7 +- src/testUtils.js | 6 + src/tracking/constants.js | 53 +++++ src/tracking/index.js | 13 ++ src/tracking/trackers/course.js | 75 +++++++ src/tracking/trackers/course.test.js | 119 +++++++++++ src/tracking/trackers/engagement.js | 23 ++ src/tracking/trackers/engagement.test.js | 31 +++ src/tracking/trackers/enterpriseDashboard.js | 44 ++++ .../trackers/enterpriseDashboard.test.js | 38 ++++ src/tracking/trackers/entitlements.js | 34 +++ src/tracking/trackers/entitlements.test.js | 34 +++ src/tracking/trackers/socialShare.js | 11 + src/tracking/trackers/socialShare.test.js | 17 ++ .../__snapshots__/index.test.jsx.snap | 1 - .../LookingForChallengeWidget/index.jsx | 12 +- .../LookingForChallengeWidget/index.test.jsx | 9 +- .../RecommendationsPanel/LoadedView.jsx | 5 +- .../RecommendationsPanel/LoadedView.test.jsx | 4 +- .../__snapshots__/LoadedView.test.jsx.snap | 6 +- .../components/CourseCard.jsx | 22 +- .../RecommendationsPanel/components/hooks.js | 30 +++ src/widgets/RecommendationsPanel/hooks.js | 9 +- .../RecommendationsPanel/hooks.test.js | 27 ++- src/widgets/RecommendationsPanel/index.jsx | 13 +- .../RecommendationsPanel/index.test.jsx | 21 +- src/widgets/RecommendationsPanel/messages.js | 6 +- src/widgets/RecommendationsPanel/track.js | 28 +++ 76 files changed, 1467 insertions(+), 1121 deletions(-) delete mode 100644 src/containers/CourseCard/components/CourseCardActions/hooks.js delete mode 100644 src/containers/CourseCard/components/CourseCardActions/hooks.test.js delete mode 100644 src/containers/CourseCard/components/CourseCardContent.jsx delete mode 100644 src/containers/CourseCard/components/CourseCardContent.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardImage.jsx delete mode 100644 src/containers/CourseCard/components/CourseCardLayout.jsx delete mode 100644 src/containers/CourseCard/components/CourseCardLayout.test.jsx create mode 100644 src/containers/CourseCard/components/CourseCardTitle.jsx delete mode 100644 src/containers/CourseCard/components/__snapshots__/CourseCardContent.test.jsx.snap delete mode 100644 src/containers/CourseCard/components/__snapshots__/CourseCardLayout.test.jsx.snap delete mode 100644 src/containers/UnenrollConfirmModal/hooks.js delete mode 100644 src/containers/UnenrollConfirmModal/hooks.test.js create mode 100644 src/containers/UnenrollConfirmModal/hooks/index.js create mode 100644 src/containers/UnenrollConfirmModal/hooks/index.test.js create mode 100644 src/containers/UnenrollConfirmModal/hooks/reasons.js create mode 100644 src/containers/UnenrollConfirmModal/hooks/reasons.test.js delete mode 100644 src/data/services/segment/constants.js create mode 100644 src/tracking/constants.js create mode 100644 src/tracking/index.js create mode 100644 src/tracking/trackers/course.js create mode 100644 src/tracking/trackers/course.test.js create mode 100644 src/tracking/trackers/engagement.js create mode 100644 src/tracking/trackers/engagement.test.js create mode 100644 src/tracking/trackers/enterpriseDashboard.js create mode 100644 src/tracking/trackers/enterpriseDashboard.test.js create mode 100644 src/tracking/trackers/entitlements.js create mode 100644 src/tracking/trackers/entitlements.test.js create mode 100644 src/tracking/trackers/socialShare.js create mode 100644 src/tracking/trackers/socialShare.test.js create mode 100644 src/widgets/RecommendationsPanel/components/hooks.js create mode 100644 src/widgets/RecommendationsPanel/track.js diff --git a/package-lock.json b/package-lock.json index 962373c..090a811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@edx/frontend-app-learner-dash", + "name": "@edx/frontend-app-learner-dashboard", "version": "0.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "@edx/frontend-app-learner-dash", + "name": "@edx/frontend-app-learner-dashboard", "version": "0.0.1", "license": "AGPL-3.0", "dependencies": { @@ -13,7 +13,7 @@ "@edx/browserslist-config": "^1.1.0", "@edx/frontend-component-footer": "^11.4.1", "@edx/frontend-platform": "^2.6.2", - "@edx/paragon": "20.12.0", + "@edx/paragon": "20.19.0", "@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", @@ -2604,9 +2604,9 @@ } }, "node_modules/@edx/paragon": { - "version": "20.12.0", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.12.0.tgz", - "integrity": "sha512-0BRsKjSWJdUYV2c0OHiYon7beIxL8uhlyyYpJVyVI6dDRGr5SnJufPaRXdo5L9NUpakDYo+SWr59DL1t5/0Q4Q==", + "version": "20.19.0", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.19.0.tgz", + "integrity": "sha512-N35cTPOrpacUKEfl8L2hryPzmPBGNbLRSgl/+BAIxyJuvn5etAjsAw6Etz3M91yRaTGLYH7+9HtExLES+qNfXw==", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", @@ -2634,7 +2634,8 @@ }, "peerDependencies": { "react": "^16.8.6 || ^17.0.0", - "react-dom": "^16.8.6 || ^17.0.0" + "react-dom": "^16.8.6 || ^17.0.0", + "react-intl": "^5.25.1" } }, "node_modules/@edx/paragon/node_modules/@fortawesome/fontawesome-common-types": { @@ -32218,9 +32219,9 @@ } }, "@edx/paragon": { - "version": "20.12.0", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.12.0.tgz", - "integrity": "sha512-0BRsKjSWJdUYV2c0OHiYon7beIxL8uhlyyYpJVyVI6dDRGr5SnJufPaRXdo5L9NUpakDYo+SWr59DL1t5/0Q4Q==", + "version": "20.19.0", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.19.0.tgz", + "integrity": "sha512-N35cTPOrpacUKEfl8L2hryPzmPBGNbLRSgl/+BAIxyJuvn5etAjsAw6Etz3M91yRaTGLYH7+9HtExLES+qNfXw==", "requires": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", diff --git a/package.json b/package.json index fc5584a..6e0a917 100755 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "@edx/frontend-app-learner-dash", + "name": "@edx/frontend-app-learner-dashboard", "version": "0.0.1", "description": "", "repository": { "type": "git", - "url": "git+https://github.com/edx/frontend-app-learner-dash.git" + "url": "git+https://github.com/edx/frontend-app-learner-dashboard.git" }, "scripts": { "build": "fedx-scripts webpack", @@ -31,7 +31,7 @@ "@edx/browserslist-config": "^1.1.0", "@edx/frontend-component-footer": "^11.4.1", "@edx/frontend-platform": "^2.6.2", - "@edx/paragon": "20.12.0", + "@edx/paragon": "20.19.0", "@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", diff --git a/src/__snapshots__/index.test.jsx.snap b/src/__snapshots__/index.test.jsx.snap index 1e8cd8e..cf7b63d 100644 --- a/src/__snapshots__/index.test.jsx.snap +++ b/src/__snapshots__/index.test.jsx.snap @@ -1,9 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`app registry subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element 1`] = ` - + + + `; exports[`app registry subscribe: APP_READY. links App to root element 1`] = ` diff --git a/src/config/index.js b/src/config/index.js index 98dfc88..9ada1d1 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -7,7 +7,7 @@ const configuration = { // REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT, // DATA_API_BASE_URL: process.env.DATA_API_BASE_URL, // SECURE_COOKIES: process.env.NODE_ENV !== 'development', - // SEGMENT_KEY: process.env.SEGMENT_KEY, + SEGMENT_KEY: process.env.SEGMENT_KEY, // ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME, LEARNING_BASE_URL: process.env.LEARNING_BASE_URL, PERSONALIZED_RECOMMENDATION_COOKIE_NAME: process.env.PERSONALIZED_RECOMMENDATION_COOKIE_NAME || '', diff --git a/src/containers/CourseCard/__snapshots__/index.test.jsx.snap b/src/containers/CourseCard/__snapshots__/index.test.jsx.snap index 1fba43a..84cd781 100644 --- a/src/containers/CourseCard/__snapshots__/index.test.jsx.snap +++ b/src/containers/CourseCard/__snapshots__/index.test.jsx.snap @@ -13,10 +13,41 @@ exports[`CourseCard component snapshot: collapsed 1`] = ` className="d-flex flex-column w-100" >
- + + + } + title={ + + } + /> + + + + + + + +
- + + + } + title={ + + } + /> + + + + + + + +
{ + const { formatMessage } = useIntl(); + const { upgradeUrl } = hooks.useCardCourseRunData(cardId); const { canUpgrade } = hooks.useCardEnrollmentData(cardId); const { isMasquerading } = hooks.useMasqueradeData(); - const { formatMessage } = useIntl(); - const isEnabled = (!isMasquerading && canUpgrade); - const { trackUpgradeClick } = useTrackUpgradeData(); + const trackUpgradeClick = hooks.useTrackCourseEvent( + track.course.upgradeClicked, + cardId, + upgradeUrl, + ); + const isEnabled = (!isMasquerading && canUpgrade); + const enabledProps = { + as: 'a', + href: upgradeUrl, + onClick: trackUpgradeClick, + }; return ( {formatMessage(messages.upgrade)} diff --git a/src/containers/CourseCard/components/CourseCardActions/UpgradeButton.test.jsx b/src/containers/CourseCard/components/CourseCardActions/UpgradeButton.test.jsx index b28c69f..d9b6167 100644 --- a/src/containers/CourseCard/components/CourseCardActions/UpgradeButton.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/UpgradeButton.test.jsx @@ -1,20 +1,28 @@ import { shallow } from 'enzyme'; -import { htmlProps } from 'data/constants/htmlKeys'; +import track from 'tracking'; import { hooks } from 'data/redux'; +import { htmlProps } from 'data/constants/htmlKeys'; import UpgradeButton from './UpgradeButton'; +jest.mock('tracking', () => ({ + course: { + upgradeClicked: jest.fn().mockName('segment.trackUpgradeClicked'), + }, +})); + jest.mock('data/redux', () => ({ hooks: { useMasqueradeData: jest.fn(() => ({ isMasquerading: false })), useCardCourseRunData: jest.fn(), useCardEnrollmentData: jest.fn(() => ({ canUpgrade: true })), + useTrackCourseEvent: jest.fn( + (eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }), + ), }, })); + jest.mock('./ActionButton', () => 'ActionButton'); -jest.mock('./hooks', () => () => ({ - trackUpgradeClick: jest.fn().mockName('trackUpgradeClick'), -})); describe('UpgradeButton', () => { const props = { @@ -27,6 +35,11 @@ describe('UpgradeButton', () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); expect(wrapper.prop(htmlProps.disabled)).toEqual(false); + expect(wrapper.prop(htmlProps.onClick)).toEqual(hooks.useTrackCourseEvent( + track.course.upgradeClicked, + props.cardId, + upgradeUrl, + )); }); test('cannot upgrade', () => { hooks.useCardEnrollmentData.mockReturnValueOnce({ canUpgrade: false }); diff --git a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx index 5f9e3da..7844793 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; +import track from 'tracking'; import { hooks } from 'data/redux'; import ActionButton from './ActionButton'; import messages from './messages'; @@ -10,12 +11,19 @@ import messages from './messages'; export const ViewCourseButton = ({ cardId }) => { const { homeUrl } = hooks.useCardCourseRunData(cardId); const { hasAccess } = hooks.useCardEnrollmentData(cardId); + const handleClick = hooks.useTrackCourseEvent( + track.course.enterCourseClicked, + cardId, + homeUrl, + ); const { formatMessage } = useIntl(); + return ( {formatMessage(messages.viewCourse)} diff --git a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx index 0f88968..1d67015 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx @@ -1,14 +1,24 @@ import { shallow } from 'enzyme'; +import track from 'tracking'; import { htmlProps } from 'data/constants/htmlKeys'; import { hooks } from 'data/redux'; import ViewCourseButton from './ViewCourseButton'; +jest.mock('tracking', () => ({ + course: { + enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), + }, +})); + jest.mock('data/redux', () => ({ hooks: { useCardCourseRunData: jest.fn(), useCardEnrollmentData: jest.fn(), useCardEntitlementData: jest.fn(), + useTrackCourseEvent: jest.fn( + (eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }), + ), }, })); jest.mock('./ActionButton', () => 'ActionButton'); @@ -38,7 +48,11 @@ describe('ViewCourseButton', () => { expect(wrapper).toMatchSnapshot(); }); test('links to home URL', () => { - expect(wrapper.prop(htmlProps.href)).toEqual(homeUrl); + expect(wrapper.prop(htmlProps.onClick)).toEqual(hooks.useTrackCourseEvent( + track.course.enterCourseClicked, + defaultProps.cardId, + homeUrl, + )); }); test('link is enabled', () => { expect(wrapper.prop(htmlProps.disabled)).toEqual(false); @@ -52,7 +66,11 @@ describe('ViewCourseButton', () => { expect(wrapper).toMatchSnapshot(); }); test('links to home URL', () => { - expect(wrapper.prop(htmlProps.href)).toEqual(homeUrl); + expect(wrapper.prop(htmlProps.onClick)).toEqual(hooks.useTrackCourseEvent( + track.course.enterCourseClicked, + defaultProps.cardId, + homeUrl, + )); }); test('link is enabled', () => { expect(wrapper.prop(htmlProps.disabled)).toEqual(true); diff --git a/src/containers/CourseCard/components/CourseCardActions/__snapshots__/UpgradeButton.test.jsx.snap b/src/containers/CourseCard/components/CourseCardActions/__snapshots__/UpgradeButton.test.jsx.snap index b5b2077..4e250f1 100644 --- a/src/containers/CourseCard/components/CourseCardActions/__snapshots__/UpgradeButton.test.jsx.snap +++ b/src/containers/CourseCard/components/CourseCardActions/__snapshots__/UpgradeButton.test.jsx.snap @@ -6,7 +6,15 @@ exports[`UpgradeButton snapshot can upgrade 1`] = ` disabled={false} href="upgradeUrl" iconBefore={[MockFunction icons.Locked]} - onClick={[MockFunction trackUpgradeClick]} + onClick={ + Object { + "trackCourseEvent": Object { + "cardId": "cardId", + "eventName": [MockFunction segment.trackUpgradeClicked], + "upgradeUrl": "upgradeUrl", + }, + } + } variant="outline-primary" > Upgrade @@ -17,7 +25,6 @@ exports[`UpgradeButton snapshot cannot upgrade 1`] = ` Upgrade @@ -28,7 +35,6 @@ exports[`UpgradeButton snapshot masquerading 1`] = ` Upgrade diff --git a/src/containers/CourseCard/components/CourseCardActions/__snapshots__/ViewCourseButton.test.jsx.snap b/src/containers/CourseCard/components/CourseCardActions/__snapshots__/ViewCourseButton.test.jsx.snap index da92a1f..89b6b80 100644 --- a/src/containers/CourseCard/components/CourseCardActions/__snapshots__/ViewCourseButton.test.jsx.snap +++ b/src/containers/CourseCard/components/CourseCardActions/__snapshots__/ViewCourseButton.test.jsx.snap @@ -4,7 +4,16 @@ exports[`ViewCourseButton learner does not have access to course snapshot 1`] = View Course @@ -14,7 +23,16 @@ exports[`ViewCourseButton learner has access to course snapshot 1`] = ` View Course diff --git a/src/containers/CourseCard/components/CourseCardActions/hooks.js b/src/containers/CourseCard/components/CourseCardActions/hooks.js deleted file mode 100644 index c05c94e..0000000 --- a/src/containers/CourseCard/components/CourseCardActions/hooks.js +++ /dev/null @@ -1,18 +0,0 @@ -import { handleEvent } from 'data/services/segment/utils'; -import { eventNames } from 'data/services/segment/constants'; - -export const useTrackUpgradeData = () => { - const trackUpgradeClick = () => { - handleEvent(eventNames.upgradeCourse, { - pageName: 'learner_home', - linkType: 'button', - linkCategory: 'green_upgrade', - }); - }; - - return { - trackUpgradeClick, - }; -}; - -export default useTrackUpgradeData; diff --git a/src/containers/CourseCard/components/CourseCardActions/hooks.test.js b/src/containers/CourseCard/components/CourseCardActions/hooks.test.js deleted file mode 100644 index 26b5e4e..0000000 --- a/src/containers/CourseCard/components/CourseCardActions/hooks.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { handleEvent } from 'data/services/segment/utils'; -import { eventNames } from 'data/services/segment/constants'; -import * as hooks from './hooks'; - -jest.mock('data/services/segment/utils', () => ({ - handleEvent: jest.fn(), -})); - -describe('CourseCardActions hooks', () => { - describe('useTrackUpgradeData', () => { - it('calls handleEvent with correct params', () => { - const out = hooks.useTrackUpgradeData(); - out.trackUpgradeClick(); - expect(handleEvent).toHaveBeenCalledWith(eventNames.upgradeCourse, { - pageName: 'learner_home', - linkType: 'button', - linkCategory: 'green_upgrade', - }); - }); - }); -}); diff --git a/src/containers/CourseCard/components/CourseCardContent.jsx b/src/containers/CourseCard/components/CourseCardContent.jsx deleted file mode 100644 index 5939ed5..0000000 --- a/src/containers/CourseCard/components/CourseCardContent.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; - -// import PropTypes from 'prop-types'; -import { Card, Badge } from '@edx/paragon'; - -import { hooks as appHooks } from 'data/redux'; - -import verifiedRibbon from 'assets/verified-ribbon.png'; -import RelatedProgramsBadge from './RelatedProgramsBadge'; -import CourseCardMenu from './CourseCardMenu'; -import CourseCardActions from './CourseCardActions'; -import CourseCardDetails from './CourseCardDetails'; - -import messages from '../messages'; - -export const CourseCardContent = ({ cardId, orientation }) => { - const { formatMessage } = useIntl(); - const { courseName, bannerImgSrc } = appHooks.useCardCourseData(cardId); - const { homeUrl } = appHooks.useCardCourseRunData(cardId); - const { isVerified } = appHooks.useCardEnrollmentData(cardId); - return ( - <> - - {formatMessage(messages.bannerAlt)} - { - isVerified && ( - - {formatMessage(messages.verifiedBanner)} - {formatMessage(messages.verifiedBannerRibbonAlt)} - - ) - } - - - - - {courseName} - - - )} - actions={} - /> - - - - - - - - - - ); -}; - -CourseCardContent.propTypes = { - cardId: PropTypes.string.isRequired, - orientation: PropTypes.string.isRequired, -}; - -CourseCardContent.defaultProps = {}; - -export default CourseCardContent; diff --git a/src/containers/CourseCard/components/CourseCardContent.test.jsx b/src/containers/CourseCard/components/CourseCardContent.test.jsx deleted file mode 100644 index ae2c8b7..0000000 --- a/src/containers/CourseCard/components/CourseCardContent.test.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import { shallow } from 'enzyme'; - -import { hooks } from 'data/redux'; -import CourseCardContent from './CourseCardContent'; - -jest.mock('data/redux', () => ({ - hooks: { - useCardCourseData: jest.fn(), - useCardCourseRunData: jest.fn(), - useCardEnrollmentData: jest.fn(), - }, -})); - -jest.mock('./CourseCardActions', () => 'CourseCardActions'); -jest.mock('./CourseCardDetails', () => 'CourseCardDetails'); -jest.mock('./RelatedProgramsBadge', () => 'RelatedProgramsBadge'); -jest.mock('./CourseCardMenu', () => 'CourseCardMenu'); - -describe('CourseCardContent', () => { - const props = { - cardId: 'test-card-id', - orientation: 'vertical', - }; - hooks.useCardCourseData.mockReturnValue({ - courseName: 'test-course-name', - bannerImgSrc: 'test-banner-img-src', - }); - hooks.useCardCourseRunData.mockReturnValue({ - homeUrl: 'test-home-url', - }); - describe('snapshot', () => { - test('orientation vertical', () => { - hooks.useCardEnrollmentData.mockReturnValue({ - isVerified: true, - }); - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - test('orientation horizontal', () => { - hooks.useCardEnrollmentData.mockReturnValue({ - isVerified: true, - }); - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - test('not verified', () => { - hooks.useCardEnrollmentData.mockReturnValue({ - isVerified: false, - }); - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - }); -}); diff --git a/src/containers/CourseCard/components/CourseCardImage.jsx b/src/containers/CourseCard/components/CourseCardImage.jsx new file mode 100644 index 0000000..3be032e --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardImage.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { Badge } from '@edx/paragon'; + +import track from 'tracking'; +import { hooks as appHooks } from 'data/redux'; + +import verifiedRibbon from 'assets/verified-ribbon.png'; + +import messages from '../messages'; + +const { courseImageClicked } = track.course; + +export const CourseCardImage = ({ cardId, orientation }) => { + const { formatMessage } = useIntl(); + const { bannerImgSrc } = appHooks.useCardCourseData(cardId); + const { homeUrl } = appHooks.useCardCourseRunData(cardId); + const { isVerified } = appHooks.useCardEnrollmentData(cardId); + const { isEntitlement } = appHooks.useCardEntitlementData(cardId); + const handleImageClicked = appHooks.useTrackCourseEvent(courseImageClicked, cardId, homeUrl); + const image = ( + <> + {formatMessage(messages.bannerAlt)} + { + isVerified && ( + + + {formatMessage(messages.verifiedBanner)} + + {formatMessage(messages.verifiedBannerRibbonAlt)} + + ) + } + + ); + return isEntitlement + ? image + : ( + + {image} + + ); +}; +CourseCardImage.propTypes = { + cardId: PropTypes.string.isRequired, + orientation: PropTypes.string.isRequired, +}; + +CourseCardImage.defaultProps = {}; + +export default CourseCardImage; diff --git a/src/containers/CourseCard/components/CourseCardLayout.jsx b/src/containers/CourseCard/components/CourseCardLayout.jsx deleted file mode 100644 index 38246e4..0000000 --- a/src/containers/CourseCard/components/CourseCardLayout.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { Card } from '@edx/paragon'; - -import { useIsCollapsed } from '../hooks'; -import CourseCardBanners from './CourseCardBanners'; -import CourseCardContent from './CourseCardContent'; - -export const CourseCardLayout = ({ - cardId, -}) => { - const isCollapsed = useIsCollapsed(); - return ( -
- -
-
- -
-
- -
-
-
-
- ); -}; -CourseCardLayout.propTypes = { - cardId: PropTypes.string.isRequired, -}; - -export default CourseCardLayout; diff --git a/src/containers/CourseCard/components/CourseCardLayout.test.jsx b/src/containers/CourseCard/components/CourseCardLayout.test.jsx deleted file mode 100644 index 7be493d..0000000 --- a/src/containers/CourseCard/components/CourseCardLayout.test.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import { shallow } from 'enzyme'; - -import CourseCardLayout from './CourseCardLayout'; -import { useIsCollapsed } from '../hooks'; - -jest.mock('../hooks', () => ({ - useIsCollapsed: jest.fn(), -})); - -jest.mock('./CourseCardBanners', () => 'CourseCardBanners'); -jest.mock('./CourseCardContent', () => 'CourseCardContent'); - -describe('CourseCardLayout', () => { - const props = { - cardId: 'test-card-id', - }; - describe('snapshot', () => { - test('is collapsed', () => { - useIsCollapsed.mockReturnValue(true); - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - test('is not collapsed', () => { - useIsCollapsed.mockReturnValue(false); - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - }); -}); diff --git a/src/containers/CourseCard/components/CourseCardMenu/__snapshots__/index.test.jsx.snap b/src/containers/CourseCard/components/CourseCardMenu/__snapshots__/index.test.jsx.snap index d4c6064..44fe18c 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/__snapshots__/index.test.jsx.snap +++ b/src/containers/CourseCard/components/CourseCardMenu/__snapshots__/index.test.jsx.snap @@ -28,6 +28,7 @@ exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1 { isVisible, }; }; + +export const useHandleToggleDropdown = (cardId) => { + const eventName = track.course.courseOptionsDropdownClicked; + const trackCourseEvent = appHooks.useTrackCourseEvent(eventName, cardId); + return (isOpen) => { + if (isOpen) { trackCourseEvent(); } + }; +}; diff --git a/src/containers/CourseCard/components/CourseCardMenu/index.jsx b/src/containers/CourseCard/components/CourseCardMenu/index.jsx index 1e5f429..87bd4d9 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/index.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/index.jsx @@ -6,28 +6,38 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Dropdown, Icon, IconButton } from '@edx/paragon'; import { MoreVert } from '@edx/paragon/icons'; +import track from 'tracking'; import { hooks as appHooks } from 'data/redux'; import EmailSettingsModal from 'containers/EmailSettingsModal'; import UnenrollConfirmModal from 'containers/UnenrollConfirmModal'; -import { useEmailSettings, useUnenrollData } from './hooks'; +import { + useEmailSettings, + useUnenrollData, + useHandleToggleDropdown, +} from './hooks'; import messages from './messages'; export const CourseCardMenu = ({ cardId }) => { - const emailSettingsModal = useEmailSettings(); - const unenrollModal = useUnenrollData(); + const { formatMessage } = useIntl(); + const { courseName } = appHooks.useCardCourseData(cardId); const { isEnrolled, isEmailEnabled } = appHooks.useCardEnrollmentData(cardId); - const { - // facebook, - twitter, - } = appHooks.useCardSocialSettingsData(cardId); + const { twitter } = appHooks.useCardSocialSettingsData(cardId); const { isMasquerading } = appHooks.useMasqueradeData(); - const { formatMessage } = useIntl(); + const handleTwitterShare = appHooks.useTrackCourseEvent( + track.socialShare, + cardId, + 'twitter', + ); + + const emailSettingsModal = useEmailSettings(); + const unenrollModal = useUnenrollData(); + const handleToggleDropdown = useHandleToggleDropdown(cardId); return ( <> - + { {twitter.isEnabled && ( ({ useCardEnrollmentData: jest.fn(), useCardSocialSettingsData: jest.fn(), useMasqueradeData: jest.fn(), + useTrackCourseEvent: jest.fn(), }, })); jest.mock('./hooks', () => ({ useEmailSettings: jest.fn(), useUnenrollData: jest.fn(), + useHandleToggleDropdown: jest.fn(), })); const props = { @@ -58,6 +60,7 @@ describe('CourseCardMenu', () => { appHooks.useCardCourseData.mockReturnValue({ courseName }); appHooks.useCardEnrollmentData.mockReturnValue({ isEnrolled: true, isEmailEnabled: true }); appHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false }); + appHooks.useTrackCourseEvent.mockReturnValue(jest.fn().mockName('handleTwitterShare')); }); describe('enrolled, share enabled, email setting enable', () => { beforeEach(() => { diff --git a/src/containers/CourseCard/components/CourseCardTitle.jsx b/src/containers/CourseCard/components/CourseCardTitle.jsx new file mode 100644 index 0000000..812224e --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardTitle.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import track from 'tracking'; +import { hooks as appHooks } from 'data/redux'; + +const { courseTitleClicked } = track.course; + +export const CourseCardTitle = ({ cardId }) => { + const { courseName } = appHooks.useCardCourseData(cardId); + const { homeUrl } = appHooks.useCardCourseRunData(cardId); + const handleTitleClicked = appHooks.useTrackCourseEvent(courseTitleClicked, cardId, homeUrl); + return ( +

+ + {courseName} + +

+ ); +}; + +CourseCardTitle.propTypes = { + cardId: PropTypes.string.isRequired, +}; + +CourseCardTitle.defaultProps = {}; + +export default CourseCardTitle; diff --git a/src/containers/CourseCard/components/__snapshots__/CourseCardContent.test.jsx.snap b/src/containers/CourseCard/components/__snapshots__/CourseCardContent.test.jsx.snap deleted file mode 100644 index 26d6449..0000000 --- a/src/containers/CourseCard/components/__snapshots__/CourseCardContent.test.jsx.snap +++ /dev/null @@ -1,189 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CourseCardContent snapshot not verified 1`] = ` - - - Course thumbnail - - - - } - title={ -

- - test-course-name - -

- } - /> - - - - - - - -
-
-`; - -exports[`CourseCardContent snapshot orientation horizontal 1`] = ` - - - Course thumbnail - - - Verified - - ID Verified Ribbon/Badge - - - - - } - title={ -

- - test-course-name - -

- } - /> - - - - - - - -
-
-`; - -exports[`CourseCardContent snapshot orientation vertical 1`] = ` - - - Course thumbnail - - - Verified - - ID Verified Ribbon/Badge - - - - - } - title={ -

- - test-course-name - -

- } - /> - - - - - - - -
-
-`; diff --git a/src/containers/CourseCard/components/__snapshots__/CourseCardLayout.test.jsx.snap b/src/containers/CourseCard/components/__snapshots__/CourseCardLayout.test.jsx.snap deleted file mode 100644 index bca8f23..0000000 --- a/src/containers/CourseCard/components/__snapshots__/CourseCardLayout.test.jsx.snap +++ /dev/null @@ -1,63 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CourseCardLayout snapshot is collapsed 1`] = ` -
- -
-
- -
-
- -
-
-
-
-`; - -exports[`CourseCardLayout snapshot is not collapsed 1`] = ` -
- -
-
- -
-
- -
-
-
-
-`; diff --git a/src/containers/CourseCard/index.jsx b/src/containers/CourseCard/index.jsx index ac9c102..ccde230 100644 --- a/src/containers/CourseCard/index.jsx +++ b/src/containers/CourseCard/index.jsx @@ -1,12 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; -// import PropTypes from 'prop-types'; import { Card } from '@edx/paragon'; import { useIsCollapsed } from './hooks'; -import CourseCardContent from './components/CourseCardContent'; import CourseCardBanners from './components/CourseCardBanners'; +import CourseCardImage from './components/CourseCardImage'; +import CourseCardMenu from './components/CourseCardMenu'; +import CourseCardActions from './components/CourseCardActions'; +import CourseCardDetails from './components/CourseCardDetails'; +import CourseCardTitle from './components/CourseCardTitle'; +import RelatedProgramsBadge from './components/RelatedProgramsBadge'; import './CourseCard.scss'; @@ -20,7 +24,20 @@ export const CourseCard = ({
- + + + } + actions={} + /> + + + + + + + +
diff --git a/src/containers/CourseCard/index.test.jsx b/src/containers/CourseCard/index.test.jsx index 68aff70..5f09d5e 100644 --- a/src/containers/CourseCard/index.test.jsx +++ b/src/containers/CourseCard/index.test.jsx @@ -9,7 +9,12 @@ jest.mock('./hooks', () => ({ })); jest.mock('./components/CourseCardBanners', () => 'CourseCardBanners'); -jest.mock('./components/CourseCardContent', () => 'CourseCardContent'); +jest.mock('./components/CourseCardImage', () => 'CourseCardImage'); +jest.mock('./components/CourseCardMenu', () => 'CourseCardMenu'); +jest.mock('./components/CourseCardActions', () => 'CourseCardActions'); +jest.mock('./components/CourseCardDetails', () => 'CourseCardDetails'); +jest.mock('./components/CourseCardTitle', () => 'CourseCardTitle'); +jest.mock('./components/RelatedProgramsBadge', () => 'RelatedProgramsBadge'); const cardId = 'test-card-id'; diff --git a/src/containers/EnterpriseDashboardModal/__snapshots__/index.test.jsx.snap b/src/containers/EnterpriseDashboardModal/__snapshots__/index.test.jsx.snap index fae0a28..53e48cf 100644 --- a/src/containers/EnterpriseDashboardModal/__snapshots__/index.test.jsx.snap +++ b/src/containers/EnterpriseDashboardModal/__snapshots__/index.test.jsx.snap @@ -1,9 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`EnterpriseDashboard empty snapshot 1`] = `""`; + exports[`EnterpriseDashboard snapshot 1`] = `
- diff --git a/src/containers/EnterpriseDashboardModal/index.test.jsx b/src/containers/EnterpriseDashboardModal/index.test.jsx index 41a1e2e..26314bd 100644 --- a/src/containers/EnterpriseDashboardModal/index.test.jsx +++ b/src/containers/EnterpriseDashboardModal/index.test.jsx @@ -13,10 +13,17 @@ describe('EnterpriseDashboard', () => { const hookData = { dashboard: { label: 'edX, Inc.', url: '/edx-dashboard' }, showDialog: false, - handleClick: jest.fn().mockName('useEnterpriseDashboardHook.handleClick'), + handleClose: jest.fn().mockName('useEnterpriseDashboardHook.handleClose'), + handleCTAClick: jest.fn().mockName('useEnterpriseDashboardHook.handleCTAClick'), + handleEscape: jest.fn().mockName('useEnterpriseDashboardHook.handleEscape'), }; useEnterpriseDashboardHook.mockReturnValueOnce({ ...hookData }); const el = shallow(); expect(el).toMatchSnapshot(); }); + test('empty snapshot', () => { + useEnterpriseDashboardHook.mockReturnValueOnce({}); + const el = shallow(); + expect(el).toMatchSnapshot(); + }); }); diff --git a/src/containers/SelectSessionModal/hooks.js b/src/containers/SelectSessionModal/hooks.js index ced337f..1aeb381 100644 --- a/src/containers/SelectSessionModal/hooks.js +++ b/src/containers/SelectSessionModal/hooks.js @@ -5,6 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { StrictDict } from 'utils'; +import track from 'tracking'; import { hooks as appHooks, thunkActions } from 'data/redux'; import * as module from './hooks'; import { LEAVE_OPTION } from './constants'; @@ -15,6 +16,9 @@ export const state = StrictDict({ }); export const useSelectSessionModalData = () => { + const dispatch = useDispatch(); + const { formatMessage } = useIntl(); + const selectedCardId = appHooks.useSelectSessionModalData().cardId; const { availableSessions, @@ -24,9 +28,6 @@ export const useSelectSessionModalData = () => { const { courseId } = appHooks.useCardCourseRunData(selectedCardId) || {}; const { isEnrolled } = appHooks.useCardEnrollmentData(selectedCardId); - const dispatch = useDispatch(); - const { formatMessage } = useIntl(); - const [selectedSession, setSelectedSession] = module.state.selectedSession(courseId || null); let header; @@ -35,21 +36,26 @@ export const useSelectSessionModalData = () => { header = formatMessage(messages.changeOrLeaveHeader); hint = formatMessage(messages.changeOrLeaveHint); } else { - header = formatMessage(messages.selectSessionHeader, { - courseTitle, - }); + header = formatMessage(messages.selectSessionHeader, { courseTitle }); hint = formatMessage(messages.selectSessionHint); } const updateCardIdCallback = appHooks.useUpdateSelectSessionModalCallback; const closeSessionModal = updateCardIdCallback(null); + const trackNewSession = track.entitlements.newSession(selectedSession); + const trackSwitchSession = track.entitlements.switchSession(selectedCardId, selectedSession); + const trackLeaveSession = track.entitlements.leaveSession(selectedCardId); + const handleSelection = ({ target: { value } }) => setSelectedSession(value); const handleSubmit = () => { if (selectedSession === LEAVE_OPTION) { + trackLeaveSession(); dispatch(thunkActions.app.leaveEntitlementSession(selectedCardId)); } else if (isEnrolled) { + trackSwitchSession(); dispatch(thunkActions.app.switchEntitlementEnrollment(selectedCardId, selectedSession)); } else { + trackNewSession(); dispatch(thunkActions.app.newEntitlementEnrollment(selectedCardId, selectedSession)); } closeSessionModal(); diff --git a/src/containers/UnenrollConfirmModal/hooks.js b/src/containers/UnenrollConfirmModal/hooks.js deleted file mode 100644 index bfaea80..0000000 --- a/src/containers/UnenrollConfirmModal/hooks.js +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react'; - -import { thunkActions } from 'data/redux'; -import { useValueCallback } from 'hooks'; -import { StrictDict } from 'utils'; - -import * as module from './hooks'; - -export const state = StrictDict({ - confirmed: (val) => React.useState(val), // eslint-disable-line - customOption: (val) => React.useState(val), // eslint-disable-line - isSkipped: (val) => React.useState(val), // eslint-disable-line - selectedReason: (val) => React.useState(val), // eslint-disable-line -}); - -export const modalStates = StrictDict({ - confirm: 'confirm', - reason: 'reason', - finished: 'finished', -}); - -export const useUnenrollReasons = ({ - dispatch, - cardId, -}) => { - const [selectedReason, setSelectedReason] = module.state.selectedReason(null); - const [isSkipped, setIsSkipped] = module.state.isSkipped(false); - const [customOption, setCustomOption] = module.state.customOption(''); - - return { - clear: React.useCallback(() => { - setSelectedReason(null); - setCustomOption(''); - setIsSkipped(false); - }, [ - setSelectedReason, - setCustomOption, - setIsSkipped, - ]), - - customOption: { - value: customOption, - onChange: useValueCallback(setCustomOption), - }, - - selected: selectedReason, - selectOption: useValueCallback(setSelectedReason), - - isSkipped, - skip: React.useCallback(() => { - setIsSkipped(true); - dispatch(thunkActions.app.unenrollFromCourse(cardId)); - }, [cardId, dispatch, setIsSkipped]), - isSubmitted: isSkipped, - submit: React.useCallback(() => { - const submittedReason = selectedReason === 'custom' ? customOption : selectedReason; - dispatch(thunkActions.app.unenrollFromCourse(cardId, submittedReason)); - }, [cardId, customOption, dispatch, selectedReason]), - }; -}; - -export const useUnenrollData = ({ closeModal, dispatch, cardId }) => { - const [isConfirmed, setIsConfirmed] = module.state.confirmed(false); - - const confirm = React.useCallback(() => setIsConfirmed(true), [setIsConfirmed]); - - const reason = module.useUnenrollReasons({ - dispatch, - cardId, - }); - - const close = React.useCallback(() => { - closeModal(); - setIsConfirmed(false); - reason.clear(); - }, [ - closeModal, - reason, - setIsConfirmed, - ]); - - let modalState; - if (isConfirmed) { - modalState = reason.isSubmitted ? modalStates.finished : modalStates.reason; - } else { - modalState = modalStates.confirm; - } - - const closeAndRefresh = React.useCallback(() => { - dispatch(thunkActions.app.refreshList()); - closeModal(); - setIsConfirmed(false); - reason.clear(); - }, [ - closeModal, - dispatch, - reason, - setIsConfirmed, - ]); - - return { - isConfirmed, - confirm, - reason, - close, - closeAndRefresh, - modalState, - }; -}; - -export default useUnenrollData; diff --git a/src/containers/UnenrollConfirmModal/hooks.test.js b/src/containers/UnenrollConfirmModal/hooks.test.js deleted file mode 100644 index bf041cb..0000000 --- a/src/containers/UnenrollConfirmModal/hooks.test.js +++ /dev/null @@ -1,197 +0,0 @@ -import { thunkActions } from 'data/redux'; -import { useValueCallback } from 'hooks'; -import { MockUseState } from 'testUtils'; - -import * as hooks from './hooks'; - -jest.mock('hooks', () => ({ - useValueCallback: jest.fn((cb, prereqs) => ({ useValueCallback: { cb, prereqs } })), -})); - -jest.mock('data/redux/thunkActions/app', () => ({ - refreshList: jest.fn((args) => ({ refreshList: args })), - unenrollFromCourse: jest.fn((...args) => ({ unenrollFromCourse: args })), -})); - -const state = new MockUseState(hooks); -const testValue = 'test-value'; -let out; - -describe('UnenrollConfirmModal hooks', () => { - const dispatch = jest.fn(); - const closeModal = jest.fn(); - const cardId = 'test-card-id'; - - const createUseUnenrollReasons = () => hooks.useUnenrollReasons({ dispatch, cardId }); - const createUseUnenrollData = () => hooks.useUnenrollData({ closeModal, dispatch, cardId }); - - describe('state fields', () => { - state.testGetter(state.keys.confirmed); - state.testGetter(state.keys.customOption); - state.testGetter(state.keys.isSkipped); - state.testGetter(state.keys.selectedReason); - }); - describe('useUnenrollReasons', () => { - beforeEach(() => { - state.mock(); - out = createUseUnenrollReasons(); - }); - afterEach(() => { - state.restore(); - }); - describe('clear method', () => { - it('resets selected and submitted reasons, custom option and isSkipped', () => { - const { cb, prereqs } = out.clear.useCallback; - expect(prereqs).toEqual([ - state.setState.selectedReason, - state.setState.customOption, - state.setState.isSkipped, - ]); - cb(); - expect(state.setState.selectedReason).toHaveBeenCalledWith(null); - expect(state.setState.customOption).toHaveBeenCalledWith(''); - expect(state.setState.isSkipped).toHaveBeenCalledWith(false); - }); - }); - test('customOption.value returns custom option', () => { - state.mockVal(state.keys.customOption, testValue); - expect(createUseUnenrollReasons().customOption.value).toEqual(testValue); - }); - test('customOption.onChange returns valueCallback for setCustomOption', () => { - expect(out.customOption.onChange).toEqual(useValueCallback(state.setState.customOption)); - }); - test('selected returns selectedReason', () => { - state.mockVal(state.keys.selectedReason, testValue); - expect(createUseUnenrollReasons().selected).toEqual(testValue); - }); - test('selectedOption returns valueCallback for setSelectedReason', () => { - expect(out.selectOption).toEqual(useValueCallback(state.setState.selectedReason)); - }); - test('isSkipped returns state value', () => { - state.mockVal(state.keys.isSkipped, testValue); - expect(createUseUnenrollReasons().isSkipped).toEqual(testValue); - }); - test('skip returns callback that sets isSkipped to true and unenrolls with no reason', () => { - const { cb, prereqs } = out.skip.useCallback; - expect(prereqs).toEqual([cardId, dispatch, state.setState.isSkipped]); - cb(); - expect(state.setState.isSkipped).toHaveBeenCalledWith(true); - expect(dispatch).toHaveBeenCalledWith(thunkActions.app.unenrollFromCourse(cardId)); - }); - describe('isSubmitted', () => { - it('returns false if submittedReason is null and not isSkipped', () => { - expect(out.isSubmitted).toEqual(false); - }); - it('returns true if isSkipped', () => { - state.mockVal(state.keys.isSkipped, true); - expect(createUseUnenrollReasons().isSubmitted).toEqual(true); - }); - }); - describe('submit', () => { - const customValue = 'custom-value'; - const loadHook = ({ selectedReason, customOption }) => { - state.mockVal(state.keys.selectedReason, selectedReason); - state.mockVal(state.keys.customOption, customOption); - return createUseUnenrollReasons().submit.useCallback; - }; - it('depends on customOption and selectedReason', () => { - const { prereqs } = loadHook({ selectedReason: testValue, customOption: customValue }); - expect(prereqs).toContain(testValue); - expect(prereqs).toContain(customValue); - }); - it('dispatches unenroll action with submitted reason', () => { - loadHook({ selectedReason: testValue, customOption: customValue }).cb(); - expect(dispatch).toHaveBeenCalledWith( - thunkActions.app.unenrollFromCourse(cardId, testValue), - ); - dispatch.mockClear(); - loadHook({ selectedReason: 'custom', customOption: customValue }).cb(); - expect(dispatch).toHaveBeenCalledWith( - thunkActions.app.unenrollFromCourse(cardId, customValue), - ); - }); - }); - }); - describe('modalHooks', () => { - let mockReason; - beforeEach(() => { - mockReason = { - isSubmitted: false, - clear: jest.fn(), - }; - state.mock(); - state.mockVal(state.keys.confirmed, testValue); - hooks.useUnenrollReasons = jest.fn(() => mockReason); - out = createUseUnenrollData(); - }); - afterEach(() => { - state.restore(); - hooks.useUnenrollReasons.mockReset(); - }); - test('isConfirmed is forwarded from state', () => { - expect(out.isConfirmed).toEqual(testValue); - }); - test('confirm is callback that sets isConfirmed to true', () => { - const { cb, prereqs } = out.confirm.useCallback; - expect(prereqs).toEqual([state.setState.confirmed]); - cb(); - expect(state.setState.confirmed).toHaveBeenCalledWith(true); - }); - test('reason returns useUnenrollReasons output', () => { - expect(out.reason).toEqual(mockReason); - }); - describe('close', () => { - test('callback based on reason, setIsConfirmed, and closeModal', () => { - expect(out.close.useCallback.prereqs).toEqual([ - closeModal, - mockReason, - state.setState.confirmed, - ]); - }); - it('calls closeModal, sets isConfirmed to false, and calls reason.clear', () => { - out.close.useCallback.cb(); - expect(closeModal).toHaveBeenCalled(); - expect(state.setState.confirmed).toHaveBeenCalledWith(false); - expect(mockReason.clear).toHaveBeenCalled(); - }); - }); - describe('closeAndRefresh', () => { - test('callback based on prerequisites', () => { - expect(out.closeAndRefresh.useCallback.prereqs).toEqual([ - closeModal, - dispatch, - mockReason, - state.setState.confirmed, - ]); - }); - it('calls closeModal, sets isConfirmed to false, and calls reason.clear', () => { - out.closeAndRefresh.useCallback.cb(); - expect(closeModal).toHaveBeenCalled(); - expect(state.setState.confirmed).toHaveBeenCalledWith(false); - expect(mockReason.clear).toHaveBeenCalled(); - }); - it('dispatches refreshList thunkAction', () => { - out.closeAndRefresh.useCallback.cb(); - expect(dispatch).toHaveBeenCalledWith(thunkActions.app.refreshList()); - }); - }); - describe('modalState', () => { - it('returns modalStates.finished if confirmed and submitted', () => { - state.mockVal(state.keys.confirmed, true); - hooks.useUnenrollReasons = jest.fn(() => ({ ...mockReason, isSubmitted: true })); - out = createUseUnenrollData(); - expect(out.modalState).toEqual(hooks.modalStates.finished); - }); - it('returns modalStates.reason if confirmed and not submitted', () => { - state.mockVal(state.keys.confirmed, true); - out = createUseUnenrollData(); - expect(out.modalState).toEqual(hooks.modalStates.reason); - }); - it('returns modalStates.confirm if not confirmed', () => { - state.mockVal(state.keys.confirmed, false); - out = createUseUnenrollData(); - expect(out.modalState).toEqual(hooks.modalStates.confirm); - }); - }); - }); -}); diff --git a/src/containers/UnenrollConfirmModal/hooks/index.js b/src/containers/UnenrollConfirmModal/hooks/index.js new file mode 100644 index 0000000..5a06201 --- /dev/null +++ b/src/containers/UnenrollConfirmModal/hooks/index.js @@ -0,0 +1,64 @@ +import React from 'react'; + +import { StrictDict } from 'utils'; +import { hooks as appHooks, thunkActions } from 'data/redux'; +import track from 'tracking'; + +import { useUnenrollReasons } from './reasons'; +import * as module from '.'; + +export const state = StrictDict({ + confirmed: (val) => React.useState(val), // eslint-disable-line +}); + +export const modalStates = StrictDict({ + confirm: 'confirm', + reason: 'reason', + finished: 'finished', +}); + +export const useUnenrollData = ({ closeModal, dispatch, cardId }) => { + const [isConfirmed, setIsConfirmed] = module.state.confirmed(false); + const confirm = () => setIsConfirmed(true); + const reason = useUnenrollReasons({ dispatch, cardId }); + const { isEntitlement } = appHooks.useCardEntitlementData(cardId); + const handleTrackReasons = appHooks.useTrackCourseEvent( + track.engagement.unenrollReason, + cardId, + reason.submittedReason, + isEntitlement, + ); + const handleSubmit = () => { + handleTrackReasons(); + dispatch(thunkActions.app.unenrollFromCourse(cardId, reason.submittedReason)); + }; + + let modalState; + if (isConfirmed) { + modalState = reason.isSubmitted ? modalStates.finished : modalStates.reason; + } else { + modalState = modalStates.confirm; + } + + const close = () => { + closeModal(); + setIsConfirmed(false); + reason.clear(); + }; + const closeAndRefresh = () => { + dispatch(thunkActions.app.refreshList()); + close(); + }; + + return { + isConfirmed, + confirm, + reason, + close, + closeAndRefresh, + modalState, + handleSubmit, + }; +}; + +export default useUnenrollData; diff --git a/src/containers/UnenrollConfirmModal/hooks/index.test.js b/src/containers/UnenrollConfirmModal/hooks/index.test.js new file mode 100644 index 0000000..1abea23 --- /dev/null +++ b/src/containers/UnenrollConfirmModal/hooks/index.test.js @@ -0,0 +1,99 @@ +import { thunkActions } from 'data/redux'; +import { MockUseState } from 'testUtils'; + +import * as reasons from './reasons'; +import * as hooks from '.'; + +jest.mock('./reasons', () => ({ + useUnenrollReasons: jest.fn(), +})); + +jest.mock('data/redux/thunkActions/app', () => ({ + refreshList: jest.fn((args) => ({ refreshList: args })), + unenrollFromCourse: jest.fn((...args) => ({ unenrollFromCourse: args })), +})); + +const state = new MockUseState(hooks); +const testValue = 'test-value'; +let out; + +const mockReason = { + clear: jest.fn(), + isSubmitted: false, + submittedReason: 'test-submitted-reason', +}; + +const useUnenrollReasons = jest.fn(() => mockReason); + +describe('UnenrollConfirmModal hooks', () => { + beforeEach(() => { + reasons.useUnenrollReasons.mockImplementation(useUnenrollReasons); + }); + const dispatch = jest.fn(); + const closeModal = jest.fn(); + const cardId = 'test-card-id'; + + const createUseUnenrollData = () => hooks.useUnenrollData({ closeModal, dispatch, cardId }); + + describe('state fields', () => { + state.testGetter(state.keys.confirmed); + }); + describe('modalHooks', () => { + beforeEach(() => { + state.mock(); + state.mockVal(state.keys.confirmed, testValue); + out = createUseUnenrollData(); + }); + afterEach(() => { + state.restore(); + }); + test('isConfirmed is forwarded from state', () => { + expect(out.isConfirmed).toEqual(testValue); + }); + test('confirm is callback that sets isConfirmed to true', () => { + out.confirm(); + expect(state.setState.confirmed).toHaveBeenCalledWith(true); + }); + test('reason returns useUnenrollReasons output', () => { + expect(out.reason).toEqual(mockReason); + }); + describe('close', () => { + it('calls closeModal, sets isConfirmed to false, and calls reason.clear', () => { + out.close(); + expect(closeModal).toHaveBeenCalled(); + expect(state.setState.confirmed).toHaveBeenCalledWith(false); + expect(mockReason.clear).toHaveBeenCalled(); + }); + }); + describe('closeAndRefresh', () => { + it('calls closeModal, sets isConfirmed to false, and calls reason.clear', () => { + out.closeAndRefresh(); + expect(closeModal).toHaveBeenCalled(); + expect(state.setState.confirmed).toHaveBeenCalledWith(false); + expect(mockReason.clear).toHaveBeenCalled(); + }); + it('dispatches refreshList thunkAction', () => { + out.closeAndRefresh(); + expect(dispatch).toHaveBeenCalledWith(thunkActions.app.refreshList()); + }); + }); + describe('modalState', () => { + it('returns modalStates.finished if confirmed and submitted', () => { + state.mockVal(state.keys.confirmed, true); + reasons.useUnenrollReasons.mockReturnValueOnce({ ...mockReason, isSubmitted: true }); + out = createUseUnenrollData(); + expect(out.modalState).toEqual(hooks.modalStates.finished); + }); + it('returns modalStates.reason if confirmed and not submitted', () => { + state.mockVal(state.keys.confirmed, true); + out = createUseUnenrollData(); + expect(out.modalState).toEqual(hooks.modalStates.reason); + }); + it('returns modalStates.confirm if not confirmed', () => { + state.mockVal(state.keys.confirmed, false); + out = createUseUnenrollData(); + expect(out.modalState).toEqual(hooks.modalStates.confirm); + }); + }); + }); +}); diff --git a/src/containers/UnenrollConfirmModal/hooks/reasons.js b/src/containers/UnenrollConfirmModal/hooks/reasons.js new file mode 100644 index 0000000..235e4c0 --- /dev/null +++ b/src/containers/UnenrollConfirmModal/hooks/reasons.js @@ -0,0 +1,43 @@ +import React from 'react'; + +import { thunkActions } from 'data/redux'; +import { useValueCallback } from 'hooks'; +import { StrictDict } from 'utils'; + +import * as module from './reasons'; + +export const state = StrictDict({ + customOption: (val) => React.useState(val), // eslint-disable-line + isSkipped: (val) => React.useState(val), // eslint-disable-line + selectedReason: (val) => React.useState(val), // eslint-disable-line +}); + +export const useUnenrollReasons = ({ + dispatch, + cardId, +}) => { + const [selectedReason, setSelectedReason] = module.state.selectedReason(null); + const [isSkipped, setIsSkipped] = module.state.isSkipped(false); + const [customOption, setCustomOption] = module.state.customOption(''); + const submittedReason = selectedReason === 'custom' ? customOption : selectedReason; + + const clear = () => { + setSelectedReason(null); + setCustomOption(''); + setIsSkipped(false); + }; + const skip = () => { + setIsSkipped(true); + dispatch(thunkActions.app.unenrollFromCourse(cardId)); + }; + + return { + clear, + customOption: { value: customOption, onChange: useValueCallback(setCustomOption) }, + selectOption: useValueCallback(setSelectedReason), + isSkipped, + skip, + isSubmitted: isSkipped, + submittedReason, + }; +}; diff --git a/src/containers/UnenrollConfirmModal/hooks/reasons.test.js b/src/containers/UnenrollConfirmModal/hooks/reasons.test.js new file mode 100644 index 0000000..7a7be37 --- /dev/null +++ b/src/containers/UnenrollConfirmModal/hooks/reasons.test.js @@ -0,0 +1,76 @@ +import { thunkActions } from 'data/redux'; +import { useValueCallback } from 'hooks'; +import { MockUseState } from 'testUtils'; + +import * as hooks from './reasons'; + +jest.mock('hooks', () => ({ + useValueCallback: jest.fn((cb, prereqs) => ({ useValueCallback: { cb, prereqs } })), +})); + +jest.mock('data/redux/thunkActions/app', () => ({ + refreshList: jest.fn((args) => ({ refreshList: args })), + unenrollFromCourse: jest.fn((...args) => ({ unenrollFromCourse: args })), +})); + +const state = new MockUseState(hooks); +const testValue = 'test-value'; +let out; + +describe('UnenrollConfirmModal reasons hooks', () => { + const dispatch = jest.fn(); + const cardId = 'test-card-id'; + + const createUseUnenrollReasons = () => hooks.useUnenrollReasons({ dispatch, cardId }); + + describe('state fields', () => { + state.testGetter(state.keys.customOption); + state.testGetter(state.keys.isSkipped); + state.testGetter(state.keys.selectedReason); + }); + describe('useUnenrollReasons', () => { + beforeEach(() => { + state.mock(); + out = createUseUnenrollReasons(); + }); + afterEach(() => { + state.restore(); + }); + describe('clear method', () => { + it('resets selected and submitted reasons, custom option and isSkipped', () => { + out.clear(); + expect(state.setState.selectedReason).toHaveBeenCalledWith(null); + expect(state.setState.customOption).toHaveBeenCalledWith(''); + expect(state.setState.isSkipped).toHaveBeenCalledWith(false); + }); + }); + test('customOption.value returns custom option', () => { + state.mockVal(state.keys.customOption, testValue); + expect(createUseUnenrollReasons().customOption.value).toEqual(testValue); + }); + test('customOption.onChange returns valueCallback for setCustomOption', () => { + expect(out.customOption.onChange).toEqual(useValueCallback(state.setState.customOption)); + }); + test('selectedOption returns valueCallback for setSelectedReason', () => { + expect(out.selectOption).toEqual(useValueCallback(state.setState.selectedReason)); + }); + test('isSkipped returns state value', () => { + state.mockVal(state.keys.isSkipped, testValue); + expect(createUseUnenrollReasons().isSkipped).toEqual(testValue); + }); + test('skip returns callback that sets isSkipped to true and unenrolls with no reason', () => { + out.skip(); + expect(state.setState.isSkipped).toHaveBeenCalledWith(true); + expect(dispatch).toHaveBeenCalledWith(thunkActions.app.unenrollFromCourse(cardId)); + }); + describe('isSubmitted', () => { + it('returns false if submittedReason is null and not isSkipped', () => { + expect(out.isSubmitted).toEqual(false); + }); + it('returns true if isSkipped', () => { + state.mockVal(state.keys.isSkipped, true); + expect(createUseUnenrollReasons().isSubmitted).toEqual(true); + }); + }); + }); +}); diff --git a/src/data/redux/hooks.js b/src/data/redux/hooks.js index fb50ca9..58d38da 100644 --- a/src/data/redux/hooks.js +++ b/src/data/redux/hooks.js @@ -65,3 +65,8 @@ export const useMasqueradeData = () => useSelector(requestSelectors.masquerade); export const useRequestIsPending = (requestName) => useSelector(requestSelectors.isPending(requestName)); export const useRequestIsFailed = (requestName) => useSelector(requestSelectors.isFailed(requestName)); + +export const useTrackCourseEvent = (tracker, cardId, ...args) => { + const { courseId } = module.useCardCourseRunData(cardId); + return (e) => tracker(courseId, ...args)(e); +}; diff --git a/src/data/redux/thunkActions/app.js b/src/data/redux/thunkActions/app.js index b2e127b..b62a523 100644 --- a/src/data/redux/thunkActions/app.js +++ b/src/data/redux/thunkActions/app.js @@ -1,6 +1,4 @@ import { StrictDict } from 'utils'; -import { handleEvent } from 'data/services/segment/utils'; -import { eventNames } from 'data/services/segment/constants'; import { actions, selectors } from 'data/redux'; import { post } from 'data/services/lms/utils'; @@ -34,10 +32,6 @@ export const sendConfirmEmail = () => (dispatch, getState) => post( export const newEntitlementEnrollment = (cardId, selection) => (dispatch, getState) => { const { uuid } = selectors.app.courseCard.entitlement(getState(), cardId); - handleEvent(eventNames.sessionChange({ action: 'new' }), { - fromCourseRun: null, - toCourseRun: selection, - }); dispatch(requests.newEntitlementEnrollment({ uuid, courseId: selection, @@ -46,12 +40,7 @@ export const newEntitlementEnrollment = (cardId, selection) => (dispatch, getSta }; export const switchEntitlementEnrollment = (cardId, selection) => (dispatch, getState) => { - const { courseId } = selectors.app.courseCard.courseRun(getState(), cardId); const { uuid } = selectors.app.courseCard.entitlement(getState(), cardId); - handleEvent(eventNames.sessionChange({ action: 'switch' }), { - fromCourseRun: courseId, - toCourseRun: selection, - }); dispatch(requests.switchEntitlementEnrollment({ uuid, courseId: selection, @@ -60,12 +49,7 @@ export const switchEntitlementEnrollment = (cardId, selection) => (dispatch, get }; export const leaveEntitlementSession = (cardId) => (dispatch, getState) => { - const { courseId } = selectors.app.courseCard.courseRun(getState(), cardId); const { uuid, isRefundable } = selectors.app.courseCard.entitlement(getState(), cardId); - handleEvent(eventNames.entitlementUnenroll, { - leaveCourseRun: courseId, - isRefundable, - }); dispatch(requests.leaveEntitlementSession({ uuid, isRefundable, @@ -73,16 +57,8 @@ export const leaveEntitlementSession = (cardId) => (dispatch, getState) => { })); }; -export const unenrollFromCourse = (cardId, reason) => (dispatch, getState) => { +export const unenrollFromCourse = (cardId) => (dispatch, getState) => { const { courseId } = selectors.app.courseCard.courseRun(getState(), cardId); - if (reason) { - handleEvent(eventNames.unenrollReason, { - category: 'user-engagement', - displayName: 'v1', - label: reason, - course_id: courseId, - }); - } dispatch(requests.unenrollFromCourse({ courseId, onSuccess: () => dispatch(module.initialize()), diff --git a/src/data/redux/thunkActions/app.test.js b/src/data/redux/thunkActions/app.test.js index e43c37d..d24c4a0 100644 --- a/src/data/redux/thunkActions/app.test.js +++ b/src/data/redux/thunkActions/app.test.js @@ -1,14 +1,11 @@ import { keyStore } from 'utils'; -import { handleEvent } from 'data/services/segment/utils'; -import { eventNames } from 'data/services/segment/constants'; -import { post } from 'data/services/lms/utils'; import { actions, selectors } from 'data/redux'; +import { post } from 'data/services/lms/utils'; + import requests from './requests'; + import * as module from './app'; -jest.mock('data/services/segment/utils', () => ({ - handleEvent: jest.fn(), -})); jest.mock('data/services/lms/utils', () => ({ post: jest.fn(), })); @@ -108,13 +105,6 @@ describe('app thunk actions', () => { beforeEach(() => { module.newEntitlementEnrollment(cardId, selection)(dispatch, getState); }); - it('handles sessionChange(new) tracking event', () => { - expect(selectors.app.courseCard.entitlement).toHaveBeenCalledWith(testState, cardId); - expect(handleEvent).toHaveBeenCalledWith( - eventNames.sessionChange({ action: 'new' }), - { fromCourseRun: null, toCourseRun: selection }, - ); - }); it('dispatches newEntitlementEnrollment request then re-init on success', () => { const request = dispatch.mock.calls[0][0]; expect(request.newEntitlementEnrollment.uuid).toEqual(uuid); @@ -129,14 +119,6 @@ describe('app thunk actions', () => { beforeEach(() => { module.switchEntitlementEnrollment(cardId, selection)(dispatch, getState); }); - it('handles sessionChange(switch) tracking event', () => { - expect(selectors.app.courseCard.courseRun).toHaveBeenCalledWith(testState, cardId); - expect(selectors.app.courseCard.entitlement).toHaveBeenCalledWith(testState, cardId); - expect(handleEvent).toHaveBeenCalledWith( - eventNames.sessionChange({ action: 'switch' }), - { fromCourseRun: courseId, toCourseRun: selection }, - ); - }); it('dispatches switchEntitlementEnrollment request then re-init on success', () => { const request = dispatch.mock.calls[0][0]; expect(request.switchEntitlementEnrollment.uuid).toEqual(uuid); @@ -151,14 +133,6 @@ describe('app thunk actions', () => { beforeEach(() => { module.leaveEntitlementSession(cardId)(dispatch, getState); }); - it('handles sessionChange(leave) tracking event', () => { - expect(selectors.app.courseCard.courseRun).toHaveBeenCalledWith(testState, cardId); - expect(selectors.app.courseCard.entitlement).toHaveBeenCalledWith(testState, cardId); - expect(handleEvent).toHaveBeenCalledWith( - eventNames.entitlementUnenroll, - { leaveCourseRun: courseId, isRefundable }, - ); - }); it('dispatches leaveEntitlementEnrollment request then re-init on success', () => { const request = dispatch.mock.calls[0][0]; expect(request.leaveEntitlementSession.uuid).toEqual(uuid); @@ -174,21 +148,6 @@ describe('app thunk actions', () => { beforeEach(() => { initializeSpy.mockImplementationOnce(mockInitialize); }); - it('handles unenroll reason tracking event if reason provided', () => { - module.unenrollFromCourse(cardId, reason)(dispatch, getState); - expect(selectors.app.courseCard.courseRun).toHaveBeenCalledWith(testState, cardId); - expect(handleEvent).toHaveBeenCalledWith(eventNames.unenrollReason, { - category: 'user-engagement', - displayName: 'v1', - label: reason, - course_id: courseId, - }); - }); - it('does not handle unenroll reason event if reason not provided', () => { - module.unenrollFromCourse(cardId)(dispatch, getState); - expect(selectors.app.courseCard.courseRun).toHaveBeenCalledWith(testState, cardId); - expect(handleEvent).not.toHaveBeenCalled(); - }); it('dispatches unenrollFromCourse request action, re-initializing on success', () => { module.unenrollFromCourse(cardId, reason)(dispatch, getState); const request = dispatch.mock.calls[0][0]; diff --git a/src/data/services/lms/api.js b/src/data/services/lms/api.js index 5dbb8d9..5f3b37c 100644 --- a/src/data/services/lms/api.js +++ b/src/data/services/lms/api.js @@ -1,3 +1,4 @@ +import eventNames from 'tracking/constants'; import { client, get, @@ -10,33 +11,55 @@ import { enableEmailsAction, } from './constants'; import urls from './urls'; +import * as module from './api'; /********************************************************************************* * GET Actions *********************************************************************************/ -const initializeList = ({ user } = {}) => get(stringifyUrl( - urls.init, - { [apiKeys.user]: user }, -)); +export const initializeList = ({ user } = {}) => get( + stringifyUrl(urls.init, { [apiKeys.user]: user }), +); -const updateEntitlementEnrollment = ({ uuid, courseId }) => post( +export const updateEntitlementEnrollment = ({ uuid, courseId }) => post( urls.entitlementEnrollment(uuid), { [apiKeys.courseRunId]: courseId }, ); -const deleteEntitlementEnrollment = ({ uuid, isRefundable }) => client().delete(stringifyUrl( - urls.entitlementEnrollment(uuid), - { [apiKeys.isRefund]: isRefundable }, -)); +export const deleteEntitlementEnrollment = ({ uuid, isRefundable }) => client().delete( + stringifyUrl(urls.entitlementEnrollment(uuid), { [apiKeys.isRefund]: isRefundable }), +); -const updateEmailSettings = ({ courseId, enable }) => post( - stringifyUrl(urls.updateEmailSettings), +export const updateEmailSettings = ({ courseId, enable }) => post( + urls.updateEmailSettings, { [apiKeys.courseId]: courseId, ...(enable && enableEmailsAction) }, ); -const unenrollFromCourse = ({ courseId }) => post(stringifyUrl(urls.courseUnenroll), { - [apiKeys.courseId]: courseId, - ...unenrollmentAction, +export const unenrollFromCourse = ({ courseId }) => post( + urls.courseUnenroll, + { [apiKeys.courseId]: courseId, ...unenrollmentAction }, +); + +export const logEvent = ({ eventName, data, courseId }) => post(urls.event, { + courserun_key: courseId, + event_type: eventName, + page: window.location.href, + event: JSON.stringify(data), +}); + +export const logUpgrade = ({ courseId }) => module.logEvent({ + eventName: eventNames.upgradeButtonClickedEnrollment, + courseId, + data: { location: 'learner-dashboard' }, +}); + +export const logShare = ({ courseId, site }) => module.logEvent({ + eventName: eventNames.shareClicked, + courseId, + data: { + course_id: courseId, + social_media_site: site, + location: 'dashboard', + }, }); export default { @@ -45,4 +68,6 @@ export default { updateEmailSettings, updateEntitlementEnrollment, deleteEntitlementEnrollment, + logUpgrade, + logShare, }; diff --git a/src/data/services/lms/api.test.js b/src/data/services/lms/api.test.js index 69bdb5d..31de3db 100644 --- a/src/data/services/lms/api.test.js +++ b/src/data/services/lms/api.test.js @@ -1,4 +1,7 @@ -import api from './api'; +import { mockLocation } from 'testUtils'; +import { keyStore } from 'utils'; +import eventNames from 'tracking/constants'; +import * as api from './api'; import * as utils from './utils'; import urls from './urls'; import { @@ -20,9 +23,11 @@ jest.mock('./utils', () => { const testUser = 'test-user'; const testUuid = 'test-UUID'; -const testCourseId = 'TEST-course-ID'; +const courseId = 'TEST-course-ID'; const isRefundable = 'test-is-refundable'; +const moduleKeys = keyStore(api); + describe('lms api methods', () => { describe('initializeList', () => { test('calls get with the correct url and user', () => { @@ -37,11 +42,11 @@ describe('lms api methods', () => { describe('updateEntitlementEnrollment', () => { it('calls post on entitlementEnrollment url with uuid and course run ID', () => { expect( - api.updateEntitlementEnrollment({ uuid: testUuid, courseId: testCourseId }), + api.updateEntitlementEnrollment({ uuid: testUuid, courseId }), ).toEqual( utils.post( urls.entitlementEnrollment(testUuid), - { [apiKeys.courseRunId]: testCourseId }, + { [apiKeys.courseRunId]: courseId }, ), ); }); @@ -62,20 +67,19 @@ describe('lms api methods', () => { describe('disable', () => { it('calls post on updateEmailSettings url with course ID', () => { expect( - api.updateEmailSettings({ courseId: testCourseId, enable: false }), + api.updateEmailSettings({ courseId, enable: false }), ).toEqual( - utils.post(utils.stringifyUrl(urls.updateEmailSettings), - { [apiKeys.courseId]: testCourseId }), + utils.post(urls.updateEmailSettings, { [apiKeys.courseId]: courseId }), ); }); }); describe('enable', () => { it('calls post on updateEmailSettings url with course ID and enableEmailsAction', () => { expect( - api.updateEmailSettings({ courseId: testCourseId, enable: true }), + api.updateEmailSettings({ courseId, enable: true }), ).toEqual( - utils.post(utils.stringifyUrl(urls.updateEmailSettings), - { [apiKeys.courseId]: testCourseId, ...enableEmailsAction }), + utils.post(urls.updateEmailSettings, + { [apiKeys.courseId]: courseId, ...enableEmailsAction }), ); }); }); @@ -83,12 +87,52 @@ describe('lms api methods', () => { describe('unenrollFromCourse', () => { it('calls post on unenrollFromCourse url with courseId and unenrollment action', () => { expect( - api.unenrollFromCourse({ courseId: testCourseId }), + api.unenrollFromCourse({ courseId }), ).toEqual( - utils.post(utils.stringifyUrl( - urls.courseUnenroll, - ), { [apiKeys.courseId]: testCourseId, ...unenrollmentAction }), + utils.post(urls.courseUnenroll, + { [apiKeys.courseId]: courseId, ...unenrollmentAction }), ); }); }); + describe('logging events', () => { + describe('logEvent', () => { + it('posts to event url with event data', () => { + const href = 'test-href'; + const eventName = 'test-event-key'; + const data = { some: 'data' }; + mockLocation(href); + expect( + api.logEvent({ courseId, eventName, data }), + ).toEqual( + utils.post(urls.event, { + courserun_key: courseId, + event_type: eventName, + page: href, + event: JSON.stringify(data), + }), + ); + }); + }); + describe('logged events', () => { + const logEvent = (args) => ({ logEvent: args }); + beforeEach(() => { + jest.spyOn(api, moduleKeys.logEvent).mockImplementation(logEvent); + }); + test('logUpgrade sends enrollment upgrade click event with learner dashboard location', () => { + expect(api.logUpgrade({ courseId })).toEqual(logEvent({ + eventName: eventNames.upgradeButtonClickedEnrollment, + courseId, + data: { location: 'learner-dashboard' }, + })); + }); + test('logShare sends share clicke vent with course id, side and location', () => { + const site = 'test-site'; + expect(api.logShare({ courseId, site })).toEqual(logEvent({ + eventName: eventNames.shareClicked, + courseId, + data: { course_id: courseId, social_media_site: site, location: 'dashboard' }, + })); + }); + }); + }); }); diff --git a/src/data/services/lms/urls.js b/src/data/services/lms/urls.js index c0ce0ec..d95c369 100644 --- a/src/data/services/lms/urls.js +++ b/src/data/services/lms/urls.js @@ -8,6 +8,7 @@ const api = `${baseUrl}/api`; // const init = `${api}learner_home/mock/init`; // mock endpoint for testing const init = `${api}/learner_home/init`; +const event = `${baseUrl}/event`; const courseUnenroll = `${baseUrl}/change_enrollment`; const updateEmailSettings = `${api}/change_email_settings`; const entitlementEnrollment = (uuid) => `${api}/entitlements/v1/entitlements/${uuid}/enrollments`; @@ -23,11 +24,12 @@ const programsUrl = baseAppUrl('/dashboard/programs'); export default StrictDict({ api, - init, - courseUnenroll, - updateEmailSettings, - entitlementEnrollment, baseAppUrl, + courseUnenroll, + entitlementEnrollment, + event, + init, learningMfeUrl, programsUrl, + updateEmailSettings, }); diff --git a/src/data/services/segment/constants.js b/src/data/services/segment/constants.js deleted file mode 100644 index 807e774..0000000 --- a/src/data/services/segment/constants.js +++ /dev/null @@ -1,21 +0,0 @@ -import { StrictDict } from 'utils'; - -export const events = StrictDict({ - courseEnroll: 'courseEnroll', - entitlementUnenroll: 'entitlementUnenroll', - sessionChange: 'sessionChange', - unenrollReason: 'unenrollReason', - upgradeCourse: 'upgradeCourse', -}); - -export const eventNames = StrictDict({ - [events.courseEnroll]: 'edx.bi.user.program-details.enrollment', - [events.upgradeCourse]: 'learner_home.course_card.upgrade', - [events.entitlementUnenroll]: 'entitlement_unenrollment_reason.selected', - [events.sessionChange]: ({ action }) => `course-dashboard.${action}-session`, // 'switch', 'new', 'leave' - [events.unenrollReason]: 'unenrollment_reason.selected', -}); - -export const trackingCategory = 'learner-home'; - -export const pageViewEvent = { category: trackingCategory }; diff --git a/src/data/services/segment/utils.js b/src/data/services/segment/utils.js index 6237c70..65f3064 100755 --- a/src/data/services/segment/utils.js +++ b/src/data/services/segment/utils.js @@ -1,18 +1,16 @@ /* eslint-disable import/prefer-default-export */ -import { trackEvent } from '@redux-beacon/segment'; -import { trackingCategory as category } from './constants'; +import { sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { appName } from 'tracking/constants'; -export const handleEvent = (name, options = {}) => trackEvent( - (event = {}) => { - const { payload } = event; - const { propsFn, extrasFn } = options; - return { - name, - ...(extrasFn && extrasFn(payload)), - properties: { - category, - ...(propsFn && propsFn(payload)), - }, - }; - }, +export const LINK_TIMEOUT = 300; + +export const createEventTracker = (name, options = {}) => () => sendTrackEvent( + name, + { ...options, app_name: appName }, ); + +export const createLinkTracker = (tracker, href) => (e) => { + e.preventDefault(); + tracker(); + return setTimeout(() => { global.location.href = href; }, LINK_TIMEOUT); +}; diff --git a/src/data/services/segment/utils.test.js b/src/data/services/segment/utils.test.js index 58ebfe2..1a5f32b 100644 --- a/src/data/services/segment/utils.test.js +++ b/src/data/services/segment/utils.test.js @@ -1,49 +1,35 @@ -import * as constants from './constants'; -import { handleEvent } from './utils'; +import { sendTrackEvent } from '@edx/frontend-platform/analytics'; -jest.mock('@redux-beacon/segment', () => ({ - trackEvent: (handleFn) => ({ trackEvent: handleFn }), +import { appName } from 'tracking/constants'; + +import { createEventTracker, createLinkTracker, LINK_TIMEOUT } from './utils'; + +jest.useFakeTimers(); +jest.spyOn(global, 'setTimeout'); + +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendTrackEvent: jest.fn(), })); -const category = 'AFakeCategory'; describe('segment service utils', () => { - beforeAll(() => { - global.window = Object.create(window); - constants.trackingCategory = category; + describe('createEventTracker', () => { + const name = 'aName'; + const options = { field1: 'some data', field2: 'other data' }; + it('call sendTrackEvent', () => { + createEventTracker(name, options)(); + expect(sendTrackEvent).toHaveBeenCalledWith(name, { ...options, app_name: appName }); + }); }); - describe('handleEvent', () => { - const name = 'aName'; - const payload = { field1: 'some data', field2: 'other data' }; - describe('when called with just a name', () => { - it('returns a TrackEvent call with the name and tracking category', () => { - const handler = handleEvent(name).trackEvent; - expect(handler(payload)).toEqual({ - name, - properties: { category }, - }); - }); - }); - describe('when a propsFn is provided', () => { - it('adds the output of propsFn(event.payload) to properties', () => { - const propsFn = ({ field1 }) => ({ field1 }); - const handler = handleEvent(name, { propsFn }).trackEvent; - expect(handler({ payload })).toEqual({ - name, - properties: { category, field1: payload.field1 }, - }); - }); - }); - describe('when an extrasFn object is provided', () => { - it('adds the output of extrasFn(event.payload) to top-level object', () => { - const extrasFn = ({ field2 }) => ({ field2 }); - const handler = handleEvent(name, { extrasFn }).trackEvent; - expect(handler({ payload })).toEqual({ - name, - field2: payload.field2, - properties: { category }, - }); - }); + describe('createLinkTracker', () => { + const tracker = jest.fn(); + const href = 'https://www.example.com'; + const event = { preventDefault: jest.fn() }; + it('call tracker', () => { + createLinkTracker(tracker, href)(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(tracker).toHaveBeenCalled(); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), LINK_TIMEOUT); }); }); }); diff --git a/src/index.jsx b/src/index.jsx index 9215f16..3784b92 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -37,16 +37,15 @@ subscribe(APP_READY, () => { subscribe(APP_INIT_ERROR, (error) => { ReactDOM.render( - , + + + , document.getElementById('root'), ); }); export const appName = 'LearnerHomeAppConfig'; -// TODO: remove dev debug -console.log({ SEGEMENT_KEY: process.env.SEGMENT_KEY }); - initialize({ handlers: { config: () => { diff --git a/src/testUtils.js b/src/testUtils.js index e39c8c3..7955134 100644 --- a/src/testUtils.js +++ b/src/testUtils.js @@ -205,3 +205,9 @@ export class MockUseState { return StrictDict({ ...this.hooks.state }); } } + +export const mockLocation = (href) => { + delete global.window.location; + global.window = Object.create(window); + global.window.location = { href }; +}; diff --git a/src/tracking/constants.js b/src/tracking/constants.js new file mode 100644 index 0000000..b561af4 --- /dev/null +++ b/src/tracking/constants.js @@ -0,0 +1,53 @@ +import { StrictDict } from 'utils'; + +export const categories = StrictDict({ + dashboard: 'dashboard', + upgrade: 'upgrade', + userEngagement: 'user-engagement', +}); + +export const events = StrictDict({ + enterCourseClicked: 'enterCourseClicked', + courseImageClicked: 'courseImageClicked', + courseTitleClicked: 'courseTitleClicked', + courseOptionsDropdownClicked: 'courseOptionsDropdownClicked', + upgradeButtonClicked: 'upgradeButtonClicked', + upgradeButtonClickedEnrollment: 'upgradeButtonClickedEnrollment', + upgradeButtonClickedUpsell: 'upgradeButtonClickedUpsell', + shareClicked: 'shareClicked', + userSettingsChanged: 'userSettingsChanged', + newSession: 'newSession', + switchSession: 'switchSession', + leaveSession: 'leaveSession', + unenrollReason: 'unenrollReason', + entitlementUnenrollReason: 'entitlementUnenrollReason', + enterpriseDashboardModalOpened: 'enterpriseDashboardModalOpened', + enterpriseDashboardModalCTAClicked: 'enterpriseDashboardModalCTAClicked', + enterpriseDashboardModalClosed: 'enterpriseDashboardModalClosed', +}); + +const learnerPortal = 'edx.ui.enterprise.lms.dashboard.learner_portal_modal'; + +export const eventNames = StrictDict({ + enterCourseClicked: 'edx.bi.dashboard.enter_course.clicked', + courseImageClicked: 'edx.bi.dashboard.course_image.clicked', + courseTitleClicked: 'edx.bi.dashboard.course_title.clicked', + courseOptionsDropdownClicked: 'edx.bi.dashboard.course_options_dropdown.clicked', + upgradeButtonClicked: 'edx.bi.dashboard.upgrade_button.clicked', + upgradeButtonClickedEnrollment: 'edx.course.enrollment.upgrade.clicked', + upgradeButtonClickedUpsell: 'edx.bi.ecommerce.upsell_links_clicked', + shareClicked: 'edx.course.share_clicked', + userSettingsChanged: 'edx.user.settings.changed', + newSession: 'course-dashboard.new-session', + switchSession: 'course-dashboard.switch-session', + leaveSession: 'course-dashboard.leave-session', + unenrollReason: 'unenrollment_reason.selected', + entitlementUnenrollReason: 'entitlement_unenrollment_reason.selected', + enterpriseDashboardModalOpened: `${learnerPortal}.opened`, + enterpriseDashboardModalCTAClicked: `${learnerPortal}.dashboard_cta.clicked`, + enterpriseDashboardModalClosed: `${learnerPortal}.closed`, +}); + +export const appName = 'learner-home'; + +export default eventNames; diff --git a/src/tracking/index.js b/src/tracking/index.js new file mode 100644 index 0000000..cccd169 --- /dev/null +++ b/src/tracking/index.js @@ -0,0 +1,13 @@ +import course from './trackers/course'; +import engagement from './trackers/engagement'; +import enterpriseDashboard from './trackers/enterpriseDashboard'; +import entitlements from './trackers/entitlements'; +import socialShare from './trackers/socialShare'; + +export default { + course, + engagement, + enterpriseDashboard, + entitlements, + socialShare, +}; diff --git a/src/tracking/trackers/course.js b/src/tracking/trackers/course.js new file mode 100644 index 0000000..8b44be7 --- /dev/null +++ b/src/tracking/trackers/course.js @@ -0,0 +1,75 @@ +import api from 'data/services/lms/api'; +import { createEventTracker, createLinkTracker } from 'data/services/segment/utils'; +import { categories, eventNames } from '../constants'; +import * as module from './course'; + +export const upsellOptions = { + linkName: 'course_dashboard_green', + linkType: 'button', + pageName: 'course_dashboard', + linkCategory: 'green_update', +}; + +// Utils/Helpers +/** + * Generate a segement event tracker for a given course event. + * @param {string} eventName - segment event name + * @param {string} courseId - course run identifier + * @param {[object]} options - optional event data + */ +export const courseEventTracker = (eventName, courseId, options = {}) => createEventTracker( + eventName, + { category: categories.dashboard, label: courseId, ...options }, +); +/** + * Generate a hook to allow components to provide a courseId and link href and provide + * a link tracker with defined event name and options, over a set of default optiosn. + * @param {string} eventName - event name for the click event + * @return {callback} - component hook returning a link tracking event callback + */ +export const courseLinkTracker = (eventName) => (courseId, href) => ( + createLinkTracker(module.courseEventTracker(eventName, courseId), href) +); + +// Upgrade Events +/** + * There are currently multiple tracked api events for the upgrade event, with different targets. + * Goal here is to split out the tracked events for easier testing. + */ +export const upgradeButtonClicked = (courseId) => createEventTracker( + eventNames.upgradeButtonClicked, + { category: categories.upgrade, label: courseId }, +); +export const upgradeButtonClickedUpsell = (courseId) => createEventTracker( + eventNames.upgradeButtonClickedUpsell, + { ...upsellOptions, courseId }, +); + +// Non-Link events +export const courseOptionsDropdownClicked = (courseId) => ( + module.courseEventTracker(eventNames.courseOptionsDropdownClicked, courseId) +); + +// Link events (track and then change page location) +export const courseImageClicked = (...args) => ( + module.courseLinkTracker(eventNames.courseImageClicked)(...args)); +export const courseTitleClicked = (...args) => ( + module.courseLinkTracker(eventNames.courseTitleClicked)(...args)); +export const enterCourseClicked = (...args) => ( + module.courseLinkTracker(eventNames.enterCourseClicked)(...args)); +export const upgradeClicked = (courseId, href) => createLinkTracker( + () => { + module.upgradeButtonClicked(courseId); + module.upgradeButtonClickedUpsell(courseId); + api.logUpgrade({ courseId }); + }, + href, +); + +export default { + courseImageClicked, + courseOptionsDropdownClicked, + courseTitleClicked, + enterCourseClicked, + upgradeClicked, +}; diff --git a/src/tracking/trackers/course.test.js b/src/tracking/trackers/course.test.js new file mode 100644 index 0000000..1650ce3 --- /dev/null +++ b/src/tracking/trackers/course.test.js @@ -0,0 +1,119 @@ +import { keyStore } from 'utils'; +import api from 'data/services/lms/api'; +import { createEventTracker, createLinkTracker } from 'data/services/segment/utils'; +import { categories, eventNames } from '../constants'; +import * as trackers from './course'; + +jest.mock('data/services/lms/api', () => ({ + logUpgrade: jest.fn(), +})); + +jest.mock('data/services/segment/utils', () => ({ + createEventTracker: jest.fn(args => ({ createEventTracker: args })), + createLinkTracker: jest.fn((cb, href) => ({ createLinkTracker: { cb, href } })), +})); + +const testEventName = 'test-event-name'; +const courseId = 'test-course-id'; +const options = { test: 'options' }; +const href = 'test-href'; +const moduleKeys = keyStore(trackers); + +describe('course trackers', () => { + describe('Utilities and helpers', () => { + describe('courseEventTracker', () => { + it('calls createEventTracker w/ label, category and passed options', () => { + expect(trackers.courseEventTracker(testEventName, courseId, options)).toEqual( + createEventTracker( + testEventName, + { category: categories.dashboard, label: courseId, test: options.test }, + ), + ); + }); + it('defaults to passing an empty object for options if not provided', () => { + expect(trackers.courseEventTracker(testEventName, courseId)).toEqual( + createEventTracker(testEventName, { category: categories.dashboard, label: courseId }), + ); + }); + }); + describe('courseLinkTracker', () => { + it('returns link tracker creation method', () => { + expect(trackers.courseLinkTracker(testEventName)(courseId, href)).toEqual( + createLinkTracker(trackers.courseEventTracker(testEventName, courseId), href), + ); + }); + }); + }); + describe('Upgrade Events', () => { + describe('upgradeButtonClicked', () => { + it('creates an event tracker for upgradeButtonClicked event with category and label', () => { + expect(trackers.upgradeButtonClicked(courseId)).toEqual(createEventTracker( + eventNames.upgradeButtonClicked, + { category: categories.upgrade, label: courseId }, + )); + }); + }); + describe('upgradeButtonClickedUpsell', () => { + it('creates an event tracker for upgradeButtonClickedUpsell eventwith upsellOptions', () => { + expect(trackers.upgradeButtonClickedUpsell(courseId)).toEqual( + createEventTracker(eventNames.upgradeButtonClickedUpsell, + { ...trackers.upsellOptions, courseId }), + ); + }); + }); + }); + describe('Non-link events', () => { + describe('courseOptionsDropdownClicked', () => { + it('creates course event tracker for courseOptionsDropdownClicked event', () => { + expect(trackers.courseOptionsDropdownClicked(courseId)).toEqual( + trackers.courseEventTracker(eventNames.courseOptionsDropdownClicked, courseId), + ); + }); + }); + }); + describe('Link events', () => { + const courseLinkTracker = (eventName) => (...args) => ({ + courseLinkTracker: { eventName, ...args }, + }); + beforeEach(() => { + jest.spyOn(trackers, moduleKeys.courseLinkTracker).mockImplementationOnce(courseLinkTracker); + }); + describe('courseImageClicked', () => { + it('creates courseLinkTracker for courseImageClicked event', () => { + expect(trackers.courseImageClicked(courseId, href)).toEqual( + courseLinkTracker(eventNames.courseImageClicked)(courseId, href), + ); + }); + }); + describe('courseTitleClicked', () => { + it('creates courseLinkTracker for courseTitleClicked event', () => { + expect(trackers.courseTitleClicked(courseId, href)).toEqual( + courseLinkTracker(eventNames.courseTitleClicked)(courseId, href), + ); + }); + }); + describe('enterCourseClicked', () => { + it('creates courseLinkTracker for enterCourseClicked event', () => { + expect(trackers.enterCourseClicked(courseId, href)).toEqual( + courseLinkTracker(eventNames.enterCourseClicked)(courseId, href), + ); + }); + }); + describe('upgradeClicked', () => { + it('triggers upgrade actions and api.logUpgrade with courseId', () => { + const upgradeButtonClicked = jest.fn(); + const upgradeButtonClickedUpsell = jest.fn(); + jest.spyOn(trackers, moduleKeys.upgradeButtonClicked) + .mockImplementationOnce(upgradeButtonClicked); + jest.spyOn(trackers, moduleKeys.upgradeButtonClickedUpsell) + .mockImplementationOnce(upgradeButtonClickedUpsell); + const out = trackers.upgradeClicked(courseId, href).createLinkTracker; + expect(out.href).toEqual(href); + out.cb(); + expect(upgradeButtonClicked).toHaveBeenCalledWith(courseId); + expect(upgradeButtonClickedUpsell).toHaveBeenCalledWith(courseId); + expect(api.logUpgrade).toHaveBeenCalledWith({ courseId }); + }); + }); + }); +}); diff --git a/src/tracking/trackers/engagement.js b/src/tracking/trackers/engagement.js new file mode 100644 index 0000000..4e0bf01 --- /dev/null +++ b/src/tracking/trackers/engagement.js @@ -0,0 +1,23 @@ +import { createEventTracker } from 'data/services/segment/utils'; +import { categories, eventNames } from '../constants'; + +export const engagementOptions = { + category: categories.userEngagement, + displayName: 'v1', +}; + +/** + * Creates callback which sends segment event for unenroll with reason event + * @param {string} courseId - course run identifier + * @param {string} reason - unenroll reason + * @param {bool} isEntitlement - is the course an entitlement course? + * @return {callback} - callback that will send the appropriate segment message. + */ +export const unenrollReason = (courseId, reason, isEntitlement) => () => createEventTracker( + isEntitlement ? eventNames.entitlementUnenrollReason : eventNames.unenrollReason, + { reason, course_id: courseId, ...engagementOptions }, +); + +export default { + unenrollReason, +}; diff --git a/src/tracking/trackers/engagement.test.js b/src/tracking/trackers/engagement.test.js new file mode 100644 index 0000000..71281f9 --- /dev/null +++ b/src/tracking/trackers/engagement.test.js @@ -0,0 +1,31 @@ +import { createEventTracker } from 'data/services/segment/utils'; +import { eventNames } from '../constants'; +import * as trackers from './engagement'; + +jest.mock('data/services/segment/utils', () => ({ + createEventTracker: jest.fn(args => ({ createEventTracker: args })), +})); + +const courseId = 'test-course-id'; +const reason = 'test-reason'; + +describe('engagement trackers', () => { + describe('unenrollReason', () => { + test('creates event tracker for unenrollReason if not entitlement', () => { + expect(trackers.unenrollReason(courseId, reason, false)()).toEqual( + createEventTracker( + eventNames.unenrollReason, + { reason, course_id: courseId, ...trackers.engagementOptions }, + ), + ); + }); + test('creates event tracker for entitlementUnenrollReason if entitlement', () => { + expect(trackers.unenrollReason(courseId, reason, false)()).toEqual( + createEventTracker( + eventNames.unenrollReason, + { reason, course_id: courseId, ...trackers.engagementOptions }, + ), + ); + }); + }); +}); diff --git a/src/tracking/trackers/enterpriseDashboard.js b/src/tracking/trackers/enterpriseDashboard.js new file mode 100644 index 0000000..ac71d6e --- /dev/null +++ b/src/tracking/trackers/enterpriseDashboard.js @@ -0,0 +1,44 @@ +import { createEventTracker, createLinkTracker } from 'data/services/segment/utils'; +import { eventNames } from '../constants'; + +/** Enterprise Dashboard events**/ +/** + * Creates tracking callback for Enterprise Dashboard Modal open event + * @param {string} enterpriseUUID - enterprise identifier + * @return {func} - Callback that tracks the event when fired. + */ +export const modalOpened = (enterpriseUUID) => () => createEventTracker( + eventNames.enterpriseDashboardModalOpened, + { enterpriseUUID }, +); + +/** + * Creates tracking callback for Enterprise Dashboard Modal Call-to-action click-event + * @param {string} enterpriseUUID - enterprise identifier + * @param {string} href - destination url + * @return {func} - Callback that tracks the event when fired and then loads the passed href. + */ +export const modalCTAClicked = (enterpriseUUID, href) => createLinkTracker( + () => createEventTracker( + eventNames.enterpriseDashboardModalCTAClicked, + { enterpriseUUID }, + ), + href, +); + +/** + * Creates tracking callback for Enterprise Dashboard Modal close event + * @param {string} enterpriseUUID - enterprise identifier + * @param {string} source - close event soruce ("Cancel button" vs "Escape") + * @return {func} - Callback that tracks the event when fired. + */ +export const modalClosed = (enterpriseUUID, source) => () => createEventTracker( + eventNames.enterpriseDashboardModalClosed, + { enterpriseUUID, source }, +); + +export default { + modalOpened, + modalCTAClicked, + modalClosed, +}; diff --git a/src/tracking/trackers/enterpriseDashboard.test.js b/src/tracking/trackers/enterpriseDashboard.test.js new file mode 100644 index 0000000..ea7cd7e --- /dev/null +++ b/src/tracking/trackers/enterpriseDashboard.test.js @@ -0,0 +1,38 @@ +import { createEventTracker } from 'data/services/segment/utils'; +import { eventNames } from '../constants'; +import * as trackers from './enterpriseDashboard'; + +jest.mock('data/services/segment/utils', () => ({ + createEventTracker: jest.fn(args => ({ createEventTracker: args })), + createLinkTracker: jest.fn((cb, href) => ({ createLinkTracker: { cb, href } })), +})); + +const enterpriseUUID = 'test-enterprise-uuid'; +const source = 'test-source'; + +describe('enterpriseDashboard trackers', () => { + describe('modalOpened', () => { + it('creates event tracker for dashboard modal opened event', () => { + expect(trackers.modalOpened(enterpriseUUID, source)()).toEqual( + createEventTracker(eventNames.enterpriseDashboardModalOpened, { enterpriseUUID, source }), + ); + }); + }); + describe('modalCTAClicked', () => { + const testHref = 'test-href'; + it('creates link tracker for dashboard modal cta click event', () => { + const { cb, href } = trackers.modalCTAClicked(enterpriseUUID, testHref).createLinkTracker; + expect(href).toEqual(testHref); + expect(cb()).toEqual( + createEventTracker(eventNames.enterpriseDashboardModalCTAClicked, { enterpriseUUID, source }), + ); + }); + }); + describe('modalClosed', () => { + it('creates event tracker for dashboard modal closed event with close source', () => { + expect(trackers.modalClosed(enterpriseUUID, source)()).toEqual( + createEventTracker(eventNames.enterpriseDashboardModalClosed, { enterpriseUUID, source }), + ); + }); + }); +}); diff --git a/src/tracking/trackers/entitlements.js b/src/tracking/trackers/entitlements.js new file mode 100644 index 0000000..41417fd --- /dev/null +++ b/src/tracking/trackers/entitlements.js @@ -0,0 +1,34 @@ +import { createEventTracker } from 'data/services/segment/utils'; +import { eventNames } from '../constants'; + +/** + * Create event tracker for leave entitlement session event + * @param {string} fromCourseRun - course run identifier for leaving course + * @return {callback} - callback that triggers the event tracker + */ +export const leaveSession = (fromCourseRun) => () => ( + createEventTracker(eventNames.leaveSession, { fromCourseRun, toCourseRun: null }) +); +/** + * Create event tracker for new entitlement session event + * @param {string} toCourseRun - course run identifier for new course + * @return {callback} - callback that triggers the event tracker + */ +export const newSession = (toCourseRun) => () => ( + createEventTracker(eventNames.newSession, { fromCourseRun: null, toCourseRun }) +); +/** + * Create event tracker for switch entitlement session event + * @param {string} fromCourseRun - course run identifier for leaving course + * @param {string} toCourseRun - course run identifier for new course + * @return {callback} - callback that triggers the event tracker + */ +export const switchSession = (fromCourseRun, toCourseRun) => () => ( + createEventTracker(eventNames.switchSession, { fromCourseRun, toCourseRun }) +); + +export default { + leaveSession, + newSession, + switchSession, +}; diff --git a/src/tracking/trackers/entitlements.test.js b/src/tracking/trackers/entitlements.test.js new file mode 100644 index 0000000..d8c46c6 --- /dev/null +++ b/src/tracking/trackers/entitlements.test.js @@ -0,0 +1,34 @@ +import { createEventTracker } from 'data/services/segment/utils'; +import { eventNames } from '../constants'; +import * as trackers from './entitlements'; + +jest.mock('data/services/segment/utils', () => ({ + createEventTracker: jest.fn(args => ({ createEventTracker: args })), +})); + +const fromCourseRun = 'test-from-course-run'; +const toCourseRun = 'test-to-course-run'; + +describe('entitlements trackers', () => { + describe('leaveSession', () => { + it('creates event tracker for leaveSession event', () => { + expect(trackers.leaveSession(fromCourseRun)()).toEqual( + createEventTracker(eventNames.leaveSession, { fromCourseRun, toCourseRun: null }), + ); + }); + }); + describe('newSession', () => { + it('creates event tracker for newSession event', () => { + expect(trackers.newSession(toCourseRun)()).toEqual( + createEventTracker(eventNames.newSession, { fromCourseRun: null, toCourseRun }), + ); + }); + }); + describe('switchSession', () => { + it('creates event tracker for switchSession event', () => { + expect(trackers.switchSession(fromCourseRun, toCourseRun)()).toEqual( + createEventTracker(eventNames.switchSession, { fromCourseRun, toCourseRun }), + ); + }); + }); +}); diff --git a/src/tracking/trackers/socialShare.js b/src/tracking/trackers/socialShare.js new file mode 100644 index 0000000..09b4bf1 --- /dev/null +++ b/src/tracking/trackers/socialShare.js @@ -0,0 +1,11 @@ +import api from 'data/services/lms/api'; + +/** + * Track Social Share event click. + * @param {string} courseId - course run identifier + * @param {string} site - sharing destination ('facebook', 'twitter') + * @return {func} - Callback that tracks the event when fired. + */ +export const shareClicked = (courseId, site) => () => api.trackShare({ courseId, site }); + +export default shareClicked; diff --git a/src/tracking/trackers/socialShare.test.js b/src/tracking/trackers/socialShare.test.js new file mode 100644 index 0000000..b4f656b --- /dev/null +++ b/src/tracking/trackers/socialShare.test.js @@ -0,0 +1,17 @@ +import api from 'data/services/lms/api'; +import * as trackers from './socialShare'; + +jest.mock('data/services/lms/api', () => ({ + trackShare: jest.fn(args => ({ trackShare: args })), +})); + +const courseId = 'test-course-id'; +const site = 'test-site'; + +describe('entitlements trackers', () => { + describe('shareClicked', () => { + it('creates event tracker for trackShare api event', () => { + expect(trackers.shareClicked(courseId, site)()).toEqual(api.trackShare({ courseId, site })); + }); + }); +}); diff --git a/src/widgets/LookingForChallengeWidget/__snapshots__/index.test.jsx.snap b/src/widgets/LookingForChallengeWidget/__snapshots__/index.test.jsx.snap index 733e816..f13ad67 100644 --- a/src/widgets/LookingForChallengeWidget/__snapshots__/index.test.jsx.snap +++ b/src/widgets/LookingForChallengeWidget/__snapshots__/index.test.jsx.snap @@ -19,7 +19,6 @@ exports[`LookingForChallengeWidget snapshots default 1`] = ` ); -export const LookingForChallengeWidget = ({ - courseSearchClickTracker, -}) => { +export const LookingForChallengeWidget = () => { const { courseSearchUrl } = hooks.usePlatformSettingsData(); const { formatMessage } = useIntl(); return ( @@ -32,7 +30,7 @@ export const LookingForChallengeWidget = ({ {formatMessage(messages.findCoursesButton, { arrow: arrowIcon })} @@ -43,8 +41,6 @@ export const LookingForChallengeWidget = ({ ); }; -LookingForChallengeWidget.propTypes = { - courseSearchClickTracker: PropTypes.func.isRequired, -}; +LookingForChallengeWidget.propTypes = {}; export default LookingForChallengeWidget; diff --git a/src/widgets/LookingForChallengeWidget/index.test.jsx b/src/widgets/LookingForChallengeWidget/index.test.jsx index 10f2d0a..6114679 100644 --- a/src/widgets/LookingForChallengeWidget/index.test.jsx +++ b/src/widgets/LookingForChallengeWidget/index.test.jsx @@ -10,13 +10,14 @@ jest.mock('data/redux', () => ({ }, })); +jest.mock('../RecommendationsPanel/track', () => ({ + findCoursesClicked: jest.fn().mockName('track.findCoursesClicked'), +})); + describe('LookingForChallengeWidget', () => { - const props = { - courseSearchClickTracker: jest.fn().mockName('courseSearchClickTracker'), - }; describe('snapshots', () => { test('default', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/src/widgets/RecommendationsPanel/LoadedView.jsx b/src/widgets/RecommendationsPanel/LoadedView.jsx index acefa44..20f1c3a 100644 --- a/src/widgets/RecommendationsPanel/LoadedView.jsx +++ b/src/widgets/RecommendationsPanel/LoadedView.jsx @@ -6,6 +6,7 @@ import { Button } from '@edx/paragon'; import { Search } from '@edx/paragon/icons'; import { hooks } from 'data/redux'; +import track from './track'; import CourseCard from './components/CourseCard'; import messages from './messages'; @@ -14,7 +15,6 @@ import './index.scss'; export const LoadedView = ({ courses, isPersonalizedRecommendation, - courseSearchClickTracker, }) => { const { courseSearchUrl } = hooks.usePlatformSettingsData(); const { formatMessage } = useIntl(); @@ -39,7 +39,7 @@ export const LoadedView = ({ iconBefore={Search} as="a" href={courseSearchUrl} - onClick={courseSearchClickTracker} + onClick={track.findCoursesClicked(courseSearchUrl)} > {formatMessage(messages.exploreCoursesButton)} @@ -56,7 +56,6 @@ LoadedView.propTypes = { marketingUrl: PropTypes.string, })).isRequired, isPersonalizedRecommendation: PropTypes.bool.isRequired, - courseSearchClickTracker: PropTypes.func.isRequired, }; export default LoadedView; diff --git a/src/widgets/RecommendationsPanel/LoadedView.test.jsx b/src/widgets/RecommendationsPanel/LoadedView.test.jsx index ef9928b..2f5cb14 100644 --- a/src/widgets/RecommendationsPanel/LoadedView.test.jsx +++ b/src/widgets/RecommendationsPanel/LoadedView.test.jsx @@ -13,12 +13,14 @@ jest.mock('data/redux', () => ({ }), }, })); +jest.mock('./track', () => ({ + findCoursesClicked: () => 'find-courses-clicked', +})); describe('RecommendationsPanel LoadedView', () => { const props = { courses: mockData.courses, isPersonalizedRecommendation: false, - courseSearchClickTracker: jest.fn().mockName('courseSearchClickTracker'), }; describe('snapshot', () => { test('without personalize recommendation', () => { diff --git a/src/widgets/RecommendationsPanel/__snapshots__/LoadedView.test.jsx.snap b/src/widgets/RecommendationsPanel/__snapshots__/LoadedView.test.jsx.snap index b2b3309..d074753 100644 --- a/src/widgets/RecommendationsPanel/__snapshots__/LoadedView.test.jsx.snap +++ b/src/widgets/RecommendationsPanel/__snapshots__/LoadedView.test.jsx.snap @@ -65,7 +65,7 @@ exports[`RecommendationsPanel LoadedView snapshot with personalize recommendatio