21
package-lock.json
generated
21
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`] = `
|
||||
<ErrorPage
|
||||
message="test-error-message"
|
||||
/>
|
||||
<IntlProvider
|
||||
locale="en"
|
||||
>
|
||||
<ErrorPage
|
||||
message="test-error-message"
|
||||
/>
|
||||
</IntlProvider>
|
||||
`;
|
||||
|
||||
exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -13,10 +13,41 @@ exports[`CourseCard component snapshot: collapsed 1`] = `
|
||||
className="d-flex flex-column w-100"
|
||||
>
|
||||
<div>
|
||||
<CourseCardContent
|
||||
<CourseCardImage
|
||||
cardId="test-card-id"
|
||||
orientation="vertical"
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<Card.Body>
|
||||
<Card.Header
|
||||
actions={
|
||||
<CourseCardMenu
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<CourseCardTitle
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Card.Section
|
||||
className="pt-0"
|
||||
>
|
||||
<CourseCardDetails
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Footer
|
||||
orientation="vertical"
|
||||
>
|
||||
<RelatedProgramsBadge
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
<CourseCardActions
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card.Body>
|
||||
</div>
|
||||
<div
|
||||
className="course-card-banners"
|
||||
@@ -46,10 +77,41 @@ exports[`CourseCard component snapshot: not collapsed 1`] = `
|
||||
<div
|
||||
className="d-flex"
|
||||
>
|
||||
<CourseCardContent
|
||||
<CourseCardImage
|
||||
cardId="test-card-id"
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<Card.Body>
|
||||
<Card.Header
|
||||
actions={
|
||||
<CourseCardMenu
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<CourseCardTitle
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Card.Section
|
||||
className="pt-0"
|
||||
>
|
||||
<CourseCardDetails
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Footer
|
||||
orientation="horizontal"
|
||||
>
|
||||
<RelatedProgramsBadge
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
<CourseCardActions
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card.Body>
|
||||
</div>
|
||||
<div
|
||||
className="course-card-banners"
|
||||
|
||||
@@ -4,26 +4,36 @@ import PropTypes from 'prop-types';
|
||||
import { Locked } from '@edx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import track from 'tracking';
|
||||
import { hooks } from 'data/redux';
|
||||
import useTrackUpgradeData from './hooks';
|
||||
|
||||
import ActionButton from './ActionButton';
|
||||
import messages from './messages';
|
||||
|
||||
export const UpgradeButton = ({ cardId }) => {
|
||||
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 (
|
||||
<ActionButton
|
||||
iconBefore={Locked}
|
||||
variant="outline-primary"
|
||||
disabled={!isEnabled}
|
||||
onClick={trackUpgradeClick}
|
||||
{...isEnabled && { as: 'a', href: upgradeUrl }}
|
||||
{...isEnabled && enabledProps}
|
||||
>
|
||||
{formatMessage(messages.upgrade)}
|
||||
</ActionButton>
|
||||
|
||||
@@ -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(<UpgradeButton {...props} />);
|
||||
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 });
|
||||
|
||||
@@ -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 (
|
||||
<ActionButton
|
||||
disabled={!hasAccess}
|
||||
as="a"
|
||||
href={homeUrl}
|
||||
href="#"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{formatMessage(messages.viewCourse)}
|
||||
</ActionButton>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`] = `
|
||||
<ActionButton
|
||||
disabled={true}
|
||||
iconBefore={[MockFunction icons.Locked]}
|
||||
onClick={[MockFunction trackUpgradeClick]}
|
||||
variant="outline-primary"
|
||||
>
|
||||
Upgrade
|
||||
@@ -28,7 +35,6 @@ exports[`UpgradeButton snapshot masquerading 1`] = `
|
||||
<ActionButton
|
||||
disabled={true}
|
||||
iconBefore={[MockFunction icons.Locked]}
|
||||
onClick={[MockFunction trackUpgradeClick]}
|
||||
variant="outline-primary"
|
||||
>
|
||||
Upgrade
|
||||
|
||||
@@ -4,7 +4,16 @@ exports[`ViewCourseButton learner does not have access to course snapshot 1`] =
|
||||
<ActionButton
|
||||
as="a"
|
||||
disabled={true}
|
||||
href="homeUrl"
|
||||
href="#"
|
||||
onClick={
|
||||
Object {
|
||||
"trackCourseEvent": Object {
|
||||
"cardId": "cardId",
|
||||
"eventName": [MockFunction segment.enterCourseClicked],
|
||||
"upgradeUrl": "homeUrl",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
View Course
|
||||
</ActionButton>
|
||||
@@ -14,7 +23,16 @@ exports[`ViewCourseButton learner has access to course snapshot 1`] = `
|
||||
<ActionButton
|
||||
as="a"
|
||||
disabled={false}
|
||||
href="homeUrl"
|
||||
href="#"
|
||||
onClick={
|
||||
Object {
|
||||
"trackCourseEvent": Object {
|
||||
"cardId": "cardId",
|
||||
"eventName": [MockFunction segment.enterCourseClicked],
|
||||
"upgradeUrl": "homeUrl",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
View Course
|
||||
</ActionButton>
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<>
|
||||
<a className={`pgn__card-wrapper-image-cap overflow-visible ${orientation}`} href={homeUrl}>
|
||||
<img
|
||||
className="pgn__card-image-cap"
|
||||
src={bannerImgSrc}
|
||||
alt={formatMessage(messages.bannerAlt)}
|
||||
/>
|
||||
{
|
||||
isVerified && (
|
||||
<span className="course-card-verify-ribbon-container" title={formatMessage(messages.verifiedHoverDescription)}>
|
||||
<Badge as="div" variant="success" className="w-100">{formatMessage(messages.verifiedBanner)}</Badge>
|
||||
<img src={verifiedRibbon} alt={formatMessage(messages.verifiedBannerRibbonAlt)} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</a>
|
||||
<Card.Body>
|
||||
<Card.Header
|
||||
title={(
|
||||
<h3>
|
||||
<a
|
||||
href={homeUrl}
|
||||
className="course-card-title"
|
||||
data-testid="CourseCardTitle"
|
||||
>
|
||||
{courseName}
|
||||
</a>
|
||||
</h3>
|
||||
)}
|
||||
actions={<CourseCardMenu cardId={cardId} />}
|
||||
/>
|
||||
<Card.Section className="pt-0">
|
||||
<CourseCardDetails cardId={cardId} />
|
||||
</Card.Section>
|
||||
<Card.Footer orientation={orientation}>
|
||||
<RelatedProgramsBadge cardId={cardId} />
|
||||
<CourseCardActions cardId={cardId} />
|
||||
</Card.Footer>
|
||||
</Card.Body>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CourseCardContent.propTypes = {
|
||||
cardId: PropTypes.string.isRequired,
|
||||
orientation: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
CourseCardContent.defaultProps = {};
|
||||
|
||||
export default CourseCardContent;
|
||||
@@ -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(<CourseCardContent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
test('orientation horizontal', () => {
|
||||
hooks.useCardEnrollmentData.mockReturnValue({
|
||||
isVerified: true,
|
||||
});
|
||||
const wrapper = shallow(<CourseCardContent {...props} orientation="horizontal" />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
test('not verified', () => {
|
||||
hooks.useCardEnrollmentData.mockReturnValue({
|
||||
isVerified: false,
|
||||
});
|
||||
const wrapper = shallow(<CourseCardContent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
64
src/containers/CourseCard/components/CourseCardImage.jsx
Normal file
64
src/containers/CourseCard/components/CourseCardImage.jsx
Normal file
@@ -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 = (
|
||||
<>
|
||||
<img
|
||||
className="pgn__card-image-cap"
|
||||
src={bannerImgSrc}
|
||||
alt={formatMessage(messages.bannerAlt)}
|
||||
/>
|
||||
{
|
||||
isVerified && (
|
||||
<span
|
||||
className="course-card-verify-ribbon-container"
|
||||
title={formatMessage(messages.verifiedHoverDescription)}
|
||||
>
|
||||
<Badge as="div" variant="success" className="w-100">
|
||||
{formatMessage(messages.verifiedBanner)}
|
||||
</Badge>
|
||||
<img src={verifiedRibbon} alt={formatMessage(messages.verifiedBannerRibbonAlt)} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
return isEntitlement
|
||||
? image
|
||||
: (
|
||||
<a
|
||||
className={`pgn__card-wrapper-image-cap overflow-visible ${orientation}`}
|
||||
href={homeUrl}
|
||||
onClick={handleImageClicked}
|
||||
>
|
||||
{image}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
CourseCardImage.propTypes = {
|
||||
cardId: PropTypes.string.isRequired,
|
||||
orientation: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
CourseCardImage.defaultProps = {};
|
||||
|
||||
export default CourseCardImage;
|
||||
@@ -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 (
|
||||
<div className="mb-4.5 course-card" data-testid="CourseCard">
|
||||
<Card orientation={isCollapsed ? 'vertical' : 'horizontal'}>
|
||||
<div className="d-flex flex-column w-100">
|
||||
<div className="d-flex">
|
||||
<CourseCardContent cardId={cardId} />
|
||||
</div>
|
||||
<div className="course-card-banners" data-testid="CourseCardBanners">
|
||||
<CourseCardBanners cardId={cardId} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
CourseCardLayout.propTypes = {
|
||||
cardId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseCardLayout;
|
||||
@@ -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(<CourseCardLayout {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
test('is not collapsed', () => {
|
||||
useIsCollapsed.mockReturnValue(false);
|
||||
const wrapper = shallow(<CourseCardLayout {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1
|
||||
</Dropdown.Item>
|
||||
<TwitterShareButton
|
||||
className="pgn__dropdown-item dropdown-item"
|
||||
onClick={[MockFunction handleTwitterShare]}
|
||||
resetButtonStyle={false}
|
||||
title="I'm taking test-course-name online with twitter-social-brand. Check it out!"
|
||||
url="twitter-share-url"
|
||||
@@ -77,6 +78,7 @@ exports[`CourseCardMenu masquerading snapshot 1`] = `
|
||||
</Dropdown.Item>
|
||||
<TwitterShareButton
|
||||
className="pgn__dropdown-item dropdown-item"
|
||||
onClick={[MockFunction handleTwitterShare]}
|
||||
resetButtonStyle={false}
|
||||
title="I'm taking test-course-name online with twitter-social-brand. Check it out!"
|
||||
url="twitter-share-url"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React from 'react';
|
||||
import { StrictDict } from 'utils';
|
||||
|
||||
import track from 'tracking';
|
||||
import { hooks as appHooks } from 'data/redux';
|
||||
|
||||
import * as module from './hooks';
|
||||
|
||||
export const state = StrictDict({
|
||||
@@ -24,3 +28,11 @@ export const useEmailSettings = () => {
|
||||
isVisible,
|
||||
};
|
||||
};
|
||||
|
||||
export const useHandleToggleDropdown = (cardId) => {
|
||||
const eventName = track.course.courseOptionsDropdownClicked;
|
||||
const trackCourseEvent = appHooks.useTrackCourseEvent(eventName, cardId);
|
||||
return (isOpen) => {
|
||||
if (isOpen) { trackCourseEvent(); }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Dropdown>
|
||||
<Dropdown onToggle={handleToggleDropdown}>
|
||||
<Dropdown.Toggle
|
||||
id={`course-actions-dropdown-${cardId}`}
|
||||
as={IconButton}
|
||||
@@ -73,6 +83,7 @@ export const CourseCardMenu = ({ cardId }) => {
|
||||
{twitter.isEnabled && (
|
||||
<ReactShare.TwitterShareButton
|
||||
url={twitter.shareUrl}
|
||||
onClick={handleTwitterShare}
|
||||
title={formatMessage(messages.shareQuote, {
|
||||
courseName,
|
||||
socialBrand: twitter.socialBrand,
|
||||
|
||||
@@ -14,11 +14,13 @@ jest.mock('data/redux', () => ({
|
||||
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(() => {
|
||||
|
||||
33
src/containers/CourseCard/components/CourseCardTitle.jsx
Normal file
33
src/containers/CourseCard/components/CourseCardTitle.jsx
Normal file
@@ -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 (
|
||||
<h3>
|
||||
<a
|
||||
href={homeUrl}
|
||||
className="course-card-title"
|
||||
data-testid="CourseCardTitle"
|
||||
onClick={handleTitleClicked}
|
||||
>
|
||||
{courseName}
|
||||
</a>
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
CourseCardTitle.propTypes = {
|
||||
cardId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
CourseCardTitle.defaultProps = {};
|
||||
|
||||
export default CourseCardTitle;
|
||||
@@ -1,189 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CourseCardContent snapshot not verified 1`] = `
|
||||
<Fragment>
|
||||
<a
|
||||
className="pgn__card-wrapper-image-cap overflow-visible vertical"
|
||||
href="test-home-url"
|
||||
>
|
||||
<img
|
||||
alt="Course thumbnail"
|
||||
className="pgn__card-image-cap"
|
||||
src="test-banner-img-src"
|
||||
/>
|
||||
</a>
|
||||
<Card.Body>
|
||||
<Card.Header
|
||||
actions={
|
||||
<CourseCardMenu
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<h3>
|
||||
<a
|
||||
className="course-card-title"
|
||||
data-testid="CourseCardTitle"
|
||||
href="test-home-url"
|
||||
>
|
||||
test-course-name
|
||||
</a>
|
||||
</h3>
|
||||
}
|
||||
/>
|
||||
<Card.Section
|
||||
className="pt-0"
|
||||
>
|
||||
<CourseCardDetails
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Footer
|
||||
orientation="vertical"
|
||||
>
|
||||
<RelatedProgramsBadge
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
<CourseCardActions
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card.Body>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`CourseCardContent snapshot orientation horizontal 1`] = `
|
||||
<Fragment>
|
||||
<a
|
||||
className="pgn__card-wrapper-image-cap overflow-visible horizontal"
|
||||
href="test-home-url"
|
||||
>
|
||||
<img
|
||||
alt="Course thumbnail"
|
||||
className="pgn__card-image-cap"
|
||||
src="test-banner-img-src"
|
||||
/>
|
||||
<span
|
||||
className="course-card-verify-ribbon-container"
|
||||
title="You're enrolled as a verified student"
|
||||
>
|
||||
<Badge
|
||||
as="div"
|
||||
className="w-100"
|
||||
variant="success"
|
||||
>
|
||||
Verified
|
||||
</Badge>
|
||||
<img
|
||||
alt="ID Verified Ribbon/Badge"
|
||||
src="test-file-stub"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
<Card.Body>
|
||||
<Card.Header
|
||||
actions={
|
||||
<CourseCardMenu
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<h3>
|
||||
<a
|
||||
className="course-card-title"
|
||||
data-testid="CourseCardTitle"
|
||||
href="test-home-url"
|
||||
>
|
||||
test-course-name
|
||||
</a>
|
||||
</h3>
|
||||
}
|
||||
/>
|
||||
<Card.Section
|
||||
className="pt-0"
|
||||
>
|
||||
<CourseCardDetails
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Footer
|
||||
orientation="horizontal"
|
||||
>
|
||||
<RelatedProgramsBadge
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
<CourseCardActions
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card.Body>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`CourseCardContent snapshot orientation vertical 1`] = `
|
||||
<Fragment>
|
||||
<a
|
||||
className="pgn__card-wrapper-image-cap overflow-visible vertical"
|
||||
href="test-home-url"
|
||||
>
|
||||
<img
|
||||
alt="Course thumbnail"
|
||||
className="pgn__card-image-cap"
|
||||
src="test-banner-img-src"
|
||||
/>
|
||||
<span
|
||||
className="course-card-verify-ribbon-container"
|
||||
title="You're enrolled as a verified student"
|
||||
>
|
||||
<Badge
|
||||
as="div"
|
||||
className="w-100"
|
||||
variant="success"
|
||||
>
|
||||
Verified
|
||||
</Badge>
|
||||
<img
|
||||
alt="ID Verified Ribbon/Badge"
|
||||
src="test-file-stub"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
<Card.Body>
|
||||
<Card.Header
|
||||
actions={
|
||||
<CourseCardMenu
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<h3>
|
||||
<a
|
||||
className="course-card-title"
|
||||
data-testid="CourseCardTitle"
|
||||
href="test-home-url"
|
||||
>
|
||||
test-course-name
|
||||
</a>
|
||||
</h3>
|
||||
}
|
||||
/>
|
||||
<Card.Section
|
||||
className="pt-0"
|
||||
>
|
||||
<CourseCardDetails
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Footer
|
||||
orientation="vertical"
|
||||
>
|
||||
<RelatedProgramsBadge
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
<CourseCardActions
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card.Body>
|
||||
</Fragment>
|
||||
`;
|
||||
@@ -1,63 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CourseCardLayout snapshot is collapsed 1`] = `
|
||||
<div
|
||||
className="mb-4.5 course-card"
|
||||
data-testid="CourseCard"
|
||||
>
|
||||
<Card
|
||||
orientation="vertical"
|
||||
>
|
||||
<div
|
||||
className="d-flex flex-column w-100"
|
||||
>
|
||||
<div
|
||||
className="d-flex"
|
||||
>
|
||||
<CourseCardContent
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="course-card-banners"
|
||||
data-testid="CourseCardBanners"
|
||||
>
|
||||
<CourseCardBanners
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`CourseCardLayout snapshot is not collapsed 1`] = `
|
||||
<div
|
||||
className="mb-4.5 course-card"
|
||||
data-testid="CourseCard"
|
||||
>
|
||||
<Card
|
||||
orientation="horizontal"
|
||||
>
|
||||
<div
|
||||
className="d-flex flex-column w-100"
|
||||
>
|
||||
<div
|
||||
className="d-flex"
|
||||
>
|
||||
<CourseCardContent
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="course-card-banners"
|
||||
data-testid="CourseCardBanners"
|
||||
>
|
||||
<CourseCardBanners
|
||||
cardId="test-card-id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
`;
|
||||
@@ -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 = ({
|
||||
<Card orientation={orientation}>
|
||||
<div className="d-flex flex-column w-100">
|
||||
<div {...(!isCollapsed && { className: 'd-flex' })}>
|
||||
<CourseCardContent cardId={cardId} orientation={orientation} />
|
||||
<CourseCardImage cardId={cardId} orientation="horizontal" />
|
||||
<Card.Body>
|
||||
<Card.Header
|
||||
title={<CourseCardTitle cardId={cardId} />}
|
||||
actions={<CourseCardMenu cardId={cardId} />}
|
||||
/>
|
||||
<Card.Section className="pt-0">
|
||||
<CourseCardDetails cardId={cardId} />
|
||||
</Card.Section>
|
||||
<Card.Footer orientation={orientation}>
|
||||
<RelatedProgramsBadge cardId={cardId} />
|
||||
<CourseCardActions cardId={cardId} />
|
||||
</Card.Footer>
|
||||
</Card.Body>
|
||||
</div>
|
||||
<div className="course-card-banners" data-testid="CourseCardBanners">
|
||||
<CourseCardBanners cardId={cardId} />
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`EnterpriseDashboard empty snapshot 1`] = `""`;
|
||||
|
||||
exports[`EnterpriseDashboard snapshot 1`] = `
|
||||
<ModalDialog
|
||||
hasCloseButton={false}
|
||||
onClose={[MockFunction useEnterpriseDashboardHook.handleClick]}
|
||||
onClose={[MockFunction useEnterpriseDashboardHook.handleEscape]}
|
||||
title=""
|
||||
>
|
||||
<div
|
||||
@@ -22,13 +24,14 @@ exports[`EnterpriseDashboard snapshot 1`] = `
|
||||
</p>
|
||||
<ActionRow>
|
||||
<Button
|
||||
onClick={[MockFunction useEnterpriseDashboardHook.handleClick]}
|
||||
onClick={[MockFunction useEnterpriseDashboardHook.handleClose]}
|
||||
variant="tertiary"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
href="/edx-dashboard"
|
||||
onClick={[MockFunction useEnterpriseDashboardHook.handleCTAClick]}
|
||||
type="a"
|
||||
>
|
||||
Go To Dashboard
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
import React from 'react';
|
||||
import { hooks as appHooks } from 'data/redux';
|
||||
|
||||
import { StrictDict } from 'utils';
|
||||
import track from 'tracking';
|
||||
import { hooks as appHooks } from 'data/redux';
|
||||
|
||||
import * as module from './hooks';
|
||||
|
||||
export const state = StrictDict({
|
||||
showModal: (val) => React.useState(val), // eslint-disable-line
|
||||
});
|
||||
|
||||
const { modalOpened, modalClosed, modalCTAClicked } = track.enterpriseDashboard;
|
||||
|
||||
export const useEnterpriseDashboardHook = () => {
|
||||
const [showModal, setShowModal] = module.state.showModal(true);
|
||||
const dashboard = appHooks.useEnterpriseDashboardData();
|
||||
const handleClick = () => setShowModal(false);
|
||||
|
||||
const trackOpened = () => modalOpened(dashboard.enterpriseUUID);
|
||||
const trackClose = () => modalClosed(dashboard.enterpriseUUID, 'Cancel button');
|
||||
const trackEscape = () => modalClosed(dashboard.enterpriseUUID, 'Escape');
|
||||
|
||||
const handleCTAClick = () => {
|
||||
modalCTAClicked(dashboard.enterpriseUUID, dashboard.url);
|
||||
};
|
||||
const handleClose = () => {
|
||||
trackClose();
|
||||
setShowModal(false);
|
||||
};
|
||||
const handleEscape = () => {
|
||||
trackEscape();
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
React.useEffect(trackOpened, []);
|
||||
|
||||
return {
|
||||
showModal,
|
||||
handleClick,
|
||||
handleCTAClick,
|
||||
handleClose,
|
||||
handleEscape,
|
||||
dashboard,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MockUseState } from 'testUtils';
|
||||
import { hooks as appHooks } from 'data/redux';
|
||||
import track from 'tracking';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
|
||||
@@ -8,6 +9,16 @@ jest.mock('data/redux', () => ({
|
||||
useEnterpriseDashboardData: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('tracking', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
enterpriseDashboard: {
|
||||
modalOpened: jest.fn(),
|
||||
modalClosed: jest.fn(),
|
||||
modalCTAClicked: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const state = new MockUseState(hooks);
|
||||
|
||||
@@ -35,8 +46,22 @@ describe('EnterpriseDashboard hooks', () => {
|
||||
|
||||
test('modal initializes to shown when rendered and closes on click', () => {
|
||||
state.expectInitializedWith(state.keys.showModal, true);
|
||||
out.handleClick();
|
||||
out.handleClose();
|
||||
expect(state.values.showModal).toEqual(false);
|
||||
});
|
||||
|
||||
test('modal initializes to shown when rendered and closes on escape', () => {
|
||||
state.expectInitializedWith(state.keys.showModal, true);
|
||||
out.handleEscape();
|
||||
expect(state.values.showModal).toEqual(false);
|
||||
});
|
||||
|
||||
test('CTA click tracks modalCTAClicked', () => {
|
||||
out.handleCTAClick();
|
||||
expect(track.enterpriseDashboard.modalCTAClicked).toHaveBeenCalledWith(
|
||||
enterpriseDashboardData.enterpriseUUID,
|
||||
enterpriseDashboardData.url,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,9 @@ export const EnterpriseDashboardModal = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
showModal,
|
||||
handleClick,
|
||||
handleClose,
|
||||
handleCTAClick,
|
||||
handleEscape,
|
||||
dashboard,
|
||||
} = useEnterpriseDashboardHook();
|
||||
if (!dashboard || !dashboard.label) {
|
||||
@@ -22,7 +24,7 @@ export const EnterpriseDashboardModal = () => {
|
||||
return (
|
||||
<ModalDialog
|
||||
isOpen={showModal}
|
||||
onClose={handleClick}
|
||||
onClose={handleEscape}
|
||||
hasCloseButton={false}
|
||||
title=""
|
||||
>
|
||||
@@ -41,10 +43,10 @@ export const EnterpriseDashboardModal = () => {
|
||||
})}
|
||||
</p>
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={handleClick}>
|
||||
<Button variant="tertiary" onClick={handleClose}>
|
||||
{formatMessage(messages.enterpriseDialogDismissButton)}
|
||||
</Button>
|
||||
<Button type="a" href={dashboard.url}>
|
||||
<Button type="a" href={dashboard.url} onClick={handleCTAClick}>
|
||||
{formatMessage(messages.enterpriseDialogConfirmButton)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
|
||||
@@ -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(<EnterpriseDashboard />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
test('empty snapshot', () => {
|
||||
useEnterpriseDashboardHook.mockReturnValueOnce({});
|
||||
const el = shallow(<EnterpriseDashboard />);
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
64
src/containers/UnenrollConfirmModal/hooks/index.js
Normal file
64
src/containers/UnenrollConfirmModal/hooks/index.js
Normal file
@@ -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;
|
||||
99
src/containers/UnenrollConfirmModal/hooks/index.test.js
Normal file
99
src/containers/UnenrollConfirmModal/hooks/index.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
43
src/containers/UnenrollConfirmModal/hooks/reasons.js
Normal file
43
src/containers/UnenrollConfirmModal/hooks/reasons.js
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
76
src/containers/UnenrollConfirmModal/hooks/reasons.test.js
Normal file
76
src/containers/UnenrollConfirmModal/hooks/reasons.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,16 +37,15 @@ subscribe(APP_READY, () => {
|
||||
|
||||
subscribe(APP_INIT_ERROR, (error) => {
|
||||
ReactDOM.render(
|
||||
<ErrorPage message={error.message} />,
|
||||
<IntlProvider locale="en">
|
||||
<ErrorPage message={error.message} />
|
||||
</IntlProvider>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
});
|
||||
|
||||
export const appName = 'LearnerHomeAppConfig';
|
||||
|
||||
// TODO: remove dev debug
|
||||
console.log({ SEGEMENT_KEY: process.env.SEGMENT_KEY });
|
||||
|
||||
initialize({
|
||||
handlers: {
|
||||
config: () => {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
53
src/tracking/constants.js
Normal file
53
src/tracking/constants.js
Normal file
@@ -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;
|
||||
13
src/tracking/index.js
Normal file
13
src/tracking/index.js
Normal file
@@ -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,
|
||||
};
|
||||
75
src/tracking/trackers/course.js
Normal file
75
src/tracking/trackers/course.js
Normal file
@@ -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,
|
||||
};
|
||||
119
src/tracking/trackers/course.test.js
Normal file
119
src/tracking/trackers/course.test.js
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
23
src/tracking/trackers/engagement.js
Normal file
23
src/tracking/trackers/engagement.js
Normal file
@@ -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,
|
||||
};
|
||||
31
src/tracking/trackers/engagement.test.js
Normal file
31
src/tracking/trackers/engagement.test.js
Normal file
@@ -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 },
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
44
src/tracking/trackers/enterpriseDashboard.js
Normal file
44
src/tracking/trackers/enterpriseDashboard.js
Normal file
@@ -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,
|
||||
};
|
||||
38
src/tracking/trackers/enterpriseDashboard.test.js
Normal file
38
src/tracking/trackers/enterpriseDashboard.test.js
Normal file
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
34
src/tracking/trackers/entitlements.js
Normal file
34
src/tracking/trackers/entitlements.js
Normal file
@@ -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,
|
||||
};
|
||||
34
src/tracking/trackers/entitlements.test.js
Normal file
34
src/tracking/trackers/entitlements.test.js
Normal file
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
11
src/tracking/trackers/socialShare.js
Normal file
11
src/tracking/trackers/socialShare.js
Normal file
@@ -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;
|
||||
17
src/tracking/trackers/socialShare.test.js
Normal file
17
src/tracking/trackers/socialShare.test.js
Normal file
@@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ exports[`LookingForChallengeWidget snapshots default 1`] = `
|
||||
<Hyperlink
|
||||
className="d-flex align-items-center"
|
||||
destination="course-search-url"
|
||||
onClick={[MockFunction courseSearchClickTracker]}
|
||||
variant="brand"
|
||||
>
|
||||
<format-message-function
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Card, Hyperlink, Icon } from '@edx/paragon';
|
||||
@@ -8,14 +7,13 @@ import { ArrowForward } from '@edx/paragon/icons';
|
||||
import { hooks } from 'data/redux';
|
||||
import moreCoursesSVG from 'assets/more-courses-sidewidget.svg';
|
||||
|
||||
import track from '../RecommendationsPanel/track';
|
||||
import messages from './messages';
|
||||
import './index.scss';
|
||||
|
||||
export const arrowIcon = (<Icon className="mx-1" src={ArrowForward} />);
|
||||
|
||||
export const LookingForChallengeWidget = ({
|
||||
courseSearchClickTracker,
|
||||
}) => {
|
||||
export const LookingForChallengeWidget = () => {
|
||||
const { courseSearchUrl } = hooks.usePlatformSettingsData();
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
@@ -32,7 +30,7 @@ export const LookingForChallengeWidget = ({
|
||||
<Hyperlink
|
||||
variant="brand"
|
||||
destination={courseSearchUrl}
|
||||
onClick={courseSearchClickTracker}
|
||||
onClick={track.findCoursesClicked(courseSearchUrl)}
|
||||
className="d-flex align-items-center"
|
||||
>
|
||||
{formatMessage(messages.findCoursesButton, { arrow: arrowIcon })}
|
||||
@@ -43,8 +41,6 @@ export const LookingForChallengeWidget = ({
|
||||
);
|
||||
};
|
||||
|
||||
LookingForChallengeWidget.propTypes = {
|
||||
courseSearchClickTracker: PropTypes.func.isRequired,
|
||||
};
|
||||
LookingForChallengeWidget.propTypes = {};
|
||||
|
||||
export default LookingForChallengeWidget;
|
||||
|
||||
@@ -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(<LookingForChallengeWidget {...props} />);
|
||||
const wrapper = shallow(<LookingForChallengeWidget />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
</Button>
|
||||
@@ -56,7 +56,6 @@ LoadedView.propTypes = {
|
||||
marketingUrl: PropTypes.string,
|
||||
})).isRequired,
|
||||
isPersonalizedRecommendation: PropTypes.bool.isRequired,
|
||||
courseSearchClickTracker: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default LoadedView;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -65,7 +65,7 @@ exports[`RecommendationsPanel LoadedView snapshot with personalize recommendatio
|
||||
<Button
|
||||
as="a"
|
||||
href="course-search-url"
|
||||
onClick={[MockFunction courseSearchClickTracker]}
|
||||
onClick="find-courses-clicked"
|
||||
variant="tertiary"
|
||||
>
|
||||
Explore courses
|
||||
@@ -81,7 +81,7 @@ exports[`RecommendationsPanel LoadedView snapshot without personalize recommenda
|
||||
<h3
|
||||
className="pb-2"
|
||||
>
|
||||
Popular on edX
|
||||
Popular courses
|
||||
</h3>
|
||||
<div>
|
||||
<CourseCard
|
||||
@@ -139,7 +139,7 @@ exports[`RecommendationsPanel LoadedView snapshot without personalize recommenda
|
||||
<Button
|
||||
as="a"
|
||||
href="course-search-url"
|
||||
onClick={[MockFunction courseSearchClickTracker]}
|
||||
onClick="find-courses-clicked"
|
||||
variant="tertiary"
|
||||
>
|
||||
Explore courses
|
||||
|
||||
@@ -4,27 +4,19 @@ import PropTypes from 'prop-types';
|
||||
import { Card, Hyperlink, Truncate } from '@edx/paragon';
|
||||
|
||||
import { useIsCollapsed } from 'containers/CourseCard/hooks';
|
||||
import { configuration } from '../../../config';
|
||||
import { setCookie, getCookie } from '../../../utils/cookies';
|
||||
import useCourseCardData from './hooks';
|
||||
import './index.scss';
|
||||
|
||||
export const CourseCard = ({ course, isPersonalizedRecommendation }) => {
|
||||
const isCollapsed = useIsCollapsed();
|
||||
|
||||
const handleCourseClick = () => {
|
||||
const cookieName = configuration.PERSONALIZED_RECOMMENDATION_COOKIE_NAME;
|
||||
let recommendedCourses = getCookie(cookieName);
|
||||
if (typeof recommendedCourses === 'undefined') {
|
||||
recommendedCourses = { course_keys: [course.courseKey] };
|
||||
} else if (!recommendedCourses.course_keys.includes(course.courseKey)) {
|
||||
recommendedCourses.course_keys.push(course.courseKey);
|
||||
}
|
||||
recommendedCourses.is_personalized_recommendation = isPersonalizedRecommendation;
|
||||
setCookie(cookieName, JSON.stringify(recommendedCourses), 365);
|
||||
};
|
||||
const { handleCourseClick } = useCourseCardData(course, isPersonalizedRecommendation);
|
||||
|
||||
return (
|
||||
<Hyperlink destination={course?.marketingUrl} className="card-link" onClick={handleCourseClick}>
|
||||
<Hyperlink
|
||||
destination={course?.marketingUrl}
|
||||
className="card-link"
|
||||
onClick={handleCourseClick}
|
||||
>
|
||||
<Card orientation={isCollapsed ? 'vertical' : 'horizontal'} className="p-3 mb-1 recommended-course-card">
|
||||
<div className={isCollapsed ? '' : 'd-flex align-items-center'}>
|
||||
<Card.ImageCap
|
||||
|
||||
30
src/widgets/RecommendationsPanel/components/hooks.js
Normal file
30
src/widgets/RecommendationsPanel/components/hooks.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { configuration } from 'config';
|
||||
import { setCookie, getCookie } from 'utils/cookies';
|
||||
|
||||
import track from '../track';
|
||||
import './index.scss';
|
||||
|
||||
export const useCourseCardData = (course, isPersonalized) => {
|
||||
const handleCourseClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const cookieName = configuration.PERSONALIZED_RECOMMENDATION_COOKIE_NAME;
|
||||
let recommendedCourses = getCookie(cookieName);
|
||||
if (typeof recommendedCourses === 'undefined') {
|
||||
recommendedCourses = { course_keys: [course.courseKey] };
|
||||
} else if (!recommendedCourses.course_keys.includes(course.courseKey)) {
|
||||
recommendedCourses.course_keys.push(course.courseKey);
|
||||
}
|
||||
recommendedCourses.is_personalized_recommendation = isPersonalized;
|
||||
setCookie(cookieName, JSON.stringify(recommendedCourses), 365);
|
||||
|
||||
track.recommendedCourseClicked(
|
||||
course.courseKey,
|
||||
isPersonalized,
|
||||
course?.marketingUrl,
|
||||
);
|
||||
};
|
||||
return { handleCourseClick };
|
||||
};
|
||||
|
||||
export default useCourseCardData;
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
|
||||
import { StrictDict } from 'utils';
|
||||
import { RequestStates } from 'data/constants/requests';
|
||||
import { handleEvent } from 'data/services/segment/utils';
|
||||
|
||||
import * as module from './hooks';
|
||||
import api from './api';
|
||||
@@ -38,19 +37,13 @@ export const useRecommendationPanelData = () => {
|
||||
module.useFetchCourses(setRequestState, setData);
|
||||
const courses = data.data?.courses || [];
|
||||
const isPersonalizedRecommendation = data.data?.isPersonalizedRecommendation || false;
|
||||
const courseSearchClickTracker = () => handleEvent(searchCourseEventName, {
|
||||
pageName: 'learner_home',
|
||||
linkType: 'button',
|
||||
linkCategory: 'search_button',
|
||||
});
|
||||
return {
|
||||
courses,
|
||||
isPersonalizedRecommendation,
|
||||
isLoaded: requestState === RequestStates.completed && courses.length > 0,
|
||||
isFailed: requestState === RequestStates.failed
|
||||
|| (requestState === RequestStates.completed && courses.length === 0),
|
||||
isLoading: requestState === RequestStates.pending,
|
||||
courseSearchClickTracker,
|
||||
isPersonalizedRecommendation,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
|
||||
import { MockUseState } from 'testUtils';
|
||||
import { RequestStates } from 'data/constants/requests';
|
||||
import { handleEvent } from 'data/services/segment/utils';
|
||||
|
||||
import api from './api';
|
||||
import * as hooks from './hooks';
|
||||
@@ -10,9 +9,6 @@ import * as hooks from './hooks';
|
||||
jest.mock('./api', () => ({
|
||||
fetchRecommendedCourses: jest.fn(),
|
||||
}));
|
||||
jest.mock('data/services/segment/utils', () => ({
|
||||
handleEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
const state = new MockUseState(hooks);
|
||||
|
||||
@@ -100,16 +96,6 @@ describe('RecommendationsPanel hooks', () => {
|
||||
it('initializes requestState as RequestStates.pending', () => {
|
||||
state.expectInitializedWith(state.keys.requestState, RequestStates.pending);
|
||||
});
|
||||
describe('courseSearchClickTracker behavior', () => {
|
||||
it('calls handleEvent with correct args', () => {
|
||||
out.courseSearchClickTracker();
|
||||
expect(handleEvent).toHaveBeenCalledWith(hooks.searchCourseEventName, {
|
||||
pageName: 'learner_home',
|
||||
linkType: 'button',
|
||||
linkCategory: 'search_button',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('request is completed, with returned courses', () => {
|
||||
beforeEach(() => {
|
||||
@@ -130,6 +116,19 @@ describe('RecommendationsPanel hooks', () => {
|
||||
expect(out.courses).toEqual(testList);
|
||||
});
|
||||
});
|
||||
describe('personalize recommendation', () => {
|
||||
it('default to false', () => {
|
||||
state.mockVal(state.keys.data, {});
|
||||
out = hooks.useRecommendationPanelData();
|
||||
expect(out.isPersonalizedRecommendation).toEqual(false);
|
||||
});
|
||||
it('is based on data', () => {
|
||||
const expectOutput = { test: 'abirary' };
|
||||
state.mockVal(state.keys.data, { data: { isPersonalizedRecommendation: expectOutput } });
|
||||
out = hooks.useRecommendationPanelData();
|
||||
expect(out.isPersonalizedRecommendation).toEqual(expectOutput);
|
||||
});
|
||||
});
|
||||
describe('request is completed, with no returned courses', () => {
|
||||
beforeEach(() => {
|
||||
state.mockVal(state.keys.requestState, RequestStates.completed);
|
||||
|
||||
@@ -8,11 +8,10 @@ import hooks from './hooks';
|
||||
export const RecommendationsPanel = () => {
|
||||
const {
|
||||
courses,
|
||||
isPersonalizedRecommendation,
|
||||
isFailed,
|
||||
isLoaded,
|
||||
isLoading,
|
||||
courseSearchClickTracker,
|
||||
isPersonalizedRecommendation,
|
||||
} = hooks.useRecommendationPanelData();
|
||||
|
||||
if (isLoading) {
|
||||
@@ -20,18 +19,14 @@ export const RecommendationsPanel = () => {
|
||||
}
|
||||
if (isLoaded) {
|
||||
return (
|
||||
<LoadedView
|
||||
courses={courses}
|
||||
isPersonalizedRecommendation={isPersonalizedRecommendation}
|
||||
courseSearchClickTracker={courseSearchClickTracker}
|
||||
/>
|
||||
<LoadedView courses={courses} isPersonalizedRecommendation={isPersonalizedRecommendation} />
|
||||
);
|
||||
}
|
||||
if (isFailed) {
|
||||
return (<LookingForChallengeWidget courseSearchClickTracker={courseSearchClickTracker} />);
|
||||
return (<LookingForChallengeWidget />);
|
||||
}
|
||||
// default fallback
|
||||
return (<LookingForChallengeWidget courseSearchClickTracker={courseSearchClickTracker} />);
|
||||
return (<LookingForChallengeWidget />);
|
||||
};
|
||||
|
||||
export default RecommendationsPanel;
|
||||
|
||||
@@ -18,16 +18,15 @@ jest.mock('./LoadedView', () => 'LoadedView');
|
||||
const { courses } = mockData;
|
||||
|
||||
describe('RecommendationsPanel snapshot', () => {
|
||||
const defaultProps = {
|
||||
courseSearchClickTracker: jest.fn().mockName('courseSearchClickTracker'),
|
||||
const defaultLoadedViewProps = {
|
||||
courses: [],
|
||||
isPersonalizedRecommendation: false,
|
||||
};
|
||||
const defaultValues = {
|
||||
isFailed: false,
|
||||
isLoaded: false,
|
||||
isLoading: false,
|
||||
courses: [],
|
||||
isPersonalizedRecommendation: false,
|
||||
...defaultProps,
|
||||
...defaultLoadedViewProps,
|
||||
};
|
||||
it('displays LoadingView if request is loading', () => {
|
||||
hooks.useRecommendationPanelData.mockReturnValueOnce({
|
||||
@@ -43,13 +42,7 @@ describe('RecommendationsPanel snapshot', () => {
|
||||
isLoaded: true,
|
||||
});
|
||||
expect(shallow(<RecommendationsPanel />)).toMatchObject(
|
||||
shallow(
|
||||
<LoadedView
|
||||
courses={courses}
|
||||
isPersonalizedRecommendation={false}
|
||||
{...defaultProps}
|
||||
/>,
|
||||
),
|
||||
shallow(<LoadedView {...defaultLoadedViewProps} courses={courses} />),
|
||||
);
|
||||
});
|
||||
it('displays LookingForChallengeWidget if request is failed', () => {
|
||||
@@ -58,7 +51,7 @@ describe('RecommendationsPanel snapshot', () => {
|
||||
isFailed: true,
|
||||
});
|
||||
expect(shallow(<RecommendationsPanel />)).toMatchObject(
|
||||
shallow(<LookingForChallengeWidget {...defaultProps} />),
|
||||
shallow(<LookingForChallengeWidget />),
|
||||
);
|
||||
});
|
||||
it('defaults to LookingForChallengeWidget if no flags are true', () => {
|
||||
@@ -66,7 +59,7 @@ describe('RecommendationsPanel snapshot', () => {
|
||||
...defaultValues,
|
||||
});
|
||||
expect(shallow(<RecommendationsPanel />)).toMatchObject(
|
||||
shallow(<LookingForChallengeWidget {...defaultProps} />),
|
||||
shallow(<LookingForChallengeWidget />),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,12 @@ const messages = defineMessages({
|
||||
recommendationsHeading: {
|
||||
id: 'RecommendationsPanel.recommendationsHeading',
|
||||
defaultMessage: 'Recommendations for you',
|
||||
description: 'Heading on recommendations panel with personalized recommendations',
|
||||
description: 'Personalize courses heading on recommendations panel',
|
||||
},
|
||||
popularCoursesHeading: {
|
||||
id: 'RecommendationsPanel.popularCoursesHeading',
|
||||
defaultMessage: 'Popular on edX',
|
||||
description: 'Heading on recommendations panel with general recommendations',
|
||||
defaultMessage: 'Popular courses',
|
||||
description: 'Popular courses heading on recommendations panel',
|
||||
},
|
||||
exploreCoursesButton: {
|
||||
id: 'RecommendationsPanel.exploreCoursesButton',
|
||||
|
||||
28
src/widgets/RecommendationsPanel/track.js
Normal file
28
src/widgets/RecommendationsPanel/track.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createLinkTracker, createEventTracker } from 'data/services/segment/utils';
|
||||
|
||||
export const eventNames = {
|
||||
findCoursesClicked: 'edx.bi.dashboard.find_courses_button.clicked',
|
||||
recommendedCourseClicked: 'edx.bi.user.recommended.course.click',
|
||||
};
|
||||
|
||||
export const findCoursesClicked = (href) => createLinkTracker(
|
||||
createEventTracker(eventNames.findCoursesClicked, {
|
||||
pageName: 'learner_home',
|
||||
linkType: 'button',
|
||||
linkCategory: 'search_button',
|
||||
}),
|
||||
href,
|
||||
);
|
||||
|
||||
export const recommendedCourseClicked = (courseKey, isPersonalized, href) => createLinkTracker(
|
||||
createEventTracker(eventNames.recommendedCoursesClicked, {
|
||||
course_key: courseKey,
|
||||
is_personalized_recommendation: isPersonalized,
|
||||
}),
|
||||
href,
|
||||
);
|
||||
|
||||
export default {
|
||||
findCoursesClicked,
|
||||
recommendedCourseClicked,
|
||||
};
|
||||
Reference in New Issue
Block a user