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

@@ -19,6 +19,7 @@ import {
import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard';
import ZendeskFab from 'components/ZendeskFab';
import { ExperimentProvider } from 'ExperimentContext';
import track from 'tracking';
@@ -84,7 +85,11 @@ export const App = () => {
<Alert variant="danger">
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
</Alert>
) : (<Dashboard />)}
) : (
<ExperimentProvider>
<Dashboard />
</ExperimentProvider>
)}
</main>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
<ZendeskFab />

View File

@@ -13,6 +13,7 @@ import { RequestKeys } from 'data/constants/requests';
import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import { ExperimentProvider } from 'ExperimentContext';
import { App } from './App';
import messages from './messages';
@@ -21,6 +22,9 @@ jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('containers/Dashboard', () => 'Dashboard');
jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader');
jest.mock('components/ZendeskFab', () => 'ZendeskFab');
jest.mock('ExperimentContext', () => ({
ExperimentProvider: 'ExperimentProvider',
}));
jest.mock('data/redux', () => ({
selectors: 'redux.selectors',
actions: 'redux.actions',
@@ -71,7 +75,7 @@ describe('App router component', () => {
runBasicTests();
it('loads dashboard', () => {
expect(el.find('main')).toMatchObject(shallow(
<main><Dashboard /></main>,
<main><ExperimentProvider><Dashboard /></ExperimentProvider></main>,
));
});
});

64
src/ExperimentContext.jsx Normal file
View File

@@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useWindowSize, breakpoints } from '@edx/paragon';
import { StrictDict } from 'utils';
import api from 'widgets/ProductRecommendations/api';
import * as module from './ExperimentContext';
export const state = StrictDict({
experiment: (val) => React.useState(val), // eslint-disable-line
countryCode: (val) => React.useState(val), // eslint-disable-line
});
export const useCountryCode = (setCountryCode) => {
React.useEffect(() => {
api
.fetchRecommendationsContext()
.then((response) => {
setCountryCode(response.data.countryCode);
})
.catch(() => {
setCountryCode('');
});
/* eslint-disable */
}, []);
};
export const ExperimentContext = React.createContext();
export const ExperimentProvider = ({ children }) => {
const [countryCode, setCountryCode] = module.state.countryCode(null);
const [experiment, setExperiment] = module.state.experiment({
isExperimentActive: false,
inRecommendationsVariant: true,
});
module.useCountryCode(setCountryCode);
const { width } = useWindowSize();
const isMobile = width < breakpoints.small.minWidth;
const contextValue = React.useMemo(
() => ({
experiment,
countryCode,
setExperiment,
setCountryCode,
isMobile,
}),
[experiment, countryCode, setExperiment, setCountryCode, isMobile]
);
return (
<ExperimentContext.Provider value={contextValue}>
{children}
</ExperimentContext.Provider>
);
};
export const useExperimentContext = () => React.useContext(ExperimentContext);
ExperimentProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export default { useCountryCode, useExperimentContext };

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { useWindowSize } from '@edx/paragon';
import api from 'widgets/ProductRecommendations/api';
import { MockUseState } from 'testUtils';
import * as experiment from 'ExperimentContext';
const state = new MockUseState(experiment);
jest.unmock('react');
jest.spyOn(React, 'useEffect').mockImplementation((cb, prereqs) => ({ useEffect: { cb, prereqs } }));
jest.mock('widgets/ProductRecommendations/api', () => ({
fetchRecommendationsContext: jest.fn(),
}));
describe('experiments context', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('useCountryCode', () => {
describe('behaviour', () => {
describe('useEffect call', () => {
let calls;
let cb;
const setCountryCode = jest.fn();
const successfulFetch = { data: { countryCode: 'ZA' } };
beforeEach(() => {
experiment.useCountryCode(setCountryCode);
({ calls } = React.useEffect.mock);
[[cb]] = calls;
});
it('calls useEffect once', () => {
expect(calls.length).toEqual(1);
});
describe('successfull fetch', () => {
it('sets the country code', async () => {
let resolveFn;
api.fetchRecommendationsContext.mockReturnValueOnce(
new Promise((resolve) => {
resolveFn = resolve;
}),
);
cb();
expect(api.fetchRecommendationsContext).toHaveBeenCalled();
expect(setCountryCode).not.toHaveBeenCalled();
resolveFn(successfulFetch);
await waitFor(() => {
expect(setCountryCode).toHaveBeenCalledWith(successfulFetch.data.countryCode);
});
});
});
describe('unsuccessfull fetch', () => {
it('sets the country code to an empty string', async () => {
let rejectFn;
api.fetchRecommendationsContext.mockReturnValueOnce(
new Promise((resolve, reject) => {
rejectFn = reject;
}),
);
cb();
expect(api.fetchRecommendationsContext).toHaveBeenCalled();
expect(setCountryCode).not.toHaveBeenCalled();
rejectFn();
await waitFor(() => {
expect(setCountryCode).toHaveBeenCalledWith('');
});
});
});
});
});
});
describe('ExperimentProvider', () => {
const { ExperimentProvider } = experiment;
const TestComponent = () => {
const {
experiment: exp,
setExperiment,
countryCode,
setCountryCode,
isMobile,
} = experiment.useExperimentContext();
expect(exp.isExperimentActive).toBeFalsy();
expect(exp.inRecommendationsVariant).toBeTruthy();
expect(countryCode).toBeNull();
expect(isMobile).toBe(false);
expect(setExperiment).toBeDefined();
expect(setCountryCode).toBeDefined();
return (
<div />
);
};
it('allows access to child components with the context stateful values', () => {
const countryCodeSpy = jest.spyOn(experiment, 'useCountryCode').mockImplementationOnce(() => {});
useWindowSize.mockImplementationOnce(() => ({ width: 577, height: 943 }));
state.mock();
mount(
<ExperimentProvider>
<TestComponent />
</ExperimentProvider>,
);
expect(countryCodeSpy).toHaveBeenCalledWith(state.setState.countryCode);
state.expectInitializedWith(state.keys.countryCode, null);
state.expectInitializedWith(state.keys.experiment, { isExperimentActive: false, inRecommendationsVariant: true });
});
});
});

View File

@@ -42,7 +42,9 @@ exports[`App router component component no network failure snapshot 1`] = `
<div>
<LearnerDashboardHeader />
<main>
<Dashboard />
<ExperimentProvider>
<Dashboard />
</ExperimentProvider>
</main>
<Footer
logo="fakeLogo.png"

View File

@@ -5,9 +5,9 @@ import RecommendationsPanel from 'widgets/RecommendationsPanel';
import hooks from 'widgets/ProductRecommendations/hooks';
export const WidgetSidebar = ({ setSidebarShowing }) => {
const { shouldShowFooter } = hooks.useShowRecommendationsFooter();
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
if (!shouldShowFooter) {
if (!inRecommendationsVariant && isExperimentActive) {
setSidebarShowing(true);
return (

View File

@@ -18,19 +18,31 @@ describe('WidgetSidebar', () => {
describe('snapshots', () => {
test('default', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.dontShowOrLoad,
mockFooterRecommendationsHook.activeControl,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).toHaveBeenCalledWith(true);
expect(wrapper).toMatchSnapshot();
});
});
test('is hidden if footer is shown', () => {
test('is hidden when the has the default values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.showDontLoad,
mockFooterRecommendationsHook.default,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
test('is hidden when the has the treatment values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.activeTreatment,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});

View File

@@ -5,9 +5,9 @@ import RecommendationsPanel from 'widgets/RecommendationsPanel';
import hooks from 'widgets/ProductRecommendations/hooks';
export const WidgetSidebar = ({ setSidebarShowing }) => {
const { shouldShowFooter } = hooks.useShowRecommendationsFooter();
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
if (!shouldShowFooter) {
if (!inRecommendationsVariant && isExperimentActive) {
setSidebarShowing(true);
return (

View File

@@ -18,19 +18,31 @@ describe('WidgetSidebar', () => {
describe('snapshots', () => {
test('default', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.dontShowOrLoad,
mockFooterRecommendationsHook.activeControl,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).toHaveBeenCalledWith(true);
expect(wrapper).toMatchSnapshot();
});
});
test('is hidden if footer is shown', () => {
test('is hidden when the has the default values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.showDontLoad,
mockFooterRecommendationsHook.default,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
test('is hidden when the has the treatment values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.activeTreatment,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});

View File

@@ -4,9 +4,10 @@ import ProductRecommendations from 'widgets/ProductRecommendations';
import hooks from 'widgets/ProductRecommendations/hooks';
export const WidgetFooter = () => {
const { shouldShowFooter, shouldLoadFooter } = hooks.useShowRecommendationsFooter();
hooks.useActivateRecommendationsExperiment();
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
if (shouldShowFooter && shouldLoadFooter) {
if (inRecommendationsVariant && isExperimentActive) {
return (
<div className="widget-footer">
<ProductRecommendations />

View File

@@ -6,6 +6,7 @@ import WidgetFooter from '.';
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
jest.mock('widgets/ProductRecommendations/hooks', () => ({
useActivateRecommendationsExperiment: jest.fn(),
useShowRecommendationsFooter: jest.fn(),
}));
@@ -13,26 +14,32 @@ describe('WidgetFooter', () => {
describe('snapshots', () => {
test('default', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.showAndLoad,
mockFooterRecommendationsHook.activeTreatment,
);
const wrapper = shallow(<WidgetFooter />);
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
expect(wrapper).toMatchSnapshot();
});
});
test('is hidden when shouldShowFooter is false but shouldLoadFooter is true', () => {
test('is hidden when the experiment has the default values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.loadDontShow,
mockFooterRecommendationsHook.default,
);
const wrapper = shallow(<WidgetFooter />);
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
test('is hidden when shouldLoadFooter is false but shouldShowFooter is true', () => {
test('is hidden when the experiment has the control values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.showDontLoad,
mockFooterRecommendationsHook.activeControl,
);
const wrapper = shallow(<WidgetFooter />);
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
});

View File

@@ -44,6 +44,7 @@ jest.unmock('hooks');
jest.mock('containers/WidgetContainers/LoadedSidebar', () => jest.fn(() => 'loaded-widget-sidebar'));
jest.mock('containers/WidgetContainers/NoCoursesSidebar', () => jest.fn(() => 'no-courses-widget-sidebar'));
jest.mock('containers/WidgetContainers/WidgetFooter', () => 'product-recommendations-footer');
jest.mock('components/NoticesWrapper', () => 'notices-wrapper');
jest.mock('@edx/frontend-platform', () => ({
@@ -60,6 +61,10 @@ jest.mock('@edx/frontend-platform/auth', () => ({
getLoginRedirectUrl: jest.fn(),
}));
jest.mock('ExperimentContext', () => ({
ExperimentProvider: 'div'
}));
jest.mock('@edx/frontend-enterprise-hotjar', () => ({
initializeHotjar: jest.fn(),
}));

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