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

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