feat: Implemented product recommendations experiment (#174)
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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
64
src/ExperimentContext.jsx
Normal 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 };
|
||||
123
src/ExperimentContext.test.jsx
Normal file
123
src/ExperimentContext.test.jsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,9 @@ import urls from 'data/services/lms/urls';
|
||||
|
||||
export const crossProductAndAmplitudeRecommendationsUrl = (courseId) => `${urls.getApiUrl()}/learner_recommendations/product_recommendations/${courseId}/`;
|
||||
export const amplitudeRecommendationsUrl = () => `${urls.getApiUrl()}/learner_recommendations/product_recommendations/`;
|
||||
export const recommendationsContextUrl = () => `${urls.getApiUrl()}/learner_recommendations/recommendations_context/`;
|
||||
|
||||
const fetchRecommendationsContext = () => get(stringifyUrl(recommendationsContextUrl()));
|
||||
|
||||
const fetchCrossProductRecommendations = (courseId) => (
|
||||
get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl(courseId)))
|
||||
@@ -12,4 +15,5 @@ const fetchAmplitudeRecommendations = () => get(stringifyUrl(amplitudeRecommenda
|
||||
export default {
|
||||
fetchCrossProductRecommendations,
|
||||
fetchAmplitudeRecommendations,
|
||||
fetchRecommendationsContext,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { get, stringifyUrl } from 'data/services/lms/utils';
|
||||
|
||||
import api, { crossProductAndAmplitudeRecommendationsUrl, amplitudeRecommendationsUrl } from './api';
|
||||
import api, { crossProductAndAmplitudeRecommendationsUrl, amplitudeRecommendationsUrl, recommendationsContextUrl } from './api';
|
||||
|
||||
jest.mock('data/services/lms/utils', () => ({
|
||||
stringifyUrl: (...args) => ({ stringifyUrl: args }),
|
||||
@@ -23,4 +23,12 @@ describe('productRecommendationCourses api', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchRecommendationsContext', () => {
|
||||
it('calls get with the correct recommendation courses URL', () => {
|
||||
expect(api.fetchRecommendationsContext()).toEqual(
|
||||
get(stringifyUrl(recommendationsContextUrl())),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,52 +6,90 @@ import {
|
||||
Truncate,
|
||||
Hyperlink,
|
||||
} from '@edx/paragon';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { trackProductCardClicked, trackCourseCardClicked } from '../optimizelyExperiment';
|
||||
import { productCardClicked, discoveryCardClicked } from '../track';
|
||||
import { bootCamp, executiveEducation } from '../constants';
|
||||
|
||||
const ProductCard = ({
|
||||
title,
|
||||
subtitle,
|
||||
headerImage,
|
||||
courseRunKey,
|
||||
schoolLogo,
|
||||
courseType,
|
||||
url,
|
||||
}) => (
|
||||
<Card
|
||||
className="base-card d-flex text-decoration-none"
|
||||
as={Hyperlink}
|
||||
destination={url}
|
||||
isClickable
|
||||
>
|
||||
<Card.ImageCap
|
||||
src={headerImage}
|
||||
srcAlt={`header image for ${title}`}
|
||||
logoSrc={schoolLogo}
|
||||
logoAlt={`logo for ${subtitle}`}
|
||||
/>
|
||||
<Card.Header
|
||||
className="mt-2"
|
||||
title={(
|
||||
<Truncate lines={3} ellipsis="…" className="product-card-title font-weight-bold">
|
||||
{title}
|
||||
</Truncate>
|
||||
}) => {
|
||||
const handleClick = (type, link) => {
|
||||
const crossProductTypes = [executiveEducation, bootCamp];
|
||||
const userId = getAuthenticatedUser().userId.toString();
|
||||
|
||||
if (crossProductTypes.includes(type)) {
|
||||
trackProductCardClicked(userId);
|
||||
productCardClicked(courseRunKey, title, type, link);
|
||||
} else {
|
||||
trackCourseCardClicked(userId);
|
||||
discoveryCardClicked(courseRunKey, title, link);
|
||||
}
|
||||
};
|
||||
|
||||
const getRedirectUrl = (link) => {
|
||||
const urlObj = new URL(link);
|
||||
const hasQueryStringParameters = urlObj.search !== '';
|
||||
|
||||
if (hasQueryStringParameters) {
|
||||
return `${link}&linked_from=recommender`;
|
||||
}
|
||||
|
||||
return `${link}?linked_from=recommender`;
|
||||
};
|
||||
|
||||
const redirectUrl = getRedirectUrl(url);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="base-card d-flex text-decoration-none"
|
||||
as={Hyperlink}
|
||||
destination={redirectUrl}
|
||||
onClick={() => {
|
||||
handleClick(courseType, redirectUrl);
|
||||
}}
|
||||
isClickable
|
||||
>
|
||||
<Card.ImageCap
|
||||
src={headerImage}
|
||||
srcAlt={`header image for ${title}`}
|
||||
logoSrc={schoolLogo}
|
||||
logoAlt={`logo for ${subtitle}`}
|
||||
/>
|
||||
<Card.Header
|
||||
className="mt-2"
|
||||
title={(
|
||||
<Truncate lines={3} ellipsis="…" className="product-card-title font-weight-bold">
|
||||
{title}
|
||||
</Truncate>
|
||||
)}
|
||||
subtitle={(
|
||||
<Truncate lines={1} className="product-card-subtitle font-weight-normal">
|
||||
{subtitle}
|
||||
</Truncate>
|
||||
)}
|
||||
subtitle={(
|
||||
<Truncate lines={1} className="product-card-subtitle font-weight-normal">
|
||||
{subtitle}
|
||||
</Truncate>
|
||||
)}
|
||||
/>
|
||||
<Card.Section>
|
||||
<div className="product-badge position-absolute">
|
||||
<Badge className="bg-light-500 text-dark-500">{courseType}</Badge>
|
||||
</div>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
/>
|
||||
<Card.Section>
|
||||
<div className="product-badge position-absolute">
|
||||
<Badge className="bg-light-500 text-dark-500">{courseType}</Badge>
|
||||
</div>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCard.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
subtitle: PropTypes.string.isRequired,
|
||||
headerImage: PropTypes.string.isRequired,
|
||||
courseRunKey: PropTypes.string.isRequired,
|
||||
schoolLogo: PropTypes.string.isRequired,
|
||||
courseType: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
|
||||
@@ -1,29 +1,83 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { mockCrossProductCourses } from '../testData';
|
||||
import { mockCrossProductCourses, mockOpenCourses, mockFallbackOpenCourse } from '../testData';
|
||||
import { trackProductCardClicked, trackCourseCardClicked } from '../optimizelyExperiment';
|
||||
import { productCardClicked, discoveryCardClicked } from '../track';
|
||||
import ProductCard from './ProductCard';
|
||||
import { courseTypeToProductTypeMap } from '../utils';
|
||||
|
||||
describe('ProductRecommendations ProductCard', () => {
|
||||
const course = mockCrossProductCourses[0];
|
||||
const {
|
||||
title,
|
||||
owners: [{ name: subtitle }],
|
||||
image: { src: headerImage },
|
||||
owners: [{ logoImageUrl: schoolLogo }],
|
||||
} = course;
|
||||
jest.mock('../optimizelyExperiment', () => ({
|
||||
trackProductCardClicked: jest.fn(),
|
||||
trackCourseCardClicked: jest.fn(),
|
||||
}));
|
||||
|
||||
const props = {
|
||||
title,
|
||||
subtitle,
|
||||
headerImage,
|
||||
schoolLogo,
|
||||
courseType: courseTypeToProductTypeMap[course.courseType],
|
||||
url: `${course.marketingUrl}&linked_from=recommender`,
|
||||
jest.mock('../track', () => ({
|
||||
productCardClicked: jest.fn(),
|
||||
discoveryCardClicked: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(() => ({ userId: '1' })),
|
||||
}));
|
||||
|
||||
describe('ProductRecommendations ProductCard', () => {
|
||||
const getProps = (course) => {
|
||||
const {
|
||||
title,
|
||||
owners: [{ name: subtitle }],
|
||||
image: { src: headerImage },
|
||||
owners: [{ logoImageUrl: schoolLogo }],
|
||||
} = course;
|
||||
|
||||
return {
|
||||
title,
|
||||
subtitle,
|
||||
headerImage,
|
||||
schoolLogo,
|
||||
courseType: courseTypeToProductTypeMap[course.courseType],
|
||||
courseRunKey: course.courseRunKey,
|
||||
url: course.marketingUrl,
|
||||
};
|
||||
};
|
||||
|
||||
const crossProductProps = getProps(mockCrossProductCourses[0]);
|
||||
const openCourseProps = getProps(mockOpenCourses[0]);
|
||||
const fallbackOpenCourseProps = getProps(mockFallbackOpenCourse[0]);
|
||||
|
||||
it('matches snapshot', () => {
|
||||
expect(shallow(<ProductCard {...props} />)).toMatchSnapshot();
|
||||
expect(shallow(<ProductCard {...crossProductProps} />)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('has the query string parameter attached to a fallback recommendations url', () => {
|
||||
const wrapper = shallow(<ProductCard {...fallbackOpenCourseProps} />);
|
||||
const cardUrl = wrapper.find('Card').props().destination;
|
||||
|
||||
expect(cardUrl).toEqual('https://www.edx.org/course/some-course?linked_from=recommender');
|
||||
});
|
||||
|
||||
it('send outs experiment events related to open courses when clicked', () => {
|
||||
const wrapper = shallow(<ProductCard {...openCourseProps} />);
|
||||
const { courseRunKey, title, url } = openCourseProps;
|
||||
|
||||
wrapper.simulate('click');
|
||||
|
||||
expect(trackCourseCardClicked).toHaveBeenCalledWith('1');
|
||||
expect(discoveryCardClicked).toHaveBeenCalledWith(courseRunKey, title, `${url}&linked_from=recommender`);
|
||||
});
|
||||
|
||||
it('send outs experiment events related to cross product courses when clicked', () => {
|
||||
const wrapper = shallow(<ProductCard {...crossProductProps} />);
|
||||
const {
|
||||
courseRunKey,
|
||||
title,
|
||||
courseType,
|
||||
url,
|
||||
} = crossProductProps;
|
||||
|
||||
wrapper.simulate('click');
|
||||
|
||||
expect(trackProductCardClicked).toHaveBeenCalledWith('1');
|
||||
expect(productCardClicked).toHaveBeenCalledWith(courseRunKey, title, courseType, `${url}&linked_from=recommender`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { courseShape, courseTypeToProductTypeMap } from '../utils';
|
||||
import { course } from '../constants';
|
||||
import ProductCard from './ProductCard';
|
||||
import ProductCardHeader from './ProductCardHeader';
|
||||
|
||||
@@ -14,18 +15,19 @@ const ProductCardContainer = ({ finalProductList, courseTypes }) => (
|
||||
<ProductCardHeader courseType={type} />
|
||||
<div
|
||||
className={classNames('d-flex', {
|
||||
'course-subcontainer': type === 'Course',
|
||||
'course-subcontainer': type === course,
|
||||
})}
|
||||
>
|
||||
{finalProductList
|
||||
.filter((course) => courseTypeToProductTypeMap[course.courseType] === type)
|
||||
.filter((courseObj) => courseTypeToProductTypeMap[courseObj.courseType] === type)
|
||||
.map((item) => (
|
||||
<ProductCard
|
||||
key={item.title}
|
||||
url={`${item.marketingUrl}&linked_from=recommender`}
|
||||
url={item.marketingUrl}
|
||||
title={item.title}
|
||||
subtitle={item.owners[0].name}
|
||||
headerImage={item.image.src}
|
||||
courseRunKey={item.courseRunKey}
|
||||
schoolLogo={item.owners[0].logoImageUrl}
|
||||
courseType={type}
|
||||
/>
|
||||
|
||||
@@ -3,11 +3,12 @@ import { shallow } from 'enzyme';
|
||||
|
||||
import { mockCrossProductCourses, mockOpenCourses } from '../testData';
|
||||
import ProductCardContainer from './ProductCardContainer';
|
||||
import { executiveEducation, bootCamp, course } from '../constants';
|
||||
|
||||
describe('ProductRecommendations ProductCardContainer', () => {
|
||||
const props = {
|
||||
finalProductList: [...mockCrossProductCourses, ...mockOpenCourses],
|
||||
courseTypes: ['Executive Education', 'Boot Camp', 'Course'],
|
||||
courseTypes: [executiveEducation, bootCamp, course],
|
||||
};
|
||||
|
||||
it('matches snapshot', () => {
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { Icon, Hyperlink } from '@edx/paragon';
|
||||
import { ChevronRight } from '@edx/paragon/icons';
|
||||
import { trackProductHeaderClicked } from '../optimizelyExperiment';
|
||||
import { recommendationsHeaderClicked } from '../track';
|
||||
import { executiveEducation, bootCamp } from '../constants';
|
||||
import messages from '../messages';
|
||||
|
||||
const ProductCardHeader = ({ courseType }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const getProductTypeDetail = (type) => {
|
||||
switch (type) {
|
||||
case 'Executive Education':
|
||||
case executiveEducation:
|
||||
return {
|
||||
heading: messages.executiveEducationHeading,
|
||||
description: messages.executiveEducationDescription,
|
||||
url: '/executive-education?linked_from=recommender',
|
||||
};
|
||||
case 'Boot Camp':
|
||||
case bootCamp:
|
||||
return {
|
||||
heading: messages.bootcampHeading,
|
||||
description: messages.bootcampDescription,
|
||||
@@ -27,19 +29,31 @@ const ProductCardHeader = ({ courseType }) => {
|
||||
return {
|
||||
heading: messages.courseHeading,
|
||||
description: messages.courseDescription,
|
||||
url: '/search?tab=course?linked_from=recommender',
|
||||
url: '/search?tab=course',
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (type, url) => {
|
||||
const userId = getAuthenticatedUser().userId.toString();
|
||||
|
||||
trackProductHeaderClicked(userId);
|
||||
recommendationsHeaderClicked(type, url);
|
||||
};
|
||||
|
||||
const { formatMessage } = useIntl();
|
||||
const productTypeDetail = getProductTypeDetail(courseType);
|
||||
const headerUrl = `${process.env.MARKETING_SITE_BASE_URL}${productTypeDetail.url}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Hyperlink
|
||||
destination={`https://www.edx.org${productTypeDetail.url}`}
|
||||
destination={headerUrl}
|
||||
className="base-card-link text-decoration-none"
|
||||
onClick={() => {
|
||||
handleClick(courseType, headerUrl);
|
||||
}}
|
||||
>
|
||||
<div className="d-flex align-items-center border-bottom">
|
||||
<h3 className="h3 mb-2 text-left">
|
||||
|
||||
@@ -2,28 +2,53 @@ import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import ProductCardHeader from './ProductCardHeader';
|
||||
import { executiveEducation, bootCamp } from '../constants';
|
||||
import { trackProductHeaderClicked } from '../optimizelyExperiment';
|
||||
import { recommendationsHeaderClicked } from '../track';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(() => ({ userId: '1' })),
|
||||
}));
|
||||
|
||||
jest.mock('../optimizelyExperiment', () => ({
|
||||
trackProductHeaderClicked: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../track', () => ({
|
||||
recommendationsHeaderClicked: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ProductRecommendations ProductCardHeader', () => {
|
||||
const bootCampType = 'Boot Camp';
|
||||
const executiveEducationType = 'Executive Education';
|
||||
const courseType = 'Courses';
|
||||
const coursesType = 'Courses';
|
||||
|
||||
it('matches snapshot', () => {
|
||||
expect(shallow(<ProductCardHeader courseType={executiveEducationType} />)).toMatchSnapshot();
|
||||
expect(shallow(<ProductCardHeader courseType={executiveEducation} />)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('with bootcamp courseType prop', () => {
|
||||
it('renders a bootcamp header', () => {
|
||||
const wrapper = shallow(<ProductCardHeader courseType={bootCampType} />);
|
||||
const wrapper = shallow(<ProductCardHeader courseType={bootCamp} />);
|
||||
|
||||
expect(wrapper.find('h3').text()).toEqual(bootCampType);
|
||||
expect(wrapper.find('h3').text()).toEqual(bootCamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with course courseType prop', () => {
|
||||
describe('with courses courseType prop', () => {
|
||||
it('renders a courses header', () => {
|
||||
const wrapper = shallow(<ProductCardHeader courseType={courseType} />);
|
||||
const wrapper = shallow(<ProductCardHeader courseType={coursesType} />);
|
||||
|
||||
expect(wrapper.find('h3').text()).toEqual(courseType);
|
||||
expect(wrapper.find('h3').text()).toEqual(coursesType);
|
||||
});
|
||||
});
|
||||
|
||||
it('send outs experiment events when clicked', () => {
|
||||
const wrapper = shallow(<ProductCardHeader courseType={executiveEducation} />);
|
||||
const hyperLink = wrapper.find('Hyperlink');
|
||||
const execEdLink = 'http://localhost:18000/executive-education?linked_from=recommender';
|
||||
|
||||
hyperLink.simulate('click');
|
||||
|
||||
expect(trackProductHeaderClicked).toHaveBeenCalledWith('1');
|
||||
expect(recommendationsHeaderClicked).toHaveBeenCalledWith(executiveEducation, execEdLink);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ exports[`ProductRecommendations ProductCard matches snapshot 1`] = `
|
||||
className="base-card d-flex text-decoration-none"
|
||||
destination="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
|
||||
isClickable={true}
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Card.ImageCap
|
||||
logoAlt="logo for Harvard University"
|
||||
|
||||
@@ -14,13 +14,14 @@ exports[`ProductRecommendations ProductCardContainer matches snapshot 1`] = `
|
||||
className="d-flex"
|
||||
>
|
||||
<ProductCard
|
||||
courseRunKey="course-v1:Test+Course+2022T2"
|
||||
courseType="Executive Education"
|
||||
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||
key="Introduction to Computer Science"
|
||||
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||
subtitle="Harvard University"
|
||||
title="Introduction to Computer Science"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,13 +35,14 @@ exports[`ProductRecommendations ProductCardContainer matches snapshot 1`] = `
|
||||
className="d-flex"
|
||||
>
|
||||
<ProductCard
|
||||
courseRunKey="course-v1:Test+Course+2022T2"
|
||||
courseType="Boot Camp"
|
||||
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||
key="Introduction to Computer Science"
|
||||
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||
subtitle="Harvard University"
|
||||
title="Introduction to Computer Science"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,40 +56,44 @@ exports[`ProductRecommendations ProductCardContainer matches snapshot 1`] = `
|
||||
className="d-flex course-subcontainer"
|
||||
>
|
||||
<ProductCard
|
||||
courseRunKey="course-v1:Test+Course+2022T2"
|
||||
courseType="Course"
|
||||
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||
key="Introduction to Computer Science"
|
||||
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||
subtitle="Harvard University"
|
||||
title="Introduction to Computer Science"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source"
|
||||
/>
|
||||
<ProductCard
|
||||
courseRunKey="course-v1:Test+Course+2022T2"
|
||||
courseType="Course"
|
||||
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||
key="Introduction to Computer Science"
|
||||
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||
subtitle="Harvard University"
|
||||
title="Introduction to Computer Science"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source"
|
||||
/>
|
||||
<ProductCard
|
||||
courseRunKey="course-v1:Test+Course+2022T2"
|
||||
courseType="Course"
|
||||
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||
key="Introduction to Computer Science"
|
||||
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||
subtitle="Harvard University"
|
||||
title="Introduction to Computer Science"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source"
|
||||
/>
|
||||
<ProductCard
|
||||
courseRunKey="course-v1:Test+Course+2022T2"
|
||||
courseType="Course"
|
||||
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||
key="Introduction to Computer Science"
|
||||
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||
subtitle="Harvard University"
|
||||
title="Introduction to Computer Science"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source&linked_from=recommender"
|
||||
url="https://www.edx.org/course/some-course?utm_source=source"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@ exports[`ProductRecommendations ProductCardHeader matches snapshot 1`] = `
|
||||
<div>
|
||||
<Hyperlink
|
||||
className="base-card-link text-decoration-none"
|
||||
destination="https://www.edx.org/executive-education?linked_from=recommender"
|
||||
destination="http://localhost:18000/executive-education?linked_from=recommender"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div
|
||||
className="d-flex align-items-center border-bottom"
|
||||
|
||||
7
src/widgets/ProductRecommendations/constants.js
Normal file
7
src/widgets/ProductRecommendations/constants.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const bootCamp = 'Boot Camp';
|
||||
export const executiveEducation = 'Executive Education';
|
||||
export const course = 'Course';
|
||||
|
||||
export const control = 'control';
|
||||
export const treatment = 'treatment';
|
||||
export const noExperiment = 'no experiment';
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { RequestStates, RequestKeys } from 'data/constants/requests';
|
||||
import { StrictDict } from 'utils';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { SortKeys } from 'data/constants/app';
|
||||
import { useWindowSize, breakpoints } from '@edx/paragon';
|
||||
import { useExperimentContext } from 'ExperimentContext';
|
||||
import { control, treatment, noExperiment } from './constants';
|
||||
import { activateProductRecommendationsExperiment, trackProductRecommendationsViewed } from './optimizelyExperiment';
|
||||
import { recommendationsViewed } from './track';
|
||||
|
||||
import api from './api';
|
||||
import * as module from './hooks';
|
||||
|
||||
@@ -17,16 +24,6 @@ export const useIsMobile = () => {
|
||||
return width < breakpoints.small.minWidth;
|
||||
};
|
||||
|
||||
export const useShowRecommendationsFooter = () => {
|
||||
const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards();
|
||||
const hasRequestCompleted = reduxHooks.useRequestIsCompleted(RequestKeys.initialize);
|
||||
|
||||
return {
|
||||
shouldShowFooter: false,
|
||||
shouldLoadFooter: hasRequestCompleted && !hasAvailableDashboards,
|
||||
};
|
||||
};
|
||||
|
||||
export const useMostRecentCourseRunKey = () => {
|
||||
const mostRecentCourseRunKey = reduxHooks.useCurrentCourseList({
|
||||
sortBy: SortKeys.enrolled,
|
||||
@@ -37,6 +34,61 @@ export const useMostRecentCourseRunKey = () => {
|
||||
return mostRecentCourseRunKey;
|
||||
};
|
||||
|
||||
export const useActivateRecommendationsExperiment = () => {
|
||||
const enterpriseDashboardData = reduxHooks.useEnterpriseDashboardData();
|
||||
const hasRequestCompleted = reduxHooks.useRequestIsCompleted(RequestKeys.initialize);
|
||||
const mostRecentCourseRunKey = module.useMostRecentCourseRunKey();
|
||||
const userId = getAuthenticatedUser().userId.toString();
|
||||
|
||||
const {
|
||||
experiment: { isExperimentActive },
|
||||
setExperiment,
|
||||
isMobile,
|
||||
countryCode,
|
||||
} = useExperimentContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExperimentActive && countryCode !== null) {
|
||||
const activateExperiment = () => {
|
||||
const userAttributes = {
|
||||
is_mobile_user: isMobile,
|
||||
is_enterprise_user: !!enterpriseDashboardData,
|
||||
location: countryCode ? countryCode.toLowerCase() : '',
|
||||
};
|
||||
const experiment = activateProductRecommendationsExperiment(userId, userAttributes);
|
||||
|
||||
setExperiment((prev) => ({
|
||||
...prev,
|
||||
isExperimentActive: true,
|
||||
inRecommendationsVariant: experiment.inExperimentVariant,
|
||||
}));
|
||||
|
||||
return experiment;
|
||||
};
|
||||
|
||||
const sendViewedEvent = () => {
|
||||
trackProductRecommendationsViewed(userId);
|
||||
recommendationsViewed(true, control, mostRecentCourseRunKey);
|
||||
};
|
||||
|
||||
if (hasRequestCompleted) {
|
||||
const { experimentActivated, inExperimentVariant } = activateExperiment();
|
||||
|
||||
if (experimentActivated && !inExperimentVariant) {
|
||||
sendViewedEvent();
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-disable */
|
||||
}, [isExperimentActive, countryCode])
|
||||
};
|
||||
|
||||
export const useShowRecommendationsFooter = () => {
|
||||
const { experiment } = useExperimentContext();
|
||||
|
||||
return experiment;
|
||||
};
|
||||
|
||||
export const useFetchRecommendations = (setRequestState, setData) => {
|
||||
const courseRunKey = module.useMostRecentCourseRunKey();
|
||||
|
||||
@@ -74,10 +126,29 @@ export const useFetchRecommendations = (setRequestState, setData) => {
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const useSendViewedEvents = (requestState, data) => {
|
||||
const mostRecentCourseRunKey = module.useMostRecentCourseRunKey();
|
||||
const userId = getAuthenticatedUser().userId.toString();
|
||||
|
||||
useEffect(() => {
|
||||
if (requestState === RequestStates.completed) {
|
||||
if (data.crossProductCourses?.length === 2) {
|
||||
trackProductRecommendationsViewed(userId);
|
||||
recommendationsViewed(false, treatment, mostRecentCourseRunKey);
|
||||
} else {
|
||||
trackProductRecommendationsViewed(userId);
|
||||
recommendationsViewed(true, noExperiment, mostRecentCourseRunKey);
|
||||
}
|
||||
}
|
||||
}, [data, requestState])
|
||||
}
|
||||
|
||||
export const useProductRecommendationsData = () => {
|
||||
const [requestState, setRequestState] = module.state.requestState(RequestStates.pending);
|
||||
const [data, setData] = module.state.data({});
|
||||
|
||||
module.useFetchRecommendations(setRequestState, setData);
|
||||
module.useSendViewedEvents(requestState, data);
|
||||
|
||||
return {
|
||||
productRecommendations: data,
|
||||
@@ -87,4 +158,4 @@ export const useProductRecommendationsData = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export default { useProductRecommendationsData, useShowRecommendationsFooter, useIsMobile };
|
||||
export default { useProductRecommendationsData, useShowRecommendationsFooter, useIsMobile, useActivateRecommendationsExperiment };
|
||||
|
||||
@@ -5,7 +5,13 @@ import { MockUseState } from 'testUtils';
|
||||
import { RequestStates } from 'data/constants/requests';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useExperimentContext } from 'ExperimentContext';
|
||||
import { recommendationsViewed } from './track';
|
||||
import { activateProductRecommendationsExperiment, trackProductRecommendationsViewed } from './optimizelyExperiment';
|
||||
import { control, treatment, noExperiment } from './constants';
|
||||
import { wait } from './utils';
|
||||
import { mockCrossProductResponse, mockAmplitudeResponse } from './testData';
|
||||
|
||||
import api from './api';
|
||||
import * as hooks from './hooks';
|
||||
@@ -13,16 +19,34 @@ import * as hooks from './hooks';
|
||||
jest.mock('./api', () => ({
|
||||
fetchCrossProductRecommendations: jest.fn(),
|
||||
fetchAmplitudeRecommendations: jest.fn(),
|
||||
fetchRecommendationsContext: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('ExperimentContext', () => ({
|
||||
useExperimentContext: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCurrentCourseList: jest.fn(),
|
||||
useHasAvailableDashboards: jest.fn(),
|
||||
useEnterpriseDashboardData: jest.fn(),
|
||||
useRequestIsCompleted: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./track', () => ({
|
||||
recommendationsViewed: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./optimizelyExperiment', () => ({
|
||||
trackProductRecommendationsViewed: jest.fn(),
|
||||
activateProductRecommendationsExperiment: jest.fn(),
|
||||
}));
|
||||
|
||||
const state = new MockUseState(hooks);
|
||||
const mostRecentCourseRunKey = 'course ID 1';
|
||||
|
||||
@@ -53,6 +77,7 @@ let output;
|
||||
describe('ProductRecommendations hooks', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
getAuthenticatedUser.mockImplementation(() => ({ userId: '1' }));
|
||||
});
|
||||
|
||||
describe('state fields', () => {
|
||||
@@ -86,14 +111,175 @@ describe('ProductRecommendations hooks', () => {
|
||||
});
|
||||
|
||||
describe('useShowRecommendationsFooter', () => {
|
||||
// TODO: Update when hardcoded value is removed
|
||||
it('returns whether the footer widget should show and should load', () => {
|
||||
reduxHooks.useHasAvailableDashboards.mockReturnValueOnce(false);
|
||||
reduxHooks.useRequestIsCompleted.mockReturnValueOnce(true);
|
||||
const { shouldShowFooter, shouldLoadFooter } = hooks.useShowRecommendationsFooter();
|
||||
it('returns the experiment object, stating if the experiment has activated and the variant', () => {
|
||||
useExperimentContext
|
||||
.mockImplementationOnce(() => ({ experiment: { inRecommendationsVariant: true, isExperimentActive: false } }));
|
||||
|
||||
expect(shouldShowFooter).toBeFalsy();
|
||||
expect(shouldLoadFooter).toBeTruthy();
|
||||
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
|
||||
|
||||
expect(useExperimentContext).toHaveBeenCalled();
|
||||
expect(inRecommendationsVariant).toBeTruthy();
|
||||
expect(isExperimentActive).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useActivateRecommendationsExperiment', () => {
|
||||
describe('behavior', () => {
|
||||
describe('useEffect call', () => {
|
||||
let cb;
|
||||
let calls;
|
||||
let prereqs;
|
||||
const setExperiment = jest.fn();
|
||||
const setCountryCode = jest.fn();
|
||||
const userAttributes = { is_enterprise_user: false, is_mobile_user: false, location: 'za' };
|
||||
|
||||
const optimizelyExperimentMock = ({
|
||||
experimentActivated = false,
|
||||
inExperimentVariant = false,
|
||||
}) => ({
|
||||
experimentActivated,
|
||||
inExperimentVariant,
|
||||
});
|
||||
|
||||
const experimentContextMock = ({
|
||||
isExperimentActive = false,
|
||||
inRecommendationsVariant = true,
|
||||
countryCode = 'ZA',
|
||||
isMobile = false,
|
||||
}) => ({
|
||||
experiment: { isExperimentActive, inRecommendationsVariant },
|
||||
countryCode,
|
||||
isMobile,
|
||||
setExperiment,
|
||||
setCountryCode,
|
||||
});
|
||||
|
||||
const setUp = (
|
||||
isCompleted,
|
||||
experimentContext = experimentContextMock({}),
|
||||
optimizelyExperiment = optimizelyExperimentMock({}),
|
||||
) => {
|
||||
reduxHooks.useCurrentCourseList.mockReturnValueOnce(populatedCourseListData);
|
||||
reduxHooks.useEnterpriseDashboardData.mockReturnValueOnce(null);
|
||||
reduxHooks.useRequestIsCompleted.mockReturnValueOnce(isCompleted);
|
||||
useExperimentContext.mockReturnValueOnce(experimentContext);
|
||||
activateProductRecommendationsExperiment.mockReturnValueOnce(optimizelyExperiment);
|
||||
|
||||
hooks.useActivateRecommendationsExperiment();
|
||||
|
||||
({ calls } = React.useEffect.mock);
|
||||
([[cb, prereqs]] = calls);
|
||||
};
|
||||
|
||||
it('runs when isExperimentActive or countryCode changes (prereqs)', () => {
|
||||
setUp(true);
|
||||
expect(prereqs).toEqual([false, 'ZA']);
|
||||
expect(calls.length).toEqual(1);
|
||||
});
|
||||
describe('when the request state is not completed', () => {
|
||||
it('does not activate or send any events', () => {
|
||||
setUp(false);
|
||||
cb();
|
||||
expect(activateProductRecommendationsExperiment).not.toHaveBeenCalled();
|
||||
expect(trackProductRecommendationsViewed).not.toHaveBeenCalled();
|
||||
expect(recommendationsViewed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('when the experiment is active', () => {
|
||||
it('does not activate or send any events', () => {
|
||||
setUp(true, experimentContextMock({ isExperimentActive: true }));
|
||||
cb();
|
||||
expect(activateProductRecommendationsExperiment).not.toHaveBeenCalled();
|
||||
expect(trackProductRecommendationsViewed).not.toHaveBeenCalled();
|
||||
expect(recommendationsViewed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('when the experiment is inactive but user country code has not been fetched', () => {
|
||||
it('does not activate or send any events', () => {
|
||||
setUp(true, experimentContextMock({ countryCode: null }));
|
||||
cb();
|
||||
expect(activateProductRecommendationsExperiment).not.toHaveBeenCalled();
|
||||
expect(trackProductRecommendationsViewed).not.toHaveBeenCalled();
|
||||
expect(recommendationsViewed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('when the experiment is inactive and user country code has been fetched', () => {
|
||||
it('activates the experiment and sends viewed event for control group', () => {
|
||||
setUp(
|
||||
true,
|
||||
experimentContextMock({}),
|
||||
optimizelyExperimentMock({ experimentActivated: true, inExperimentVariant: false }),
|
||||
);
|
||||
cb();
|
||||
expect(activateProductRecommendationsExperiment).toHaveBeenCalledWith('1', userAttributes);
|
||||
expect(setExperiment).toHaveBeenCalled();
|
||||
expect(trackProductRecommendationsViewed).toHaveBeenCalledWith('1');
|
||||
expect(recommendationsViewed).toHaveBeenCalledWith(true, control, mostRecentCourseRunKey);
|
||||
});
|
||||
it('activates the experiment and does not sends viewed event for treatment group', () => {
|
||||
setUp(
|
||||
true,
|
||||
experimentContextMock({ countryCode: '' }),
|
||||
optimizelyExperimentMock({ experimentActivated: true, inExperimentVariant: true }),
|
||||
);
|
||||
cb();
|
||||
expect(activateProductRecommendationsExperiment).toHaveBeenCalledWith('1', { ...userAttributes, location: '' });
|
||||
expect(setExperiment).toHaveBeenCalled();
|
||||
expect(trackProductRecommendationsViewed).not.toHaveBeenCalled();
|
||||
expect(recommendationsViewed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSendViewedEvents', () => {
|
||||
describe('behavior', () => {
|
||||
describe('useEffect call', () => {
|
||||
let cb;
|
||||
let calls;
|
||||
let prereqs;
|
||||
const { completed, pending } = RequestStates;
|
||||
|
||||
const setUp = (requestState, response) => {
|
||||
reduxHooks.useCurrentCourseList.mockReturnValueOnce(populatedCourseListData);
|
||||
hooks.useSendViewedEvents(requestState, response);
|
||||
({ calls } = React.useEffect.mock);
|
||||
([[cb, prereqs]] = calls);
|
||||
};
|
||||
|
||||
it('runs when data or requestState changes (prereqs)', () => {
|
||||
setUp(completed, mockCrossProductResponse);
|
||||
expect(prereqs).toEqual([mockCrossProductResponse, completed]);
|
||||
expect(calls.length).toEqual(1);
|
||||
});
|
||||
describe('when the request state is not completed', () => {
|
||||
it('does not send any events', () => {
|
||||
setUp(pending, mockCrossProductResponse);
|
||||
cb();
|
||||
expect(trackProductRecommendationsViewed).not.toHaveBeenCalled();
|
||||
expect(recommendationsViewed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('when the request state is completed', () => {
|
||||
describe('with crossProduct data that has 2 cross product courses', () => {
|
||||
it('sends out recommendations viewed event for "treatment" group', () => {
|
||||
setUp(completed, mockCrossProductResponse);
|
||||
cb();
|
||||
expect(trackProductRecommendationsViewed).toHaveBeenCalledWith('1');
|
||||
expect(recommendationsViewed).toHaveBeenCalledWith(false, treatment, mostRecentCourseRunKey);
|
||||
});
|
||||
});
|
||||
describe('with amplitude data and no cross product data', () => {
|
||||
it('sends out recommendations viewed event for "no experiment" group', () => {
|
||||
setUp(completed, mockAmplitudeResponse);
|
||||
cb();
|
||||
expect(trackProductRecommendationsViewed).toHaveBeenCalledWith('1');
|
||||
expect(recommendationsViewed).toHaveBeenCalledWith(true, noExperiment, mostRecentCourseRunKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,14 +467,19 @@ describe('ProductRecommendations hooks', () => {
|
||||
});
|
||||
});
|
||||
describe('useProductRecommendationsData', () => {
|
||||
let fetchSpy;
|
||||
let fetchRecommendationsSpy;
|
||||
let sendViewedEventsSpy;
|
||||
beforeEach(() => {
|
||||
state.mock();
|
||||
fetchSpy = jest.spyOn(hooks, 'useFetchRecommendations').mockImplementationOnce(() => {});
|
||||
fetchRecommendationsSpy = jest.spyOn(hooks, 'useFetchRecommendations').mockImplementationOnce(() => {});
|
||||
sendViewedEventsSpy = jest.spyOn(hooks, 'useSendViewedEvents').mockImplementationOnce(() => {});
|
||||
output = hooks.useProductRecommendationsData();
|
||||
});
|
||||
it('calls useFetchRecommendations with setRequestState and setData', () => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith(state.setState.requestState, state.setState.data);
|
||||
expect(fetchRecommendationsSpy).toHaveBeenCalledWith(state.setState.requestState, state.setState.data);
|
||||
});
|
||||
it('calls useFetchViewedEvents with requestState and data', () => {
|
||||
expect(sendViewedEventsSpy).toHaveBeenCalledWith(state.stateVals.requestState, state.stateVals.data);
|
||||
});
|
||||
it('initializes requestState as RequestStates.pending', () => {
|
||||
state.expectInitializedWith(state.keys.requestState, RequestStates.pending);
|
||||
|
||||
@@ -18,7 +18,10 @@ export const activateProductRecommendationsExperiment = (userId, userAttributes)
|
||||
userAttributes,
|
||||
);
|
||||
|
||||
return variation === PRODUCT_RECOMMENDATIONS_EXP_VARIATION;
|
||||
return {
|
||||
experimentActivated: variation !== null,
|
||||
inExperimentVariant: variation === PRODUCT_RECOMMENDATIONS_EXP_VARIATION,
|
||||
};
|
||||
};
|
||||
|
||||
export const trackProductRecommendationsViewed = (userId, userAttributes = {}) => {
|
||||
|
||||
@@ -24,11 +24,25 @@ const userAttributes = {
|
||||
|
||||
describe('Optimizely events', () => {
|
||||
describe('activateProductRecommendationsExperiment', () => {
|
||||
it('activates the experiment and returns in recommendations experiment variant', () => {
|
||||
it('activates the experiment and returns in experiment variant', () => {
|
||||
optimizelyClient.activate.mockReturnValueOnce(PRODUCT_RECOMMENDATIONS_EXP_VARIATION);
|
||||
const inRecommendationsVariant = activateProductRecommendationsExperiment(userId, userAttributes);
|
||||
const experiment = activateProductRecommendationsExperiment(userId, userAttributes);
|
||||
|
||||
expect(inRecommendationsVariant).toBeTruthy();
|
||||
expect(experiment.experimentActivated).toBeTruthy();
|
||||
expect(experiment.inExperimentVariant).toBeTruthy();
|
||||
expect(optimizelyClient.activate).toHaveBeenCalledWith(
|
||||
PRODUCT_RECOMMENDATIONS_EXP_KEY,
|
||||
userId,
|
||||
userAttributes,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not activate the experiment and returns not in experiment variant', () => {
|
||||
optimizelyClient.activate.mockReturnValueOnce(null);
|
||||
const experiment = activateProductRecommendationsExperiment(userId, userAttributes);
|
||||
|
||||
expect(experiment.experimentActivated).toBeFalsy();
|
||||
expect(experiment.inExperimentVariant).toBeFalsy();
|
||||
expect(optimizelyClient.activate).toHaveBeenCalledWith(
|
||||
PRODUCT_RECOMMENDATIONS_EXP_KEY,
|
||||
userId,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
export const getCoursesWithType = (courseTypes) => {
|
||||
export const getCoursesWithType = (courseTypes, parameters = true) => {
|
||||
const courses = [];
|
||||
const marketingUrl = parameters
|
||||
? 'https://www.edx.org/course/some-course?utm_source=source'
|
||||
: 'https://www.edx.org/course/some-course';
|
||||
|
||||
courseTypes.forEach((type) => {
|
||||
courses.push({
|
||||
title: 'Introduction to Computer Science',
|
||||
courseRunKey: 'course-v1:Test+Course+2022T2',
|
||||
marketingUrl: 'https://www.edx.org/course/some-course?utm_source=source',
|
||||
marketingUrl,
|
||||
courseType: type,
|
||||
image: {
|
||||
src: 'https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg',
|
||||
@@ -24,14 +27,14 @@ export const getCoursesWithType = (courseTypes) => {
|
||||
};
|
||||
|
||||
export const mockFooterRecommendationsHook = {
|
||||
showAndLoad: { shouldShowFooter: true, shouldLoadFooter: true },
|
||||
showDontLoad: { shouldShowFooter: true, shouldLoadFooter: false },
|
||||
loadDontShow: { shouldShowFooter: false, shouldLoadFooter: true },
|
||||
dontShowOrLoad: { shouldShowFooter: false, shouldLoadFooter: false },
|
||||
default: { isExperimentActive: false, inRecommendationsVariant: true },
|
||||
activeControl: { isExperimentActive: true, inRecommendationsVariant: false },
|
||||
activeTreatment: { isExperimentActive: true, inRecommendationsVariant: true },
|
||||
};
|
||||
|
||||
export const mockCrossProductCourses = getCoursesWithType(['executive-education-2u', 'bootcamp-2u']);
|
||||
export const mockOpenCourses = getCoursesWithType(['verified-audit', 'audit', 'verified', 'course']);
|
||||
export const mockFallbackOpenCourse = getCoursesWithType(['course'], false);
|
||||
|
||||
export const mockCrossProductResponse = {
|
||||
crossProductCourses: mockCrossProductCourses,
|
||||
|
||||
@@ -12,7 +12,6 @@ export const eventNames = StrictDict({
|
||||
export const productCardClicked = (courseRunKey, courseTitle, courseType, href) => {
|
||||
createLinkTracker(
|
||||
createEventTracker(eventNames.productCardClicked, {
|
||||
category: 'recommender',
|
||||
label: courseTitle,
|
||||
courserun_key: courseRunKey,
|
||||
page: 'dashboard',
|
||||
@@ -25,7 +24,6 @@ export const productCardClicked = (courseRunKey, courseTitle, courseType, href)
|
||||
export const discoveryCardClicked = (courseRunKey, courseTitle, href) => {
|
||||
createLinkTracker(
|
||||
createEventTracker(eventNames.discoveryCardClicked, {
|
||||
category: 'recommender',
|
||||
label: courseTitle,
|
||||
courserun_key: courseRunKey,
|
||||
page: 'dashboard',
|
||||
@@ -38,7 +36,6 @@ export const discoveryCardClicked = (courseRunKey, courseTitle, href) => {
|
||||
export const recommendationsHeaderClicked = (courseType, href) => {
|
||||
createLinkTracker(
|
||||
createEventTracker(eventNames.recommendationsHeaderClicked, {
|
||||
category: 'recommender',
|
||||
page: 'dashboard',
|
||||
product_line: courseTypeToProductLineMap[courseType],
|
||||
}),
|
||||
@@ -46,10 +43,11 @@ export const recommendationsHeaderClicked = (courseType, href) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const recommendationsViewed = (isControl, courseRunKey) => {
|
||||
export const recommendationsViewed = (isControl, recommenderGroup, courseRunKey) => {
|
||||
createEventTracker(eventNames.recommendationsViewed, {
|
||||
is_control: isControl,
|
||||
productRecommenderGroup: recommenderGroup,
|
||||
page: 'dashboard',
|
||||
course_key: convertCourseRunKeyToCourseKey(courseRunKey),
|
||||
course_key: courseRunKey ? convertCourseRunKeyToCourseKey(courseRunKey) : '',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLinkTracker, createEventTracker } from 'data/services/segment/utils';
|
||||
import { bootCamp, treatment, control } from './constants';
|
||||
import {
|
||||
eventNames,
|
||||
productCardClicked,
|
||||
@@ -18,17 +19,17 @@ const courseRunKeyOld = 'MITx/5.0.01/2022T2/';
|
||||
const label = 'Web Design';
|
||||
const headerLink = 'https://www.edx.org/search?tab=course?linked_from=recommender';
|
||||
const courseUrl = 'https://www.edx.org/course/some-course';
|
||||
const category = 'recommender';
|
||||
|
||||
describe('product recommendations trackers', () => {
|
||||
describe('recommendationsViewed', () => {
|
||||
describe('with old course run key format', () => {
|
||||
it('creates an event tracker for when cross product recommendations are present', () => {
|
||||
recommendationsViewed(false, courseRunKeyOld);
|
||||
recommendationsViewed(false, treatment, courseRunKeyOld);
|
||||
expect(createEventTracker).toHaveBeenCalledWith(
|
||||
eventNames.recommendationsViewed,
|
||||
{
|
||||
is_control: false,
|
||||
productRecommenderGroup: treatment,
|
||||
page: 'dashboard',
|
||||
course_key: courseKey,
|
||||
},
|
||||
@@ -36,23 +37,38 @@ describe('product recommendations trackers', () => {
|
||||
});
|
||||
});
|
||||
describe('with new course run key format', () => {
|
||||
it('creates an event tracker for when cross product recommendations are present', () => {
|
||||
recommendationsViewed(false, courseRunKeyNew);
|
||||
it('creates an event tracker for when a user is bucketed into the conrol group', () => {
|
||||
recommendationsViewed(false, control, courseRunKeyNew);
|
||||
|
||||
expect(createEventTracker).toHaveBeenCalledWith(
|
||||
eventNames.recommendationsViewed,
|
||||
{
|
||||
is_control: false,
|
||||
productRecommenderGroup: control,
|
||||
page: 'dashboard',
|
||||
course_key: courseKey,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with no course run key', () => {
|
||||
it('creates an event tracker for when a user is bucketed into the conrol group', () => {
|
||||
recommendationsViewed(false, control, '');
|
||||
expect(createEventTracker).toHaveBeenCalledWith(
|
||||
eventNames.recommendationsViewed,
|
||||
{
|
||||
is_control: false,
|
||||
productRecommenderGroup: control,
|
||||
page: 'dashboard',
|
||||
course_key: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('recommendationsHeaderClicked', () => {
|
||||
it('creates a link tracker for when a recommendations header is clicked', () => {
|
||||
const attributes = {
|
||||
category,
|
||||
product_line: 'open-courses',
|
||||
page: 'dashboard',
|
||||
};
|
||||
@@ -66,7 +82,6 @@ describe('product recommendations trackers', () => {
|
||||
describe('discoveryCardClicked', () => {
|
||||
it('creates a link tracker for when a open course card is clicked', () => {
|
||||
const attributes = {
|
||||
category,
|
||||
label,
|
||||
courserun_key: courseRunKeyNew,
|
||||
page: 'dashboard',
|
||||
@@ -82,7 +97,6 @@ describe('product recommendations trackers', () => {
|
||||
describe('productCardClicked', () => {
|
||||
it('creates a link tracker for when a cross product course card is clicked', () => {
|
||||
const attributes = {
|
||||
category,
|
||||
label,
|
||||
courserun_key: courseRunKeyNew,
|
||||
page: 'dashboard',
|
||||
@@ -90,7 +104,7 @@ describe('product recommendations trackers', () => {
|
||||
};
|
||||
const args = [eventNames.productCardClicked, attributes];
|
||||
|
||||
productCardClicked(courseRunKeyNew, label, 'Boot Camp', courseUrl);
|
||||
productCardClicked(courseRunKeyNew, label, bootCamp, courseUrl);
|
||||
expect(createEventTracker).toHaveBeenCalledWith(...args);
|
||||
expect(createLinkTracker).toHaveBeenCalledWith(createEventTracker(...args), courseUrl);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { executiveEducation, course, bootCamp } from './constants';
|
||||
|
||||
export const courseShape = {
|
||||
uuid: PropTypes.string,
|
||||
@@ -22,24 +23,24 @@ export const courseShape = {
|
||||
};
|
||||
|
||||
export const courseTypeToProductTypeMap = {
|
||||
course: 'Course',
|
||||
'verified-audit': 'Course',
|
||||
verified: 'Course',
|
||||
audit: 'Course',
|
||||
'credit-verified-audit': 'Course',
|
||||
'spoc-verified-audit': 'Course',
|
||||
course,
|
||||
'verified-audit': course,
|
||||
verified: course,
|
||||
audit: course,
|
||||
'credit-verified-audit': course,
|
||||
'spoc-verified-audit': course,
|
||||
professional: 'Professional Certificate',
|
||||
'bootcamp-2u': 'Boot Camp',
|
||||
'executive-education-2u': 'Executive Education',
|
||||
'executive-education': 'Executive Education',
|
||||
'bootcamp-2u': bootCamp,
|
||||
'executive-education-2u': executiveEducation,
|
||||
'executive-education': executiveEducation,
|
||||
masters: "Master's",
|
||||
'masters-verified-audit': "Master's",
|
||||
};
|
||||
|
||||
export const courseTypeToProductLineMap = {
|
||||
'Executive Education': 'executive-education',
|
||||
'Boot Camp': 'boot-camps',
|
||||
Course: 'open-courses',
|
||||
[executiveEducation]: 'executive-education',
|
||||
[bootCamp]: 'boot-camps',
|
||||
[course]: 'open-courses',
|
||||
};
|
||||
|
||||
export const convertCourseRunKeyToCourseKey = (courseRunKey) => {
|
||||
|
||||
Reference in New Issue
Block a user