feat: Implemented product recommendations experiment (#174)

This commit is contained in:
Jody Bailey
2023-07-11 16:45:14 +02:00
committed by GitHub
parent 58c3720087
commit 103a67654c
32 changed files with 844 additions and 153 deletions

View File

@@ -3,6 +3,9 @@ import urls from 'data/services/lms/urls';
export const crossProductAndAmplitudeRecommendationsUrl = (courseId) => `${urls.getApiUrl()}/learner_recommendations/product_recommendations/${courseId}/`;
export const amplitudeRecommendationsUrl = () => `${urls.getApiUrl()}/learner_recommendations/product_recommendations/`;
export const recommendationsContextUrl = () => `${urls.getApiUrl()}/learner_recommendations/recommendations_context/`;
const fetchRecommendationsContext = () => get(stringifyUrl(recommendationsContextUrl()));
const fetchCrossProductRecommendations = (courseId) => (
get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl(courseId)))
@@ -12,4 +15,5 @@ const fetchAmplitudeRecommendations = () => get(stringifyUrl(amplitudeRecommenda
export default {
fetchCrossProductRecommendations,
fetchAmplitudeRecommendations,
fetchRecommendationsContext,
};

View File

@@ -1,6 +1,6 @@
import { get, stringifyUrl } from 'data/services/lms/utils';
import api, { crossProductAndAmplitudeRecommendationsUrl, amplitudeRecommendationsUrl } from './api';
import api, { crossProductAndAmplitudeRecommendationsUrl, amplitudeRecommendationsUrl, recommendationsContextUrl } from './api';
jest.mock('data/services/lms/utils', () => ({
stringifyUrl: (...args) => ({ stringifyUrl: args }),
@@ -23,4 +23,12 @@ describe('productRecommendationCourses api', () => {
);
});
});
describe('fetchRecommendationsContext', () => {
it('calls get with the correct recommendation courses URL', () => {
expect(api.fetchRecommendationsContext()).toEqual(
get(stringifyUrl(recommendationsContextUrl())),
);
});
});
});

View File

