Bw/segment (#76)

Co-authored-by: Leangseu Kim <lkim@edx.org>
This commit is contained in:
Ben Warzeski
2022-11-30 11:01:39 -05:00
committed by GitHub
parent 15afb3645f
commit 9a57f9de13
76 changed files with 1467 additions and 1121 deletions

21
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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`] = `

View File

@@ -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 || '',

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 });

View File

@@ -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>

View File

@@ -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);

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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',
});
});
});
});

View File

@@ -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;

View File

@@ -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();
});
});
});

View 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;

View File

@@ -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;

View File

@@ -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();
});
});
});

View File

@@ -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"

View File

@@ -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(); }
};
};

View File

@@ -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,

View File

@@ -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(() => {

View 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;

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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} />

View File

@@ -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';

View File

@@ -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

View File

@@ -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,
};
};

View File

@@ -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,
);
});
});
});

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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();

View File

@@ -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;

View File

@@ -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);
});
});
});
});

View 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;

View 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);
});
});
});
});

View 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,
};
};

View 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);
});
});
});
});

View File

@@ -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);
};

View File

@@ -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()),

View File

@@ -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];

View File

@@ -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,
};

View File

@@ -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' },
}));
});
});
});
});

View File

@@ -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,
});

View File

@@ -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 };

View File

@@ -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);
};

View File

@@ -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);
});
});
});

View File

@@ -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: () => {

View File

@@ -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
View 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
View 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,
};

View 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,
};

View 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 });
});
});
});
});

View 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,
};

View 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 },
),
);
});
});
});

View 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,
};

View 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 }),
);
});
});
});

View 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,
};

View 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 }),
);
});
});
});

View 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;

View 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 }));
});
});
});

View File

@@ -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

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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

View 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;

View File

@@ -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,
};
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 />),
);
});
});

View File

@@ -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',

View 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,
};