feat: tracking upgrade and discovery (#66)

This commit is contained in:
leangseu-edx
2022-11-10 13:27:04 -05:00
committed by GitHub
parent e92a571cac
commit 48b157fdd0
16 changed files with 129 additions and 28 deletions

View File

@@ -5,6 +5,7 @@ import { Locked } from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { hooks } from 'data/redux';
import useTrackUpgradeData from './hooks';
import ActionButton from './ActionButton';
import messages from './messages';
@@ -14,11 +15,14 @@ export const UpgradeButton = ({ cardId }) => {
const { isMasquerading } = hooks.useMasqueradeData();
const { formatMessage } = useIntl();
const isEnabled = (!isMasquerading && canUpgrade);
const { trackUpgradeClick } = useTrackUpgradeData();
return (
<ActionButton
iconBefore={Locked}
variant="outline-primary"
disabled={!isEnabled}
onClick={trackUpgradeClick}
{...isEnabled && { as: 'a', href: upgradeUrl }}
>
{formatMessage(messages.upgrade)}

View File

@@ -12,6 +12,9 @@ jest.mock('data/redux', () => ({
},
}));
jest.mock('./ActionButton', () => 'ActionButton');
jest.mock('./hooks', () => () => ({
trackUpgradeClick: jest.fn().mockName('trackUpgradeClick'),
}));
describe('UpgradeButton', () => {
const props = {

View File

@@ -6,6 +6,7 @@ exports[`UpgradeButton snapshot can upgrade 1`] = `
disabled={false}
href="upgradeUrl"
iconBefore={[MockFunction icons.Locked]}
onClick={[MockFunction trackUpgradeClick]}
variant="outline-primary"
>
Upgrade
@@ -16,6 +17,7 @@ exports[`UpgradeButton snapshot cannot upgrade 1`] = `
<ActionButton
disabled={true}
iconBefore={[MockFunction icons.Locked]}
onClick={[MockFunction trackUpgradeClick]}
variant="outline-primary"
>
Upgrade
@@ -26,6 +28,7 @@ exports[`UpgradeButton snapshot masquerading 1`] = `
<ActionButton
disabled={true}
iconBefore={[MockFunction icons.Locked]}
onClick={[MockFunction trackUpgradeClick]}
variant="outline-primary"
>
Upgrade

View File

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,21 @@
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

@@ -5,10 +5,12 @@ export const events = StrictDict({
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',

View File

@@ -19,6 +19,7 @@ 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,4 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Card, Hyperlink, Icon } from '@edx/paragon';
import { ArrowForward } from '@edx/paragon/icons';
@@ -11,9 +13,11 @@ import './index.scss';
export const arrowIcon = (<Icon className="mx-1" src={ArrowForward} />);
export const LookingForChallengeWidget = () => {
const { formatMessage } = useIntl();
export const LookingForChallengeWidget = ({
courseSearchClickTracker,
}) => {
const { courseSearchUrl } = hooks.usePlatformSettingsData();
const { formatMessage } = useIntl();
return (
<Card orientation="horizontal" id="looking-for-challenge-widget">
<Card.ImageCap
@@ -25,7 +29,12 @@ export const LookingForChallengeWidget = () => {
{formatMessage(messages.lookingForChallengePrompt)}
</h4>
<h5>
<Hyperlink variant="brand" destination={courseSearchUrl} className="d-flex align-items-center">
<Hyperlink
variant="brand"
destination={courseSearchUrl}
onClick={courseSearchClickTracker}
className="d-flex align-items-center"
>
{formatMessage(messages.findCoursesButton, { arrow: arrowIcon })}
</Hyperlink>
</h5>
@@ -34,4 +43,8 @@ export const LookingForChallengeWidget = () => {
);
};
LookingForChallengeWidget.propTypes = {
courseSearchClickTracker: PropTypes.func.isRequired,
};
export default LookingForChallengeWidget;

View File

@@ -11,9 +11,12 @@ jest.mock('data/redux', () => ({
}));
describe('LookingForChallengeWidget', () => {
const props = {
courseSearchClickTracker: jest.fn().mockName('courseSearchClickTracker'),
};
describe('snapshots', () => {
test('default', () => {
const wrapper = shallow(<LookingForChallengeWidget />);
const wrapper = shallow(<LookingForChallengeWidget {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -13,6 +13,7 @@ import './index.scss';
export const LoadedView = ({
courses,
courseSearchClickTracker,
}) => {
const { courseSearchUrl } = hooks.usePlatformSettingsData();
const { formatMessage } = useIntl();
@@ -31,6 +32,7 @@ export const LoadedView = ({
iconBefore={Search}
as="a"
href={courseSearchUrl}
onClick={courseSearchClickTracker}
>
{formatMessage(messages.exploreCoursesButton)}
</Button>
@@ -46,6 +48,7 @@ LoadedView.propTypes = {
logoImageUrl: PropTypes.string,
marketingUrl: PropTypes.string,
})).isRequired,
courseSearchClickTracker: PropTypes.func.isRequired,
};
export default LoadedView;

View File

@@ -1,22 +1,24 @@
import React from 'react';
import { shallow } from 'enzyme';
import { hooks } from 'data/redux';
import LoadedView from './LoadedView';
import mockData from './mockData';
jest.mock('./components/CourseCard', () => 'CourseCard');
jest.mock('data/redux', () => ({
hooks: {
usePlatformSettingsData: jest.fn(),
usePlatformSettingsData: () => ({
courseSearchUrl: 'course-search-url',
}),
},
}));
const courseSearchUrl = 'test-course-search-url';
hooks.usePlatformSettingsData.mockReturnValue(courseSearchUrl);
describe('RecommendationsPanel LoadedView', () => {
const props = {
courses: mockData.courses,
courseSearchClickTracker: jest.fn().mockName('courseSearchClickTracker'),
};
test('snapshot', () => {
expect(shallow(<LoadedView courses={mockData.courses} />)).toMatchSnapshot();
expect(shallow(<LoadedView {...props} />)).toMatchSnapshot();
});
});

View File

@@ -60,6 +60,8 @@ exports[`RecommendationsPanel LoadedView snapshot 1`] = `
>
<Button
as="a"
href="course-search-url"
onClick={[MockFunction courseSearchClickTracker]}
variant="tertiary"
>
Explore courses

View File

@@ -2,10 +2,13 @@ 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';
export const searchCourseEventName = 'learner_home.widget.search_course';
export const state = StrictDict({
requestState: (val) => React.useState(val), // eslint-disable-line
data: (val) => React.useState(val), // eslint-disable-line
@@ -34,12 +37,18 @@ export const useRecommendationPanelData = () => {
const [data, setData] = module.state.data({});
module.useFetchCourses(setRequestState, setData);
const courses = data.data?.courses || [];
const courseSearchClickTracker = () => handleEvent(searchCourseEventName, {
pageName: 'learner_home',
linkType: 'button',
linkCategory: 'search_button',
});
return {
courses,
isLoaded: requestState === RequestStates.completed && courses.length > 0,
isFailed: requestState === RequestStates.failed
|| (requestState === RequestStates.completed && courses.length === 0),
isLoading: requestState === RequestStates.pending,
courseSearchClickTracker,
};
};

View File

@@ -2,6 +2,7 @@ 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';
@@ -9,6 +10,9 @@ 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);
@@ -96,6 +100,16 @@ 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(() => {

View File

@@ -11,19 +11,20 @@ export const RecommendationsPanel = () => {
isFailed,
isLoaded,
isLoading,
courseSearchClickTracker,
} = hooks.useRecommendationPanelData();
if (isLoading) {
return (<LoadingView />);
}
if (isLoaded) {
return (<LoadedView courses={courses} />);
return (<LoadedView courses={courses} courseSearchClickTracker={courseSearchClickTracker} />);
}
if (isFailed) {
return (<LookingForChallengeWidget />);
return (<LookingForChallengeWidget courseSearchClickTracker={courseSearchClickTracker} />);
}
// default fallback
return (<LookingForChallengeWidget />);
return (<LookingForChallengeWidget courseSearchClickTracker={courseSearchClickTracker} />);
};
export default RecommendationsPanel;

View File

@@ -18,46 +18,48 @@ jest.mock('./LoadedView', () => 'LoadedView');
const { courses } = mockData;
describe('RecommendationsPanel snapshot', () => {
const defaultProps = {
courseSearchClickTracker: jest.fn().mockName('courseSearchClickTracker'),
};
const defaultValues = {
isFailed: false,
isLoaded: false,
isLoading: false,
courses: [],
...defaultProps,
};
it('displays LoadingView if request is loading', () => {
hooks.useRecommendationPanelData.mockReturnValueOnce({
courses: [],
isFailed: false,
isLoaded: false,
...defaultValues,
isLoading: true,
});
expect(shallow(<RecommendationsPanel />)).toMatchObject(shallow(<LoadingView />));
});
it('displays LoadedView with courses if request is loaded', () => {
hooks.useRecommendationPanelData.mockReturnValueOnce({
...defaultValues,
courses,
isFailed: false,
isLoaded: true,
isLoading: false,
});
expect(shallow(<RecommendationsPanel />)).toMatchObject(
shallow(<LoadedView courses={courses} />),
shallow(<LoadedView courses={courses} {...defaultProps} />),
);
});
it('displays LookingForChallengeWidget if request is failed', () => {
hooks.useRecommendationPanelData.mockReturnValueOnce({
courses: [],
...defaultValues,
isFailed: true,
isLoaded: false,
isLoading: false,
});
expect(shallow(<RecommendationsPanel />)).toMatchObject(
shallow(<LookingForChallengeWidget />),
shallow(<LookingForChallengeWidget {...defaultProps} />),
);
});
it('defaults to LookingForChallengeWidget if no flags are true', () => {
hooks.useRecommendationPanelData.mockReturnValueOnce({
courses: [],
isFailed: false,
isLoaded: false,
isLoading: false,
...defaultValues,
});
expect(shallow(<RecommendationsPanel />)).toMatchObject(
shallow(<LookingForChallengeWidget />),
shallow(<LookingForChallengeWidget {...defaultProps} />),
);
});
});