@@ -6,52 +6,90 @@ import {
Truncate,
Hyperlink,
} from '@edx/paragon';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { trackProductCardClicked, trackCourseCardClicked } from '../optimizelyExperiment';
import { productCardClicked, discoveryCardClicked } from '../track';
import { bootCamp, executiveEducation } from '../constants';
const ProductCard = ({
title,
subtitle,
headerImage,
courseRunKey,
schoolLogo,
courseType,
url,
}) => (
<Card
className="base-card d-flex text-decoration-none"
as={Hyperlink}
destination={url}
isClickable
>
<Card.ImageCap
src={headerImage}
srcAlt={`header image for ${title}`}
logoSrc={schoolLogo}
logoAlt={`logo for ${subtitle}`}
/>
<Card.Header
className="mt-2"
title={(
<Truncate lines={3} ellipsis="…" className="product-card-title font-weight-bold">
{title}
</Truncate>
}) => {
const handleClick = (type, link) => {
const crossProductTypes = [executiveEducation, bootCamp];
const userId = getAuthenticatedUser().userId.toString();
if (crossProductTypes.includes(type)) {
trackProductCardClicked(userId);
productCardClicked(courseRunKey, title, type, link);
} else {
trackCourseCardClicked(userId);
discoveryCardClicked(courseRunKey, title, link);
}
};
const getRedirectUrl = (link) => {
const urlObj = new URL(link);
const hasQueryStringParameters = urlObj.search !== '';
if (hasQueryStringParameters) {
return `${link}&linked_from=recommender`;
}
return `${link}?linked_from=recommender`;
};
const redirectUrl = getRedirectUrl(url);
return (
<Card
className="base-card d-flex text-decoration-none"
as={Hyperlink}
destination={redirectUrl}
onClick={() => {
handleClick(courseType, redirectUrl);
}}
isClickable
>
<Card.ImageCap
src={headerImage}
srcAlt={`header image for ${title}`}
logoSrc={schoolLogo}
logoAlt={`logo for ${subtitle}`}
/>
<Card.Header
className="mt-2"
title={(
<Truncate lines={3} ellipsis="…" className="product-card-title font-weight-bold">
{title}
</Truncate>
)}
subtitle={(
<Truncate lines={1} className="product-card-subtitle font-weight-normal">
{subtitle}
</Truncate>
)}
subtitle={(
<Truncate lines={1} className="product-card-subtitle font-weight-normal">
{subtitle}
</Truncate>
)}
/>
<Card.Section>
<div className="product-badge position-absolute">
<Badge className="bg-light-500 text-dark-500">{courseType}</Badge>
</div>
</Card.Section>
</Card>
);
/>
<Card.Section>
<div className="product-badge position-absolute">
<Badge className="bg-light-500 text-dark-500">{courseType}</Badge>
</div>
</Card.Section>
</Card>
);
};
ProductCard.propTypes = {
title: PropTypes.string.isRequired,
subtitle: PropTypes.string.isRequired,
headerImage: PropTypes.string.isRequired,
courseRunKey: PropTypes.string.isRequired,
schoolLogo: PropTypes.string.isRequired,
courseType: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,

View File

@@ -1,29 +1,83 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mockCrossProductCourses } from '../testData';
import { mockCrossProductCourses, mockOpenCourses, mockFallbackOpenCourse } from '../testData';
import { trackProductCardClicked, trackCourseCardClicked } from '../optimizelyExperiment';
import { productCardClicked, discoveryCardClicked } from '../track';
import ProductCard from './ProductCard';
import { courseTypeToProductTypeMap } from '../utils';
describe('ProductRecommendations ProductCard', () => {
const course = mockCrossProductCourses[0];
const {
title,
owners: [{ name: subtitle }],
image: { src: headerImage },
owners: [{ logoImageUrl: schoolLogo }],
} = course;
jest.mock('../optimizelyExperiment', () => ({
trackProductCardClicked: jest.fn(),
trackCourseCardClicked: jest.fn(),
}));
const props = {
title,
subtitle,
headerImage,
schoolLogo,
courseType: courseTypeToProductTypeMap[course.courseType],
url: `${course.marketingUrl}&linked_from=recommender`,
jest.mock('../track', () => ({
productCardClicked: jest.fn(),
discoveryCardClicked: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({ userId: '1' })),
}));
describe('ProductRecommendations ProductCard', () => {
const getProps = (course) => {
const {
title,
owners: [{ name: subtitle }],
image: { src: headerImage },
owners: [{ logoImageUrl: schoolLogo }],
} = course;
return {
title,
subtitle,
headerImage,
schoolLogo,
courseType: courseTypeToProductTypeMap[course.courseType],
courseRunKey: course.courseRunKey,
url: course.marketingUrl,
};
};
const crossProductProps = getProps(mockCrossProductCourses[0]);
const openCourseProps = getProps(mockOpenCourses[0]);
const fallbackOpenCourseProps = getProps(mockFallbackOpenCourse[0]);
it('matches snapshot', () => {
expect(shallow(<ProductCard {...props} />)).toMatchSnapshot();
expect(shallow(<ProductCard {...crossProductProps} />)).toMatchSnapshot();
});
it('has the query string parameter attached to a fallback recommendations url', () => {
const wrapper = shallow(<ProductCard {...fallbackOpenCourseProps} />);
const cardUrl = wrapper.find('Card').props().destination;
expect(cardUrl).toEqual('https://www.edx.org/course/some-course?linked_from=recommender');
});
it('send outs experiment events related to open courses when clicked', () => {
const wrapper = shallow(<ProductCard {...openCourseProps} />);
const { courseRunKey, title, url } = openCourseProps;
wrapper.simulate('click');
expect(trackCourseCardClicked).toHaveBeenCalledWith('1');
expect(discoveryCardClicked).toHaveBeenCalledWith(courseRunKey, title, `${url}&linked_from=recommender`);
});
it('send outs experiment events related to cross product courses when clicked', () => {
const wrapper = shallow(<ProductCard {...crossProductProps} />);
const {
courseRunKey,
title,
courseType,
url,
} = crossProductProps;
wrapper.simulate('click');
expect(trackProductCardClicked).toHaveBeenCalledWith('1');
expect(productCardClicked).toHaveBeenCalledWith(courseRunKey, title, courseType, `${url}&linked_from=recommender`);
});
});

View File

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { courseShape, courseTypeToProductTypeMap } from '../utils';
import { course } from '../constants';
import ProductCard from './ProductCard';
import ProductCardHeader from './ProductCardHeader';
@@ -14,18 +15,19 @@ const ProductCardContainer = ({ finalProductList, courseTypes }) => (
<ProductCardHeader courseType={type} />
<div
className={classNames('d-flex', {
'course-subcontainer': type === 'Course',
'course-subcontainer': type === course,
})}
>
{finalProductList
.filter((course) => courseTypeToProductTypeMap[course.courseType] === type)
.filter((courseObj) => courseTypeToProductTypeMap[courseObj.courseType] === type)
.map((item) => (
<ProductCard
key={item.title}
url={`${item.marketingUrl}&linked_from=recommender`}
url={item.marketingUrl}
title={item.title}
subtitle={item.owners[0].name}
headerImage={item.image.src}
courseRunKey={item.courseRunKey}
schoolLogo={item.owners[0].logoImageUrl}
courseType={type}
/>

View File

@@ -3,11 +3,12 @@ import { shallow } from 'enzyme';
import { mockCrossProductCourses, mockOpenCourses } from '../testData';
import ProductCardContainer from './ProductCardContainer';
import { executiveEducation, bootCamp, course } from '../constants';
describe('ProductRecommendations ProductCardContainer', () => {
const props = {
finalProductList: [...mockCrossProductCourses, ...mockOpenCourses],
courseTypes: ['Executive Education', 'Boot Camp', 'Course'],
courseTypes: [executiveEducation, bootCamp, course],
};
it('matches snapshot', () => {

View File

@@ -1,23 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { Icon, Hyperlink } from '@edx/paragon';
import { ChevronRight } from '@edx/paragon/icons';
import { trackProductHeaderClicked } from '../optimizelyExperiment';
import { recommendationsHeaderClicked } from '../track';
import { executiveEducation, bootCamp } from '../constants';
import messages from '../messages';
const ProductCardHeader = ({ courseType }) => {
const { formatMessage } = useIntl();
const getProductTypeDetail = (type) => {
switch (type) {
case 'Executive Education':
case executiveEducation:
return {
heading: messages.executiveEducationHeading,
description: messages.executiveEducationDescription,
url: '/executive-education?linked_from=recommender',
};
case 'Boot Camp':
case bootCamp:
return {
heading: messages.bootcampHeading,
description: messages.bootcampDescription,
@@ -27,19 +29,31 @@ const ProductCardHeader = ({ courseType }) => {
return {
heading: messages.courseHeading,
description: messages.courseDescription,
url: '/search?tab=course?linked_from=recommender',
url: '/search?tab=course',
};
}
}
};
const handleClick = (type, url) => {
const userId = getAuthenticatedUser().userId.toString();
trackProductHeaderClicked(userId);
recommendationsHeaderClicked(type, url);
};
const { formatMessage } = useIntl();
const productTypeDetail = getProductTypeDetail(courseType);
const headerUrl = `${process.env.MARKETING_SITE_BASE_URL}${productTypeDetail.url}`;
return (
<div>
<Hyperlink
destination={`https://www.edx.org${productTypeDetail.url}`}
destination={headerUrl}
className="base-card-link text-decoration-none"
onClick={() => {
handleClick(courseType, headerUrl);
}}
>
<div className="d-flex align-items-center border-bottom">
<h3 className="h3 mb-2 text-left">

View File

@@ -2,28 +2,53 @@ import React from 'react';
import { shallow } from 'enzyme';
import ProductCardHeader from './ProductCardHeader';
import { executiveEducation, bootCamp } from '../constants';
import { trackProductHeaderClicked } from '../optimizelyExperiment';
import { recommendationsHeaderClicked } from '../track';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({ userId: '1' })),
}));
jest.mock('../optimizelyExperiment', () => ({
trackProductHeaderClicked: jest.fn(),
}));
jest.mock('../track', () => ({
recommendationsHeaderClicked: jest.fn(),
}));
describe('ProductRecommendations ProductCardHeader', () => {
const bootCampType = 'Boot Camp';
const executiveEducationType = 'Executive Education';
const courseType = 'Courses';
const coursesType = 'Courses';
it('matches snapshot', () => {
expect(shallow(<ProductCardHeader courseType={executiveEducationType} />)).toMatchSnapshot();
expect(shallow(<ProductCardHeader courseType={executiveEducation} />)).toMatchSnapshot();
});
describe('with bootcamp courseType prop', () => {
it('renders a bootcamp header', () => {
const wrapper = shallow(<ProductCardHeader courseType={bootCampType} />);
const wrapper = shallow(<ProductCardHeader courseType={bootCamp} />);
expect(wrapper.find('h3').text()).toEqual(bootCampType);
expect(wrapper.find('h3').text()).toEqual(bootCamp);
});
});
describe('with course courseType prop', () => {
describe('with courses courseType prop', () => {
it('renders a courses header', () => {
const wrapper = shallow(<ProductCardHeader courseType={courseType} />);
const wrapper = shallow(<ProductCardHeader courseType={coursesType} />);
expect(wrapper.find('h3').text()).toEqual(courseType);
expect(wrapper.find('h3').text()).toEqual(coursesType);
});
});
it('send outs experiment events when clicked', () => {
const wrapper = shallow(<ProductCardHeader courseType={executiveEducation} />);
const hyperLink = wrapper.find('Hyperlink');
const execEdLink = 'http://localhost:18000/executive-education?linked_from=recommender';
hyperLink.simulate('click');
expect(trackProductHeaderClicked).toHaveBeenCalledWith('1');
expect(recommendationsHeaderClicked).toHaveBeenCalledWith(executiveEducation, execEdLink);
});
});

View File

@@ -6,6 +6,7 @@ exports[`ProductRecommendations ProductCard matches snapshot 1`] = `
className="base-card d-flex text-decoration-none"
destination="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
isClickable={true}
onClick={[Function]}
>
<Card.ImageCap
logoAlt="logo for Harvard University"

View File

@@ -14,13 +14,14 @@ exports[`ProductRecommendations ProductCardContainer matches snapshot 1`] = `
className="d-flex"
>
<ProductCard
courseRunKey="course-v1:Test+Course+2022T2"
courseType="Executive Education"
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
key="Introduction to Computer Science"
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
subtitle="Harvard University"
title="Introduction to Computer Science"
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
url="https://www.edx.org/course/some-course?utm_source=source"
/>
</div>
</div>
@@ -34,13 +35,14 @@ exports[`ProductRecommendations ProductCardContainer matches snapshot 1`] = `
className="d-flex"
>
<ProductCard
courseRunKey="course-v1:Test+Course+2022T2"
courseType="Boot Camp"
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
key="Introduction to Computer Science"
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
subtitle="Harvard University"
title="Introduction to Computer Science"
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
url="https://www.edx.org/course/some-course?utm_source=source"
/>
</div>
</div>
@@ -54,40 +56,44 @@ exports[`ProductRecommendations ProductCardContainer matches snapshot 1`] = `
className="d-flex course-subcontainer"
>
<ProductCard
courseRunKey="course-v1:Test+Course+2022T2"
courseType="Course"
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
key="Introduction to Computer Science"
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
subtitle="Harvard University"
title="Introduction to Computer Science"
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
url="https://www.edx.org/course/some-course?utm_source=source"
/>
<ProductCard
courseRunKey="course-v1:Test+Course+2022T2"
courseType="Course"
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
key="Introduction to Computer Science"
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
subtitle="Harvard University"
title="Introduction to Computer Science"
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
url="https://www.edx.org/course/some-course?utm_source=source"
/>
<ProductCard
courseRunKey="course-v1:Test+Course+2022T2"
courseType="Course"
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
key="Introduction to Computer Science"
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
subtitle="Harvard University"
title="Introduction to Computer Science"
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
url="https://www.edx.org/course/some-course?utm_source=source"
/>
<ProductCard
courseRunKey="course-v1:Test+Course+2022T2"
courseType="Course"
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
key="Introduction to Computer Science"
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
subtitle="Harvard University"
title="Introduction to Computer Science"
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
url="https://www.edx.org/course/some-course?utm_source=source"
/>
</div>
</div>

View File

@@ -4,7 +4,8 @@ exports[`ProductRecommendations ProductCardHeader matches snapshot 1`] = `
<div>
<Hyperlink
className="base-card-link text-decoration-none"
destination="https://www.edx.org/executive-education?linked_from=recommender"
destination="http://localhost:18000/executive-education?linked_from=recommender"
onClick={[Function]}
>
<div
className="d-flex align-items-center border-bottom"

View File

@@ -0,0 +1,7 @@
export const bootCamp = 'Boot Camp';
export const executiveEducation = 'Executive Education';
export const course = 'Course';
export const control = 'control';
export const treatment = 'treatment';
export const noExperiment = 'no experiment';

View File

@@ -1,9 +1,16 @@
import { useState, useEffect } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { RequestStates, RequestKeys } from 'data/constants/requests';
import { StrictDict } from 'utils';
import { reduxHooks } from 'hooks';
import { SortKeys } from 'data/constants/app';
import { useWindowSize, breakpoints } from '@edx/paragon';
import { useExperimentContext } from 'ExperimentContext';
import { control, treatment, noExperiment } from './constants';
import { activateProductRecommendationsExperiment, trackProductRecommendationsViewed } from './optimizelyExperiment';
import { recommendationsViewed } from './track';
import api from './api';
import * as module from './hooks';
@@ -17,16 +24,6 @@ export const useIsMobile = () => {
return width < breakpoints.small.minWidth;
};
export const useShowRecommendationsFooter = () => {
const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards();
const hasRequestCompleted = reduxHooks.useRequestIsCompleted(RequestKeys.initialize);
return {
shouldShowFooter: false,
shouldLoadFooter: hasRequestCompleted && !hasAvailableDashboards,
};
};
export const useMostRecentCourseRunKey = () => {
const mostRecentCourseRunKey = reduxHooks.useCurrentCourseList({
sortBy: SortKeys.enrolled,
@@ -37,6 +34,61 @@ export const useMostRecentCourseRunKey = () => {
return mostRecentCourseRunKey;
};
export const useActivateRecommendationsExperiment = () => {
const enterpriseDashboardData = reduxHooks.useEnterpriseDashboardData();
const hasRequestCompleted = reduxHooks.useRequestIsCompleted(RequestKeys.initialize);
const mostRecentCourseRunKey = module.useMostRecentCourseRunKey();
const userId = getAuthenticatedUser().userId.toString();
const {
experiment: { isExperimentActive },
setExperiment,
isMobile,
countryCode,
} = useExperimentContext();
useEffect(() => {
if (!isExperimentActive && countryCode !== null) {
const activateExperiment = () => {
const userAttributes = {
is_mobile_user: isMobile,
is_enterprise_user: !!enterpriseDashboardData,
location: countryCode ? countryCode.toLowerCase() : '',
};
const experiment = activateProductRecommendationsExperiment(userId, userAttributes);
setExperiment((prev) => ({
...prev,
isExperimentActive: true,
inRecommendationsVariant: experiment.inExperimentVariant,
}));
return experiment;
};
const sendViewedEvent = () => {
trackProductRecommendationsViewed(userId);
recommendationsViewed(true, control, mostRecentCourseRunKey);
};
if (hasRequestCompleted) {
const { experimentActivated, inExperimentVariant } = activateExperiment();
if (experimentActivated && !inExperimentVariant) {
sendViewedEvent();
}
}
}
/* eslint-disable */
}, [isExperimentActive, countryCode])
};
export const useShowRecommendationsFooter = () => {
const { experiment } = useExperimentContext();
return experiment;
};
export const useFetchRecommendations = (setRequestState, setData) => {
const courseRunKey = module.useMostRecentCourseRunKey();
@@ -74,10 +126,29 @@ export const useFetchRecommendations = (setRequestState, setData) => {
}, []);
};
export const useSendViewedEvents = (requestState, data) => {
const mostRecentCourseRunKey = module.useMostRecentCourseRunKey();
const userId = getAuthenticatedUser().userId.toString();
useEffect(() => {
if (requestState === RequestStates.completed) {
if (data.crossProductCourses?.length === 2) {
trackProductRecommendationsViewed(userId);
recommendationsViewed(false, treatment, mostRecentCourseRunKey);
} else {
trackProductRecommendationsViewed(userId);
recommendationsViewed(true, noExperiment, mostRecentCourseRunKey);
}
}
}, [data, requestState])
}
export const useProductRecommendationsData = () => {
const [requestState, setRequestState] = module.state.requestState(RequestStates.pending);
const [data, setData] = module.state.data({});
module.useFetchRecommendations(setRequestState, setData);
module.useSendViewedEvents(requestState, data);
return {
productRecommendations: data,
@@ -87,4 +158,4 @@ export const useProductRecommendationsData = () => {
};
};
export default { useProductRecommendationsData, useShowRecommendationsFooter, useIsMobile };
export default { useProductRecommendationsData, useShowRecommendationsFooter, useIsMobile, useActivateRecommendationsExperiment };

View File

@@ -5,7 +5,13 @@ import { MockUseState } from 'testUtils';
import { RequestStates } from 'data/constants/requests';
import { reduxHooks } from 'hooks';
import { useWindowSize } from '@edx/paragon';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useExperimentContext } from 'ExperimentContext';
import { recommendationsViewed } from './track';
import { activateProductRecommendationsExperiment, trackProductRecommendationsViewed } from './optimizelyExperiment';
import { control, treatment, noExperiment } from './constants';
import { wait } from './utils';
import { mockCrossProductResponse, mockAmplitudeResponse } from './testData';
import api from './api';
import * as hooks from './hooks';
@@ -13,16 +19,34 @@ import * as hooks from './hooks';
jest.mock('./api', () => ({
fetchCrossProductRecommendations: jest.fn(),
fetchAmplitudeRecommendations: jest.fn(),
fetchRecommendationsContext: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(),
}));
jest.mock('ExperimentContext', () => ({
useExperimentContext: jest.fn(),
}));
jest.mock('hooks', () => ({
reduxHooks: {
useCurrentCourseList: jest.fn(),
useHasAvailableDashboards: jest.fn(),
useEnterpriseDashboardData: jest.fn(),
useRequestIsCompleted: jest.fn(),
},
}));
jest.mock('./track', () => ({
recommendationsViewed: jest.fn(),
}));
jest.mock('./optimizelyExperiment', () => ({
trackProductRecommendationsViewed: jest.fn(),
activateProductRecommendationsExperiment: jest.fn(),
}));
const state = new MockUseState(hooks);
const mostRecentCourseRunKey = 'course ID 1';
@@ -53,6 +77,7 @@ let output;
describe('ProductRecommendations hooks', () => {
beforeEach(() => {
jest.resetAllMocks();
getAuthenticatedUser.mockImplementation(() => ({ userId: '1' }));
});
describe('state fields', () => {
@@ -86,14 +111,175 @@ describe('ProductRecommendations hooks', () => {
});
describe('useShowRecommendationsFooter', () => {
// TODO: Update when hardcoded value is removed
it('returns whether the footer widget should show and should load', () => {
reduxHooks.useHasAvailableDashboards.mockReturnValueOnce(false);
reduxHooks.useRequestIsCompleted.mockReturnValueOnce(true);
const { shouldShowFooter, shouldLoadFooter } = hooks.useShowRecommendationsFooter();
it('returns the experiment object, stating if the experiment has activated and the variant', () => {
useExperimentContext
.mockImplementationOnce(() => ({ experiment: { inRecommendationsVariant: true, isExperimentActive: false } }));
expect(shouldShowFooter).toBeFalsy();
expect(shouldLoadFooter).toBeTruthy();
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
expect(useExperimentContext).toHaveBeenCalled();
expect(inRecommendationsVariant).toBeTruthy();
expect(isExperimentActive).toBeFalsy();
});
});
describe('useActivateRecommendationsExperiment', () => {
describe('behavior', () => {
describe('useEffect call', () => {
let cb;
let calls;
let prereqs;
const setExperiment = jest.fn();
const setCountryCode = jest.fn();
const userAttributes = { is_enterprise_user: false, is_mobile_user: false, location: 'za' };
const optimizelyExperimentMock = ({
experimentActivated = false,
inExperimentVariant = false,
}) => ({
experimentActivated,
inExperimentVariant,
});
const experimentContextMock = ({
isExperimentActive = false,
inRecommendationsVariant = true,
countryCode = 'ZA',
isMobile = false,
}) => ({
experiment: { isExperimentActive, inRecommendationsVariant },
countryCode,
isMobile,
setExperiment,
setCountryCode,
});
const setUp = (
isCompleted,
experimentContext = experimentContextMock({}),
optimizelyExperiment = optimizelyExperimentMock({}),
) => {
reduxHooks.useCurrentCourseList.mockReturnValueOnce(populatedCourseListData);
reduxHooks.useEnterpriseDashboardData.mockReturnValueOnce(null);
reduxHooks.useRequestIsCompleted.mockReturnValueOnce(isCompleted);
useExperimentContext.mockReturnValueOnce(experimentContext);
activateProductRecommendationsExperiment.mockReturnValueOnce(optimizelyExperiment);
hooks.useActivateRecommendationsExperiment();
({ calls } = React.useEffect.mock);
([[cb, prereqs]] = calls);
};
it('runs when isExperimentActive or countryCode changes (prereqs)', () => {
setUp(true);
expect(prereqs).toEqual([false, 'ZA']);
expect(calls.length).toEqual(1);
});
describe('when the request state is not completed', () => {
it('does not activate or send any events', () => {
setUp(false);
cb();
expect(activateProductRecommendationsExperiment).not.toHaveBeenCalled();
expect(trackProductRecommendationsViewed).not.toHaveBeenCalled();
expect(recommendationsViewed).not.toHaveBeenCalled();
});
});
describe('when the experiment is active', () => {
it('does not activate or send any events', () => {
setUp(true, experimentContextMock({ isExperimentActive: true }));
cb();
expect(activateProductRecommendationsExperiment).not.toHaveBeenCalled();
expect(trackProductRecommendationsViewed).not.toHaveBeenCalled();
expect(recommendationsViewed).not.toHaveBeenCalled();
});
});
describe('when the experiment is inactive but user country code has not been fetched', () => {
it('does not activate or send any events', () => {
setUp(true, experimentContextMock({ countryCode: null }));
cb();
expect(activateProductRecommendationsExperiment).not.toHaveBeenCalled();
expect(trackProductRecommendationsViewed).not.toHaveBeenCalled();
expect(recommendationsViewed).not.toHaveBeenCalled();
});
});
describe('when the experiment is inactive and user country code has been fetched', () => {
it('activates the experiment and sends viewed event for control group', () => {
setUp(
true,
experimentContextMock({}),
optimizelyExperimentMock({ experimentActivated: true, inExperimentVariant: false }),
);
cb();
expect(activateProductRecommendationsExperiment).toHaveBeenCalledWith('1', userAttributes);
expect(setExperiment).toHaveBeenCalled();
expect(trackProductRecommendationsViewed).toHaveBeenCalledWith('1');
expect(recommendationsViewed).toHaveBeenCalledWith(true, control, mostRecentCourseRunKey);
});
it('activates the experiment and does not sends viewed event for treatment group', () => {
setUp(
true,
experimentContextMock({ countryCode: '' }),
optimizelyExperimentMock({ experimentActivated: true, inExperimentVariant: true }),
);
cb();
expect(activateProductRecommendationsExperiment).toHaveBeenCalledWith('1', { ...userAttributes, location: '' });
expect(setExperiment).toHaveBeenCalled();
expect(trackProductRecommendationsViewed).not.toHaveBeenCalled();
expect(recommendationsViewed).not.toHaveBeenCalled();
});
});
});
});
});
describe('useSendViewedEvents', () => {
describe('behavior', () => {
describe('useEffect call', () => {
let cb;
let calls;
let prereqs;
const { completed, pending } = RequestStates;
const setUp = (requestState, response) => {
reduxHooks.useCurrentCourseList.mockReturnValueOnce(populatedCourseListData);
hooks.useSendViewedEvents(requestState, response);
({ calls } = React.useEffect.mock);
([[cb, prereqs]] = calls);
};
it('runs when data or requestState changes (prereqs)', () => {
setUp(completed, mockCrossProductResponse);
expect(prereqs).toEqual([mockCrossProductResponse, completed]);
expect(calls.length).toEqual(1);
});
describe('when the request state is not completed', () => {
it('does not send any events', () => {
setUp(pending, mockCrossProductResponse);
cb();
expect(trackProductRecommendationsViewed).not.toHaveBeenCalled();
expect(recommendationsViewed).not.toHaveBeenCalled();
});
});
describe('when the request state is completed', () => {
describe('with crossProduct data that has 2 cross product courses', () => {
it('sends out recommendations viewed event for "treatment" group', () => {
setUp(completed, mockCrossProductResponse);
cb();
expect(trackProductRecommendationsViewed).toHaveBeenCalledWith('1');
expect(recommendationsViewed).toHaveBeenCalledWith(false, treatment, mostRecentCourseRunKey);
});
});
describe('with amplitude data and no cross product data', () => {
it('sends out recommendations viewed event for "no experiment" group', () => {
setUp(completed, mockAmplitudeResponse);
cb();
expect(trackProductRecommendationsViewed).toHaveBeenCalledWith('1');
expect(recommendationsViewed).toHaveBeenCalledWith(true, noExperiment, mostRecentCourseRunKey);
});
});
});
});
});
});
@@ -281,14 +467,19 @@ describe('ProductRecommendations hooks', () => {
});
});
describe('useProductRecommendationsData', () => {
let fetchSpy;
let fetchRecommendationsSpy;
let sendViewedEventsSpy;
beforeEach(() => {
state.mock();
fetchSpy = jest.spyOn(hooks, 'useFetchRecommendations').mockImplementationOnce(() => {});
fetchRecommendationsSpy = jest.spyOn(hooks, 'useFetchRecommendations').mockImplementationOnce(() => {});
sendViewedEventsSpy = jest.spyOn(hooks, 'useSendViewedEvents').mockImplementationOnce(() => {});
output = hooks.useProductRecommendationsData();
});
it('calls useFetchRecommendations with setRequestState and setData', () => {
expect(fetchSpy).toHaveBeenCalledWith(state.setState.requestState, state.setState.data);
expect(fetchRecommendationsSpy).toHaveBeenCalledWith(state.setState.requestState, state.setState.data);
});
it('calls useFetchViewedEvents with requestState and data', () => {
expect(sendViewedEventsSpy).toHaveBeenCalledWith(state.stateVals.requestState, state.stateVals.data);
});
it('initializes requestState as RequestStates.pending', () => {
state.expectInitializedWith(state.keys.requestState, RequestStates.pending);

View File

@@ -18,7 +18,10 @@ export const activateProductRecommendationsExperiment = (userId, userAttributes)
userAttributes,
);
return variation === PRODUCT_RECOMMENDATIONS_EXP_VARIATION;
return {
experimentActivated: variation !== null,
inExperimentVariant: variation === PRODUCT_RECOMMENDATIONS_EXP_VARIATION,
};
};
export const trackProductRecommendationsViewed = (userId, userAttributes = {}) => {

View File

@@ -24,11 +24,25 @@ const userAttributes = {
describe('Optimizely events', () => {
describe('activateProductRecommendationsExperiment', () => {
it('activates the experiment and returns in recommendations experiment variant', () => {
it('activates the experiment and returns in experiment variant', () => {
optimizelyClient.activate.mockReturnValueOnce(PRODUCT_RECOMMENDATIONS_EXP_VARIATION);
const inRecommendationsVariant = activateProductRecommendationsExperiment(userId, userAttributes);
const experiment = activateProductRecommendationsExperiment(userId, userAttributes);
expect(inRecommendationsVariant).toBeTruthy();
expect(experiment.experimentActivated).toBeTruthy();
expect(experiment.inExperimentVariant).toBeTruthy();
expect(optimizelyClient.activate).toHaveBeenCalledWith(
PRODUCT_RECOMMENDATIONS_EXP_KEY,
userId,
userAttributes,
);
});
it('does not activate the experiment and returns not in experiment variant', () => {
optimizelyClient.activate.mockReturnValueOnce(null);
const experiment = activateProductRecommendationsExperiment(userId, userAttributes);
expect(experiment.experimentActivated).toBeFalsy();
expect(experiment.inExperimentVariant).toBeFalsy();
expect(optimizelyClient.activate).toHaveBeenCalledWith(
PRODUCT_RECOMMENDATIONS_EXP_KEY,
userId,

View File

@@ -1,11 +1,14 @@
export const getCoursesWithType = (courseTypes) => {
export const getCoursesWithType = (courseTypes, parameters = true) => {
const courses = [];
const marketingUrl = parameters
? 'https://www.edx.org/course/some-course?utm_source=source'
: 'https://www.edx.org/course/some-course';
courseTypes.forEach((type) => {
courses.push({
title: 'Introduction to Computer Science',
courseRunKey: 'course-v1:Test+Course+2022T2',
marketingUrl: 'https://www.edx.org/course/some-course?utm_source=source',
marketingUrl,
courseType: type,
image: {
src: 'https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg',
@@ -24,14 +27,14 @@ export const getCoursesWithType = (courseTypes) => {
};
export const mockFooterRecommendationsHook = {
showAndLoad: { shouldShowFooter: true, shouldLoadFooter: true },
showDontLoad: { shouldShowFooter: true, shouldLoadFooter: false },
loadDontShow: { shouldShowFooter: false, shouldLoadFooter: true },
dontShowOrLoad: { shouldShowFooter: false, shouldLoadFooter: false },
default: { isExperimentActive: false, inRecommendationsVariant: true },
activeControl: { isExperimentActive: true, inRecommendationsVariant: false },
activeTreatment: { isExperimentActive: true, inRecommendationsVariant: true },
};
export const mockCrossProductCourses = getCoursesWithType(['executive-education-2u', 'bootcamp-2u']);
export const mockOpenCourses = getCoursesWithType(['verified-audit', 'audit', 'verified', 'course']);
export const mockFallbackOpenCourse = getCoursesWithType(['course'], false);
export const mockCrossProductResponse = {
crossProductCourses: mockCrossProductCourses,

View File

@@ -12,7 +12,6 @@ export const eventNames = StrictDict({
export const productCardClicked = (courseRunKey, courseTitle, courseType, href) => {
createLinkTracker(
createEventTracker(eventNames.productCardClicked, {
category: 'recommender',
label: courseTitle,
courserun_key: courseRunKey,
page: 'dashboard',
@@ -25,7 +24,6 @@ export const productCardClicked = (courseRunKey, courseTitle, courseType, href)
export const discoveryCardClicked = (courseRunKey, courseTitle, href) => {
createLinkTracker(
createEventTracker(eventNames.discoveryCardClicked, {
category: 'recommender',
label: courseTitle,
courserun_key: courseRunKey,
page: 'dashboard',
@@ -38,7 +36,6 @@ export const discoveryCardClicked = (courseRunKey, courseTitle, href) => {
export const recommendationsHeaderClicked = (courseType, href) => {
createLinkTracker(
createEventTracker(eventNames.recommendationsHeaderClicked, {
category: 'recommender',
page: 'dashboard',
product_line: courseTypeToProductLineMap[courseType],
}),
@@ -46,10 +43,11 @@ export const recommendationsHeaderClicked = (courseType, href) => {
);
};
export const recommendationsViewed = (isControl, courseRunKey) => {
export const recommendationsViewed = (isControl, recommenderGroup, courseRunKey) => {
createEventTracker(eventNames.recommendationsViewed, {
is_control: isControl,
productRecommenderGroup: recommenderGroup,
page: 'dashboard',
course_key: convertCourseRunKeyToCourseKey(courseRunKey),
course_key: courseRunKey ? convertCourseRunKeyToCourseKey(courseRunKey) : '',
});
};

View File

@@ -1,4 +1,5 @@
import { createLinkTracker, createEventTracker } from 'data/services/segment/utils';
import { bootCamp, treatment, control } from './constants';
import {
eventNames,
productCardClicked,
@@ -18,17 +19,17 @@ const courseRunKeyOld = 'MITx/5.0.01/2022T2/';
const label = 'Web Design';
const headerLink = 'https://www.edx.org/search?tab=course?linked_from=recommender';
const courseUrl = 'https://www.edx.org/course/some-course';
const category = 'recommender';
describe('product recommendations trackers', () => {
describe('recommendationsViewed', () => {
describe('with old course run key format', () => {
it('creates an event tracker for when cross product recommendations are present', () => {
recommendationsViewed(false, courseRunKeyOld);
recommendationsViewed(false, treatment, courseRunKeyOld);
expect(createEventTracker).toHaveBeenCalledWith(
eventNames.recommendationsViewed,
{
is_control: false,
productRecommenderGroup: treatment,
page: 'dashboard',
course_key: courseKey,
},
@@ -36,23 +37,38 @@ describe('product recommendations trackers', () => {
});
});
describe('with new course run key format', () => {
it('creates an event tracker for when cross product recommendations are present', () => {
recommendationsViewed(false, courseRunKeyNew);
it('creates an event tracker for when a user is bucketed into the conrol group', () => {
recommendationsViewed(false, control, courseRunKeyNew);
expect(createEventTracker).toHaveBeenCalledWith(
eventNames.recommendationsViewed,
{
is_control: false,
productRecommenderGroup: control,
page: 'dashboard',
course_key: courseKey,
},
);
});
});
describe('with no course run key', () => {
it('creates an event tracker for when a user is bucketed into the conrol group', () => {
recommendationsViewed(false, control, '');
expect(createEventTracker).toHaveBeenCalledWith(
eventNames.recommendationsViewed,
{
is_control: false,
productRecommenderGroup: control,
page: 'dashboard',
course_key: '',
},
);
});
});
});
describe('recommendationsHeaderClicked', () => {
it('creates a link tracker for when a recommendations header is clicked', () => {
const attributes = {
category,
product_line: 'open-courses',
page: 'dashboard',
};
@@ -66,7 +82,6 @@ describe('product recommendations trackers', () => {
describe('discoveryCardClicked', () => {
it('creates a link tracker for when a open course card is clicked', () => {
const attributes = {
category,
label,
courserun_key: courseRunKeyNew,
page: 'dashboard',
@@ -82,7 +97,6 @@ describe('product recommendations trackers', () => {
describe('productCardClicked', () => {
it('creates a link tracker for when a cross product course card is clicked', () => {
const attributes = {
category,
label,
courserun_key: courseRunKeyNew,
page: 'dashboard',
@@ -90,7 +104,7 @@ describe('product recommendations trackers', () => {
};
const args = [eventNames.productCardClicked, attributes];
productCardClicked(courseRunKeyNew, label, 'Boot Camp', courseUrl);
productCardClicked(courseRunKeyNew, label, bootCamp, courseUrl);
expect(createEventTracker).toHaveBeenCalledWith(...args);
expect(createLinkTracker).toHaveBeenCalledWith(createEventTracker(...args), courseUrl);
});

View File

@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
import { executiveEducation, course, bootCamp } from './constants';
export const courseShape = {
uuid: PropTypes.string,
@@ -22,24 +23,24 @@ export const courseShape = {
};
export const courseTypeToProductTypeMap = {
course: 'Course',
'verified-audit': 'Course',
verified: 'Course',
audit: 'Course',
'credit-verified-audit': 'Course',
'spoc-verified-audit': 'Course',
course,
'verified-audit': course,
verified: course,
audit: course,
'credit-verified-audit': course,
'spoc-verified-audit': course,
professional: 'Professional Certificate',
'bootcamp-2u': 'Boot Camp',
'executive-education-2u': 'Executive Education',
'executive-education': 'Executive Education',
'bootcamp-2u': bootCamp,
'executive-education-2u': executiveEducation,
'executive-education': executiveEducation,
masters: "Master's",
'masters-verified-audit': "Master's",
};
export const courseTypeToProductLineMap = {
'Executive Education': 'executive-education',
'Boot Camp': 'boot-camps',
Course: 'open-courses',
[executiveEducation]: 'executive-education',
[bootCamp]: 'boot-camps',
[course]: 'open-courses',
};
export const convertCourseRunKeyToCourseKey = (courseRunKey) => {