feat: Implemented product recommendations experiment (#174)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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())),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
7
src/widgets/ProductRecommendations/constants.js
Normal file
7
src/widgets/ProductRecommendations/constants.js
Normal 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';
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {}) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) : '',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user