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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import WidgetFooter from '.';
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget'); jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
jest.mock('widgets/ProductRecommendations/hooks', () => ({ jest.mock('widgets/ProductRecommendations/hooks', () => ({
useActivateRecommendationsExperiment: jest.fn(),
useShowRecommendationsFooter: jest.fn(), useShowRecommendationsFooter: jest.fn(),
})); }));
@@ -13,26 +14,32 @@ describe('WidgetFooter', () => {
describe('snapshots', () => { describe('snapshots', () => {
test('default', () => { test('default', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce( hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.showAndLoad, mockFooterRecommendationsHook.activeTreatment,
); );
const wrapper = shallow(<WidgetFooter />); const wrapper = shallow(<WidgetFooter />);
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
expect(wrapper).toMatchSnapshot(); 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( hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.loadDontShow, mockFooterRecommendationsHook.default,
); );
const wrapper = shallow(<WidgetFooter />); const wrapper = shallow(<WidgetFooter />);
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
expect(wrapper.type()).toBeNull(); 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( hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.showDontLoad, mockFooterRecommendationsHook.activeControl,
); );
const wrapper = shallow(<WidgetFooter />); const wrapper = shallow(<WidgetFooter />);
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
expect(wrapper.type()).toBeNull(); 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/LoadedSidebar', () => jest.fn(() => 'loaded-widget-sidebar'));
jest.mock('containers/WidgetContainers/NoCoursesSidebar', () => jest.fn(() => 'no-courses-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('components/NoticesWrapper', () => 'notices-wrapper');
jest.mock('@edx/frontend-platform', () => ({ jest.mock('@edx/frontend-platform', () => ({
@@ -60,6 +61,10 @@ jest.mock('@edx/frontend-platform/auth', () => ({
getLoginRedirectUrl: jest.fn(), getLoginRedirectUrl: jest.fn(),
})); }));
jest.mock('ExperimentContext', () => ({
ExperimentProvider: 'div'
}));
jest.mock('@edx/frontend-enterprise-hotjar', () => ({ jest.mock('@edx/frontend-enterprise-hotjar', () => ({
initializeHotjar: jest.fn(), 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 crossProductAndAmplitudeRecommendationsUrl = (courseId) => `${urls.getApiUrl()}/learner_recommendations/product_recommendations/${courseId}/`;
export const amplitudeRecommendationsUrl = () => `${urls.getApiUrl()}/learner_recommendations/product_recommendations/`; 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) => ( const fetchCrossProductRecommendations = (courseId) => (
get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl(courseId))) get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl(courseId)))
@@ -12,4 +15,5 @@ const fetchAmplitudeRecommendations = () => get(stringifyUrl(amplitudeRecommenda
export default { export default {
fetchCrossProductRecommendations, fetchCrossProductRecommendations,
fetchAmplitudeRecommendations, fetchAmplitudeRecommendations,
fetchRecommendationsContext,
}; };

View File

@@ -1,6 +1,6 @@
import { get, stringifyUrl } from 'data/services/lms/utils'; 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', () => ({ jest.mock('data/services/lms/utils', () => ({
stringifyUrl: (...args) => ({ stringifyUrl: args }), 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, Truncate,
Hyperlink, Hyperlink,
} from '@edx/paragon'; } 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 = ({ const ProductCard = ({
title, title,
subtitle, subtitle,
headerImage, headerImage,
courseRunKey,
schoolLogo, schoolLogo,
courseType, courseType,
url, url,
}) => ( }) => {
<Card const handleClick = (type, link) => {
className="base-card d-flex text-decoration-none" const crossProductTypes = [executiveEducation, bootCamp];
as={Hyperlink} const userId = getAuthenticatedUser().userId.toString();
destination={url}
isClickable if (crossProductTypes.includes(type)) {
> trackProductCardClicked(userId);
<Card.ImageCap productCardClicked(courseRunKey, title, type, link);
src={headerImage} } else {
srcAlt={`header image for ${title}`} trackCourseCardClicked(userId);
logoSrc={schoolLogo} discoveryCardClicked(courseRunKey, title, link);
logoAlt={`logo for ${subtitle}`} }
/> };
<Card.Header
className="mt-2" const getRedirectUrl = (link) => {
title={( const urlObj = new URL(link);
<Truncate lines={3} ellipsis="…" className="product-card-title font-weight-bold"> const hasQueryStringParameters = urlObj.search !== '';
{title}
</Truncate> 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"> <Card.Section>
{subtitle} <div className="product-badge position-absolute">
</Truncate> <Badge className="bg-light-500 text-dark-500">{courseType}</Badge>
)} </div>
/> </Card.Section>
<Card.Section> </Card>
<div className="product-badge position-absolute"> );
<Badge className="bg-light-500 text-dark-500">{courseType}</Badge> };
</div>
</Card.Section>
</Card>
);
ProductCard.propTypes = { ProductCard.propTypes = {
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
subtitle: PropTypes.string.isRequired, subtitle: PropTypes.string.isRequired,
headerImage: PropTypes.string.isRequired, headerImage: PropTypes.string.isRequired,
courseRunKey: PropTypes.string.isRequired,
schoolLogo: PropTypes.string.isRequired, schoolLogo: PropTypes.string.isRequired,
courseType: PropTypes.string.isRequired, courseType: PropTypes.string.isRequired,
url: PropTypes.string.isRequired, url: PropTypes.string.isRequired,

View File

@@ -1,29 +1,83 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; 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 ProductCard from './ProductCard';
import { courseTypeToProductTypeMap } from '../utils'; import { courseTypeToProductTypeMap } from '../utils';
describe('ProductRecommendations ProductCard', () => { jest.mock('../optimizelyExperiment', () => ({
const course = mockCrossProductCourses[0]; trackProductCardClicked: jest.fn(),
const { trackCourseCardClicked: jest.fn(),
title, }));
owners: [{ name: subtitle }],
image: { src: headerImage },
owners: [{ logoImageUrl: schoolLogo }],
} = course;
const props = { jest.mock('../track', () => ({
title, productCardClicked: jest.fn(),
subtitle, discoveryCardClicked: jest.fn(),
headerImage, }));
schoolLogo,
courseType: courseTypeToProductTypeMap[course.courseType], jest.mock('@edx/frontend-platform/auth', () => ({
url: `${course.marketingUrl}&linked_from=recommender`, 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', () => { 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 classNames from 'classnames';
import { courseShape, courseTypeToProductTypeMap } from '../utils'; import { courseShape, courseTypeToProductTypeMap } from '../utils';
import { course } from '../constants';
import ProductCard from './ProductCard'; import ProductCard from './ProductCard';
import ProductCardHeader from './ProductCardHeader'; import ProductCardHeader from './ProductCardHeader';
@@ -14,18 +15,19 @@ const ProductCardContainer = ({ finalProductList, courseTypes }) => (
<ProductCardHeader courseType={type} /> <ProductCardHeader courseType={type} />
<div <div
className={classNames('d-flex', { className={classNames('d-flex', {
'course-subcontainer': type === 'Course', 'course-subcontainer': type === course,
})} })}
> >
{finalProductList {finalProductList
.filter((course) => courseTypeToProductTypeMap[course.courseType] === type) .filter((courseObj) => courseTypeToProductTypeMap[courseObj.courseType] === type)
.map((item) => ( .map((item) => (
<ProductCard <ProductCard
key={item.title} key={item.title}
url={`${item.marketingUrl}&linked_from=recommender`} url={item.marketingUrl}
title={item.title} title={item.title}
subtitle={item.owners[0].name} subtitle={item.owners[0].name}
headerImage={item.image.src} headerImage={item.image.src}
courseRunKey={item.courseRunKey}
schoolLogo={item.owners[0].logoImageUrl} schoolLogo={item.owners[0].logoImageUrl}
courseType={type} courseType={type}
/> />

View File

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

View File

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

View File

@@ -2,28 +2,53 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import ProductCardHeader from './ProductCardHeader'; 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', () => { describe('ProductRecommendations ProductCardHeader', () => {
const bootCampType = 'Boot Camp'; const coursesType = 'Courses';
const executiveEducationType = 'Executive Education';
const courseType = 'Courses';
it('matches snapshot', () => { it('matches snapshot', () => {
expect(shallow(<ProductCardHeader courseType={executiveEducationType} />)).toMatchSnapshot(); expect(shallow(<ProductCardHeader courseType={executiveEducation} />)).toMatchSnapshot();
}); });
describe('with bootcamp courseType prop', () => { describe('with bootcamp courseType prop', () => {
it('renders a bootcamp header', () => { 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', () => { 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" className="base-card d-flex text-decoration-none"
destination="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender" destination="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
isClickable={true} isClickable={true}
onClick={[Function]}
> >
<Card.ImageCap <Card.ImageCap
logoAlt="logo for Harvard University" logoAlt="logo for Harvard University"

View File

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

View File

@@ -4,7 +4,8 @@ exports[`ProductRecommendations ProductCardHeader matches snapshot 1`] = `
<div> <div>
<Hyperlink <Hyperlink
className="base-card-link text-decoration-none" 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 <div
className="d-flex align-items-center border-bottom" 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 { useState, useEffect } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { RequestStates, RequestKeys } from 'data/constants/requests'; import { RequestStates, RequestKeys } from 'data/constants/requests';
import { StrictDict } from 'utils'; import { StrictDict } from 'utils';
import { reduxHooks } from 'hooks'; import { reduxHooks } from 'hooks';
import { SortKeys } from 'data/constants/app'; import { SortKeys } from 'data/constants/app';
import { useWindowSize, breakpoints } from '@edx/paragon'; 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 api from './api';
import * as module from './hooks'; import * as module from './hooks';
@@ -17,16 +24,6 @@ export const useIsMobile = () => {
return width < breakpoints.small.minWidth; 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 = () => { export const useMostRecentCourseRunKey = () => {
const mostRecentCourseRunKey = reduxHooks.useCurrentCourseList({ const mostRecentCourseRunKey = reduxHooks.useCurrentCourseList({
sortBy: SortKeys.enrolled, sortBy: SortKeys.enrolled,
@@ -37,6 +34,61 @@ export const useMostRecentCourseRunKey = () => {
return mostRecentCourseRunKey; 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) => { export const useFetchRecommendations = (setRequestState, setData) => {
const courseRunKey = module.useMostRecentCourseRunKey(); 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 = () => { export const useProductRecommendationsData = () => {
const [requestState, setRequestState] = module.state.requestState(RequestStates.pending); const [requestState, setRequestState] = module.state.requestState(RequestStates.pending);
const [data, setData] = module.state.data({}); const [data, setData] = module.state.data({});
module.useFetchRecommendations(setRequestState, setData); module.useFetchRecommendations(setRequestState, setData);
module.useSendViewedEvents(requestState, data);
return { return {
productRecommendations: data, 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 { RequestStates } from 'data/constants/requests';
import { reduxHooks } from 'hooks'; import { reduxHooks } from 'hooks';
import { useWindowSize } from '@edx/paragon'; 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 { wait } from './utils';
import { mockCrossProductResponse, mockAmplitudeResponse } from './testData';
import api from './api'; import api from './api';
import * as hooks from './hooks'; import * as hooks from './hooks';
@@ -13,16 +19,34 @@ import * as hooks from './hooks';
jest.mock('./api', () => ({ jest.mock('./api', () => ({
fetchCrossProductRecommendations: jest.fn(), fetchCrossProductRecommendations: jest.fn(),
fetchAmplitudeRecommendations: 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', () => ({ jest.mock('hooks', () => ({
reduxHooks: { reduxHooks: {
useCurrentCourseList: jest.fn(), useCurrentCourseList: jest.fn(),
useHasAvailableDashboards: jest.fn(), useEnterpriseDashboardData: jest.fn(),
useRequestIsCompleted: 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 state = new MockUseState(hooks);
const mostRecentCourseRunKey = 'course ID 1'; const mostRecentCourseRunKey = 'course ID 1';
@@ -53,6 +77,7 @@ let output;
describe('ProductRecommendations hooks', () => { describe('ProductRecommendations hooks', () => {
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
getAuthenticatedUser.mockImplementation(() => ({ userId: '1' }));
}); });
describe('state fields', () => { describe('state fields', () => {
@@ -86,14 +111,175 @@ describe('ProductRecommendations hooks', () => {
}); });
describe('useShowRecommendationsFooter', () => { describe('useShowRecommendationsFooter', () => {
// TODO: Update when hardcoded value is removed it('returns the experiment object, stating if the experiment has activated and the variant', () => {
it('returns whether the footer widget should show and should load', () => { useExperimentContext
reduxHooks.useHasAvailableDashboards.mockReturnValueOnce(false); .mockImplementationOnce(() => ({ experiment: { inRecommendationsVariant: true, isExperimentActive: false } }));
reduxHooks.useRequestIsCompleted.mockReturnValueOnce(true);
const { shouldShowFooter, shouldLoadFooter } = hooks.useShowRecommendationsFooter();
expect(shouldShowFooter).toBeFalsy(); const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
expect(shouldLoadFooter).toBeTruthy();
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', () => { describe('useProductRecommendationsData', () => {
let fetchSpy; let fetchRecommendationsSpy;
let sendViewedEventsSpy;
beforeEach(() => { beforeEach(() => {
state.mock(); state.mock();
fetchSpy = jest.spyOn(hooks, 'useFetchRecommendations').mockImplementationOnce(() => {}); fetchRecommendationsSpy = jest.spyOn(hooks, 'useFetchRecommendations').mockImplementationOnce(() => {});
sendViewedEventsSpy = jest.spyOn(hooks, 'useSendViewedEvents').mockImplementationOnce(() => {});
output = hooks.useProductRecommendationsData(); output = hooks.useProductRecommendationsData();
}); });
it('calls useFetchRecommendations with setRequestState and setData', () => { 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', () => { it('initializes requestState as RequestStates.pending', () => {
state.expectInitializedWith(state.keys.requestState, RequestStates.pending); state.expectInitializedWith(state.keys.requestState, RequestStates.pending);

View File

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

View File

@@ -24,11 +24,25 @@ const userAttributes = {
describe('Optimizely events', () => { describe('Optimizely events', () => {
describe('activateProductRecommendationsExperiment', () => { 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); 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( expect(optimizelyClient.activate).toHaveBeenCalledWith(
PRODUCT_RECOMMENDATIONS_EXP_KEY, PRODUCT_RECOMMENDATIONS_EXP_KEY,
userId, userId,

View File

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

View File

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

View File

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