fix: remove Optimizely and surrounding components (#386)
This commit is contained in:
1
.env
1
.env
@@ -40,5 +40,4 @@ ACCOUNT_SETTINGS_URL=''
|
||||
ACCOUNT_PROFILE_URL=''
|
||||
ENABLE_NOTICES=''
|
||||
CAREER_LINK_URL=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
ENABLE_EDX_PERSONAL_DASHBOARD=false
|
||||
|
||||
@@ -46,5 +46,4 @@ ACCOUNT_SETTINGS_URL='http://localhost:1997'
|
||||
ACCOUNT_PROFILE_URL='http://localhost:1995'
|
||||
ENABLE_NOTICES=''
|
||||
CAREER_LINK_URL=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
ENABLE_EDX_PERSONAL_DASHBOARD=false
|
||||
|
||||
@@ -45,5 +45,4 @@ ACCOUNT_SETTINGS_URL='http://account-settings-url.test'
|
||||
ACCOUNT_PROFILE_URL='http://account-profile-url.test'
|
||||
ENABLE_NOTICES=''
|
||||
CAREER_LINK_URL=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY='SDK Key'
|
||||
ENABLE_EDX_PERSONAL_DASHBOARD=true
|
||||
|
||||
@@ -69,6 +69,5 @@ module.exports = {
|
||||
ACCOUNT_PROFILE_URL: 'http://localhost:1995',
|
||||
ENABLE_NOTICES: '',
|
||||
CAREER_LINK_URL: '',
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY: '',
|
||||
EXPERIMENT_08_23_VAN_PAINTED_DOOR: true,
|
||||
};
|
||||
|
||||
131
package-lock.json
generated
131
package-lock.json
generated
@@ -22,7 +22,6 @@
|
||||
"@openedx/frontend-plugin-framework": "^1.2.0",
|
||||
"@openedx/frontend-slot-footer": "^1.0.2",
|
||||
"@openedx/paragon": "^22.2.2",
|
||||
"@optimizely/react-sdk": "^2.9.2",
|
||||
"@redux-beacon/segment": "^1.1.0",
|
||||
"@reduxjs/toolkit": "^1.6.1",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
@@ -4566,113 +4565,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@optimizely/js-sdk-logging": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@optimizely/js-sdk-logging/-/js-sdk-logging-0.3.1.tgz",
|
||||
"integrity": "sha512-K71Jf283FP0E4oXehcXTTM3gvgHZHr7FUrIsw//0mdJlotHJT4Nss4hE0CWPbBxO7LJAtwNnO+VIA/YOcO4vHg==",
|
||||
"dependencies": {
|
||||
"@optimizely/js-sdk-utils": "^0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@optimizely/js-sdk-utils": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@optimizely/js-sdk-utils/-/js-sdk-utils-0.4.0.tgz",
|
||||
"integrity": "sha512-QG2oytnITW+VKTJK+l0RxjaS5VrA6W+AZMzpeg4LCB4Rn4BEKtF+EcW/5S1fBDLAviGq/0TLpkjM3DlFkJ9/Gw==",
|
||||
"dependencies": {
|
||||
"uuid": "^3.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@optimizely/js-sdk-utils/node_modules/uuid": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
|
||||
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
|
||||
"bin": {
|
||||
"uuid": "bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@optimizely/optimizely-sdk": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@optimizely/optimizely-sdk/-/optimizely-sdk-4.10.0.tgz",
|
||||
"integrity": "sha512-DVsbhhwOObJRKDkrXcJJw0FZh/7PIbHAewmtm3fCq9jU2UE2n45h1e/Iy4IgRzUaSOrJccN+wUsN+lD3XTJwww==",
|
||||
"dependencies": {
|
||||
"@optimizely/js-sdk-datafile-manager": "^0.9.5",
|
||||
"@optimizely/js-sdk-event-processor": "^0.10.0",
|
||||
"@optimizely/js-sdk-logging": "^0.3.1",
|
||||
"json-schema": "^0.4.0",
|
||||
"murmurhash": "0.0.2",
|
||||
"uuid": "^3.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@optimizely/optimizely-sdk/node_modules/@optimizely/js-sdk-datafile-manager": {
|
||||
"version": "0.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@optimizely/js-sdk-datafile-manager/-/js-sdk-datafile-manager-0.9.5.tgz",
|
||||
"integrity": "sha512-O4ujr1nBBAQBtx8YoKNpzzaEZgsE+aU4dxubT17ePqv/YVUWE+JOY21tSRrqZy/BlbbyzL+ElT8hrGB5ZzVoIQ==",
|
||||
"dependencies": {
|
||||
"@optimizely/js-sdk-logging": "^0.3.1",
|
||||
"@optimizely/js-sdk-utils": "^0.4.0",
|
||||
"decompress-response": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-native-async-storage/async-storage": "^1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-native-async-storage/async-storage": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@optimizely/optimizely-sdk/node_modules/@optimizely/js-sdk-event-processor": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@optimizely/js-sdk-event-processor/-/js-sdk-event-processor-0.10.0.tgz",
|
||||
"integrity": "sha512-Fkv0ulte/KT+ClgSbchcZeFYzGZbxVtl0mqmlGC9QtEXuJlOfnsKD9C3kZ1SnL2P7+5d9QhYotlxXJr19SQRQw==",
|
||||
"dependencies": {
|
||||
"@optimizely/js-sdk-logging": "^0.3.1",
|
||||
"@optimizely/js-sdk-utils": "^0.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-native-async-storage/async-storage": "^1.2.0",
|
||||
"@react-native-community/netinfo": "5.9.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-native-async-storage/async-storage": {
|
||||
"optional": true
|
||||
},
|
||||
"@react-native-community/netinfo": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@optimizely/optimizely-sdk/node_modules/uuid": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
|
||||
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
|
||||
"bin": {
|
||||
"uuid": "bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@optimizely/react-sdk": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@optimizely/react-sdk/-/react-sdk-2.9.2.tgz",
|
||||
"integrity": "sha512-//OozC59dr5Lsss2H9Jnyb35FMTF8Z+CMFi89kVs1U1Fy1sKOXK7Web1hw18DBZctwKfbb8Sl+Yw7Pgmo3P2fA==",
|
||||
"dependencies": {
|
||||
"@optimizely/js-sdk-logging": "^0.3.1",
|
||||
"@optimizely/optimizely-sdk": "^4.9.1",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"prop-types": "^15.6.2",
|
||||
"utility-types": "^2.1.0 || ^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz",
|
||||
@@ -9157,6 +9049,7 @@
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
|
||||
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"mimic-response": "^2.0.0"
|
||||
},
|
||||
@@ -15078,11 +14971,6 @@
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -15972,6 +15860,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
|
||||
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
@@ -16157,14 +16046,6 @@
|
||||
"multicast-dns": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/murmurhash": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash/-/murmurhash-0.0.2.tgz",
|
||||
"integrity": "sha512-LKlwdZKWzvCQpMszb2HO5leJ7P9T4m5XuDKku8bM0uElrzqK9cn0+iozwQS8jO4SNjrp4w7olalgd8WgsIjhWA==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
|
||||
@@ -25596,14 +25477,6 @@
|
||||
"resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz",
|
||||
"integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA=="
|
||||
},
|
||||
"node_modules/utility-types": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
|
||||
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"@openedx/frontend-plugin-framework": "^1.2.0",
|
||||
"@openedx/frontend-slot-footer": "^1.0.2",
|
||||
"@openedx/paragon": "^22.2.2",
|
||||
"@optimizely/react-sdk": "^2.9.2",
|
||||
"@redux-beacon/segment": "^1.1.0",
|
||||
"@reduxjs/toolkit": "^1.6.1",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
|
||||
19
src/App.jsx
19
src/App.jsx
@@ -18,7 +18,6 @@ import {
|
||||
import { reduxHooks } from 'hooks';
|
||||
import Dashboard from 'containers/Dashboard';
|
||||
import ZendeskFab from 'components/ZendeskFab';
|
||||
import { ExperimentProvider } from 'ExperimentContext';
|
||||
|
||||
import track from 'tracking';
|
||||
|
||||
@@ -42,19 +41,6 @@ export const App = () => {
|
||||
const { supportEmail } = reduxHooks.usePlatformSettingsData();
|
||||
const loadData = reduxHooks.useLoadData();
|
||||
|
||||
const optimizelyScript = () => {
|
||||
if (getConfig().OPTIMIZELY_URL) {
|
||||
return <script src={getConfig().OPTIMIZELY_URL} />;
|
||||
} if (getConfig().OPTIMIZELY_PROJECT_ID) {
|
||||
return (
|
||||
<script
|
||||
src={`${getConfig().MARKETING_SITE_BASE_URL}/optimizelyjs/${getConfig().OPTIMIZELY_PROJECT_ID}.js`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (authenticatedUser?.administrator || getConfig().NODE_ENV === 'development') {
|
||||
window.loadEmptyData = () => {
|
||||
@@ -91,7 +77,6 @@ export const App = () => {
|
||||
<Helmet>
|
||||
<title>{formatMessage(messages.pageTitle)}</title>
|
||||
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
|
||||
{optimizelyScript()}
|
||||
</Helmet>
|
||||
<div>
|
||||
<AppWrapper>
|
||||
@@ -103,9 +88,7 @@ export const App = () => {
|
||||
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
|
||||
</Alert>
|
||||
) : (
|
||||
<ExperimentProvider>
|
||||
<Dashboard />
|
||||
</ExperimentProvider>
|
||||
<Dashboard />
|
||||
)}
|
||||
</main>
|
||||
</AppWrapper>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { reduxHooks } from 'hooks';
|
||||
import Dashboard from 'containers/Dashboard';
|
||||
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
|
||||
import AppWrapper from 'containers/WidgetContainers/AppWrapper';
|
||||
import { ExperimentProvider } from 'ExperimentContext';
|
||||
import { App } from './App';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -19,9 +18,6 @@ jest.mock('@edx/frontend-component-footer', () => ({ FooterSlot: 'Footer' }));
|
||||
jest.mock('containers/Dashboard', () => 'Dashboard');
|
||||
jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader');
|
||||
jest.mock('components/ZendeskFab', () => 'ZendeskFab');
|
||||
jest.mock('ExperimentContext', () => ({
|
||||
ExperimentProvider: 'ExperimentProvider',
|
||||
}));
|
||||
jest.mock('containers/WidgetContainers/AppWrapper', () => 'AppWrapper');
|
||||
jest.mock('data/redux', () => ({
|
||||
selectors: 'redux.selectors',
|
||||
@@ -79,11 +75,10 @@ describe('App router component', () => {
|
||||
it('loads dashboard', () => {
|
||||
const main = el.instance.findByType('main')[0];
|
||||
expect(main.children.length).toEqual(1);
|
||||
const expProvider = main.children[0];
|
||||
expect(expProvider.type).toEqual('ExperimentProvider');
|
||||
expect(expProvider.children.length).toEqual(1);
|
||||
const dashboard = main.children[0];
|
||||
expect(dashboard.type).toEqual('Dashboard');
|
||||
expect(
|
||||
expProvider.matches(shallow(<ExperimentProvider><Dashboard /></ExperimentProvider>)),
|
||||
dashboard.matches(shallow(<Dashboard />)),
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -97,11 +92,10 @@ describe('App router component', () => {
|
||||
it('loads dashboard', () => {
|
||||
const main = el.instance.findByType('main')[0];
|
||||
expect(main.children.length).toEqual(1);
|
||||
const expProvider = main.children[0];
|
||||
expect(expProvider.type).toEqual('ExperimentProvider');
|
||||
expect(expProvider.children.length).toEqual(1);
|
||||
const dashboard = main.children[0];
|
||||
expect(dashboard.type).toEqual('Dashboard');
|
||||
expect(
|
||||
expProvider.matches(shallow(<ExperimentProvider><Dashboard /></ExperimentProvider>)),
|
||||
dashboard.matches(shallow(<Dashboard />)),
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -115,11 +109,10 @@ describe('App router component', () => {
|
||||
it('loads dashboard', () => {
|
||||
const main = el.instance.findByType('main')[0];
|
||||
expect(main.children.length).toEqual(1);
|
||||
const expProvider = main.children[0];
|
||||
expect(expProvider.type).toEqual('ExperimentProvider');
|
||||
expect(expProvider.children.length).toEqual(1);
|
||||
const dashboard = main.children[0];
|
||||
expect(dashboard.type).toEqual('Dashboard');
|
||||
expect(
|
||||
expProvider.matches(shallow(<ExperimentProvider><Dashboard /></ExperimentProvider>)),
|
||||
dashboard.matches(shallow(<Dashboard />)),
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useWindowSize, breakpoints } from '@openedx/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 };
|
||||
@@ -1,122 +0,0 @@
|
||||
import React from 'react';
|
||||
import { waitFor, render } from '@testing-library/react';
|
||||
import { useWindowSize } from '@openedx/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('successful 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('unsuccessful 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();
|
||||
|
||||
render(
|
||||
<ExperimentProvider>
|
||||
<TestComponent />
|
||||
</ExperimentProvider>,
|
||||
);
|
||||
|
||||
expect(countryCodeSpy).toHaveBeenCalledWith(state.setState.countryCode);
|
||||
state.expectInitializedWith(state.keys.countryCode, null);
|
||||
state.expectInitializedWith(state.keys.experiment, { isExperimentActive: false, inRecommendationsVariant: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,9 +51,7 @@ exports[`App router component component no network failure snapshot 1`] = `
|
||||
<AppWrapper>
|
||||
<LearnerDashboardHeader />
|
||||
<main>
|
||||
<ExperimentProvider>
|
||||
<Dashboard />
|
||||
</ExperimentProvider>
|
||||
<Dashboard />
|
||||
</main>
|
||||
</AppWrapper>
|
||||
<FooterSlot />
|
||||
@@ -75,17 +73,12 @@ exports[`App router component component no network failure with optimizely proje
|
||||
rel="shortcut icon"
|
||||
type="image/x-icon"
|
||||
/>
|
||||
<script
|
||||
src="undefined/optimizelyjs/fakeId.js"
|
||||
/>
|
||||
</HelmetWrapper>
|
||||
<div>
|
||||
<AppWrapper>
|
||||
<LearnerDashboardHeader />
|
||||
<main>
|
||||
<ExperimentProvider>
|
||||
<Dashboard />
|
||||
</ExperimentProvider>
|
||||
<Dashboard />
|
||||
</main>
|
||||
</AppWrapper>
|
||||
<FooterSlot />
|
||||
@@ -107,17 +100,12 @@ exports[`App router component component no network failure with optimizely url s
|
||||
rel="shortcut icon"
|
||||
type="image/x-icon"
|
||||
/>
|
||||
<script
|
||||
src="fake.url"
|
||||
/>
|
||||
</HelmetWrapper>
|
||||
<div>
|
||||
<AppWrapper>
|
||||
<LearnerDashboardHeader />
|
||||
<main>
|
||||
<ExperimentProvider>
|
||||
<Dashboard />
|
||||
</ExperimentProvider>
|
||||
<Dashboard />
|
||||
</main>
|
||||
</AppWrapper>
|
||||
<FooterSlot />
|
||||
|
||||
@@ -3,7 +3,8 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { Container, Col, Row } from '@openedx/paragon';
|
||||
|
||||
import WidgetFooter from 'containers/WidgetContainers/WidgetFooter';
|
||||
import WidgetSidebar from '../WidgetContainers/WidgetSidebar';
|
||||
|
||||
import hooks from './hooks';
|
||||
|
||||
export const columnConfig = {
|
||||
@@ -23,11 +24,10 @@ export const columnConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export const DashboardLayout = ({ children, sidebar: Sidebar }) => {
|
||||
export const DashboardLayout = ({ children }) => {
|
||||
const {
|
||||
isCollapsed,
|
||||
sidebarShowing,
|
||||
setSidebarShowing,
|
||||
} = hooks.useDashboardLayoutData();
|
||||
|
||||
const courseListColumnProps = sidebarShowing
|
||||
@@ -42,12 +42,7 @@ export const DashboardLayout = ({ children, sidebar: Sidebar }) => {
|
||||
</Col>
|
||||
<Col {...columnConfig.sidebar} className="sidebar-column">
|
||||
{!isCollapsed && (<h2 className="course-list-title"> </h2>)}
|
||||
<Sidebar setSidebarShowing={setSidebarShowing} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<WidgetFooter />
|
||||
<WidgetSidebar />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
@@ -55,7 +50,6 @@ export const DashboardLayout = ({ children, sidebar: Sidebar }) => {
|
||||
};
|
||||
DashboardLayout.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
sidebar: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default DashboardLayout;
|
||||
|
||||
@@ -16,17 +16,13 @@ const hookProps = {
|
||||
};
|
||||
hooks.useDashboardLayoutData.mockReturnValue(hookProps);
|
||||
|
||||
const props = {
|
||||
sidebar: jest.fn(() => 'test-sidebar-content'),
|
||||
};
|
||||
|
||||
const children = 'test-children';
|
||||
|
||||
let el;
|
||||
describe('DashboardLayout', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
el = shallow(<DashboardLayout {...props}>{children}</DashboardLayout>);
|
||||
el = shallow(<DashboardLayout>{children}</DashboardLayout>);
|
||||
});
|
||||
|
||||
const testColumns = () => {
|
||||
@@ -40,17 +36,13 @@ describe('DashboardLayout', () => {
|
||||
const columns = el.instance.findByType(Row)[0].findByType(Col);
|
||||
expect(columns[0].children).not.toHaveLength(0);
|
||||
});
|
||||
it('displays sidebar prop in second column', () => {
|
||||
it('displays WidgetSidebar in second column', () => {
|
||||
const columns = el.instance.findByType(Row)[0].findByType(Col);
|
||||
expect(columns[1].findByType(props.sidebar)).toHaveLength(1);
|
||||
});
|
||||
it('displays a footer in the second row', () => {
|
||||
const columns = el.instance.findByType(Row)[1].findByType(Col);
|
||||
expect(columns[0].children[0].type).toEqual('WidgetFooter');
|
||||
expect(columns[1].findByType('WidgetSidebar')).toHaveLength(1);
|
||||
});
|
||||
};
|
||||
const testSidebarLayout = () => {
|
||||
it('displays widthSidebar width for course list column', () => {
|
||||
it('displays withSidebar width for course list column', () => {
|
||||
const columns = el.instance.findByType(Row)[0].findByType(Col);
|
||||
Object.keys(columnConfig.courseList.withSidebar).forEach(size => {
|
||||
expect(columns[0].props[size]).toEqual(columnConfig.courseList.withSidebar[size]);
|
||||
|
||||
@@ -38,14 +38,7 @@ exports[`DashboardLayout collapsed sidebar not showing snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<mockConstructor
|
||||
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<WidgetFooter />
|
||||
<WidgetSidebar />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
@@ -89,14 +82,7 @@ exports[`DashboardLayout collapsed sidebar showing snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<mockConstructor
|
||||
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<WidgetFooter />
|
||||
<WidgetSidebar />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
@@ -145,14 +131,7 @@ exports[`DashboardLayout not collapsed sidebar not showing snapshot 1`] = `
|
||||
>
|
||||
|
||||
</h2>
|
||||
<mockConstructor
|
||||
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<WidgetFooter />
|
||||
<WidgetSidebar />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
@@ -201,14 +180,7 @@ exports[`DashboardLayout not collapsed sidebar showing snapshot 1`] = `
|
||||
>
|
||||
|
||||
</h2>
|
||||
<mockConstructor
|
||||
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<WidgetFooter />
|
||||
<WidgetSidebar />
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
||||
@@ -17,9 +17,7 @@ exports[`Dashboard snapshots courses loaded, show select session modal, no avail
|
||||
data-testid="dashboard-content"
|
||||
id="dashboard-content"
|
||||
>
|
||||
<DashboardLayout
|
||||
sidebar="LoadedWidgetSidebar"
|
||||
>
|
||||
<DashboardLayout>
|
||||
<CoursesPanel />
|
||||
</DashboardLayout>
|
||||
</div>
|
||||
@@ -62,9 +60,7 @@ exports[`Dashboard snapshots there are no courses, there ARE available dashboard
|
||||
data-testid="dashboard-content"
|
||||
id="dashboard-content"
|
||||
>
|
||||
<DashboardLayout
|
||||
sidebar="NoCoursesWidgetSidebar"
|
||||
>
|
||||
<DashboardLayout>
|
||||
<CoursesPanel />
|
||||
</DashboardLayout>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,8 @@ export const useDashboardMessages = () => {
|
||||
|
||||
export const useDashboardLayoutData = () => {
|
||||
const { width } = useWindowSize();
|
||||
const [sidebarShowing, setSidebarShowing] = module.state.sidebarShowing(false);
|
||||
|
||||
const [sidebarShowing, setSidebarShowing] = module.state.sidebarShowing(true);
|
||||
return {
|
||||
isDashboardCollapsed: width < breakpoints.large.maxWidth,
|
||||
sidebarShowing,
|
||||
|
||||
@@ -40,9 +40,9 @@ describe('CourseCard hooks', () => {
|
||||
describe('useDashboardLayoutData', () => {
|
||||
beforeEach(() => { state.mock(); });
|
||||
describe('behavior', () => {
|
||||
it('initializes sidebarShowing to default false value', () => {
|
||||
it('initializes sidebarShowing to default true value', () => {
|
||||
hooks.useDashboardLayoutData();
|
||||
state.expectInitializedWith(state.keys.sidebarShowing, false);
|
||||
state.expectInitializedWith(state.keys.sidebarShowing, true);
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
|
||||
@@ -6,9 +6,6 @@ import EnterpriseDashboardModal from 'containers/EnterpriseDashboardModal';
|
||||
import SelectSessionModal from 'containers/SelectSessionModal';
|
||||
import CoursesPanel from 'containers/CoursesPanel';
|
||||
|
||||
import LoadedSidebar from 'containers/WidgetContainers/LoadedSidebar';
|
||||
import NoCoursesSidebar from 'containers/WidgetContainers/NoCoursesSidebar';
|
||||
|
||||
import LoadingView from './LoadingView';
|
||||
import DashboardLayout from './DashboardLayout';
|
||||
import hooks from './hooks';
|
||||
@@ -35,7 +32,7 @@ export const Dashboard = () => {
|
||||
{initIsPending
|
||||
? (<LoadingView />)
|
||||
: (
|
||||
<DashboardLayout sidebar={hasCourses ? LoadedSidebar : NoCoursesSidebar}>
|
||||
<DashboardLayout>
|
||||
<CoursesPanel />
|
||||
</DashboardLayout>
|
||||
)}
|
||||
|
||||
@@ -6,9 +6,6 @@ import EnterpriseDashboardModal from 'containers/EnterpriseDashboardModal';
|
||||
import SelectSessionModal from 'containers/SelectSessionModal';
|
||||
import CoursesPanel from 'containers/CoursesPanel';
|
||||
|
||||
import LoadedWidgetSidebar from 'containers/WidgetContainers/LoadedSidebar';
|
||||
import NoCoursesWidgetSidebar from 'containers/WidgetContainers/NoCoursesSidebar';
|
||||
|
||||
import DashboardLayout from './DashboardLayout';
|
||||
import LoadingView from './LoadingView';
|
||||
import hooks from './hooks';
|
||||
@@ -25,8 +22,6 @@ jest.mock('hooks', () => ({
|
||||
|
||||
jest.mock('containers/EnterpriseDashboardModal', () => 'EnterpriseDashboardModal');
|
||||
jest.mock('containers/CoursesPanel', () => 'CoursesPanel');
|
||||
jest.mock('containers/WidgetContainers/LoadedSidebar', () => 'LoadedWidgetSidebar');
|
||||
jest.mock('containers/WidgetContainers/NoCoursesSidebar', () => 'NoCoursesWidgetSidebar');
|
||||
jest.mock('./LoadingView', () => 'LoadingView');
|
||||
jest.mock('./DashboardLayout', () => 'DashboardLayout');
|
||||
|
||||
@@ -116,7 +111,7 @@ describe('Dashboard', () => {
|
||||
showSelectSessionModal: true,
|
||||
},
|
||||
content: ['LoadedView', (
|
||||
<DashboardLayout sidebar={LoadedWidgetSidebar}><CoursesPanel /></DashboardLayout>
|
||||
<DashboardLayout><CoursesPanel /></DashboardLayout>
|
||||
)],
|
||||
showEnterpriseModal: false,
|
||||
showSelectSessionModal: true,
|
||||
@@ -132,7 +127,7 @@ describe('Dashboard', () => {
|
||||
showSelectSessionModal: false,
|
||||
},
|
||||
content: ['Dashboard layout with no courses sidebar and content', (
|
||||
<DashboardLayout sidebar={NoCoursesWidgetSidebar}><CoursesPanel /></DashboardLayout>
|
||||
<DashboardLayout><CoursesPanel /></DashboardLayout>
|
||||
)],
|
||||
showEnterpriseModal: true,
|
||||
showSelectSessionModal: false,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`WidgetSidebar snapshots default 1`] = `
|
||||
<div
|
||||
className="widget-sidebar"
|
||||
>
|
||||
<div
|
||||
className="d-flex flex-column"
|
||||
>
|
||||
<PluginSlot
|
||||
id="widget_sidebar_plugin_slot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
import hooks from 'widgets/ProductRecommendations/hooks';
|
||||
|
||||
export const WidgetSidebar = ({ setSidebarShowing }) => {
|
||||
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
|
||||
|
||||
if (!inRecommendationsVariant && isExperimentActive) {
|
||||
setSidebarShowing(true);
|
||||
|
||||
return (
|
||||
<div className="widget-sidebar">
|
||||
<div className="d-flex flex-column">
|
||||
<PluginSlot id="widget_sidebar_plugin_slot" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
WidgetSidebar.propTypes = {
|
||||
setSidebarShowing: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default WidgetSidebar;
|
||||
@@ -1,52 +0,0 @@
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import hooks from 'widgets/ProductRecommendations/hooks';
|
||||
import { mockFooterRecommendationsHook } from 'widgets/ProductRecommendations/testData';
|
||||
import WidgetSidebar from '.';
|
||||
|
||||
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
|
||||
jest.mock('widgets/ProductRecommendations/hooks', () => ({
|
||||
useShowRecommendationsFooter: jest.fn(),
|
||||
}));
|
||||
jest.mock('@openedx/frontend-plugin-framework', () => ({
|
||||
PluginSlot: 'PluginSlot',
|
||||
}));
|
||||
|
||||
describe('WidgetSidebar', () => {
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
const props = {
|
||||
setSidebarShowing: jest.fn(),
|
||||
};
|
||||
|
||||
describe('snapshots', () => {
|
||||
test('default', () => {
|
||||
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
|
||||
mockFooterRecommendationsHook.activeControl,
|
||||
);
|
||||
const wrapper = shallow(<WidgetSidebar {...props} />);
|
||||
|
||||
expect(props.setSidebarShowing).toHaveBeenCalledWith(true);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
test('is hidden when the has the default values', () => {
|
||||
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
|
||||
mockFooterRecommendationsHook.default,
|
||||
);
|
||||
const wrapper = shallow(<WidgetSidebar {...props} />);
|
||||
|
||||
expect(props.setSidebarShowing).not.toHaveBeenCalled();
|
||||
expect(wrapper.shallowWrapper).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.shallowWrapper).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
import hooks from 'widgets/ProductRecommendations/hooks';
|
||||
|
||||
export const WidgetSidebar = ({ setSidebarShowing }) => {
|
||||
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
|
||||
|
||||
if (!inRecommendationsVariant && isExperimentActive) {
|
||||
setSidebarShowing(true);
|
||||
|
||||
return (
|
||||
<div className="widget-sidebar px-2">
|
||||
<div className="d-flex">
|
||||
<PluginSlot id="widget_sidebar_plugin_slot" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
WidgetSidebar.propTypes = {
|
||||
setSidebarShowing: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default WidgetSidebar;
|
||||
@@ -1,52 +0,0 @@
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import hooks from 'widgets/ProductRecommendations/hooks';
|
||||
import { mockFooterRecommendationsHook } from 'widgets/ProductRecommendations/testData';
|
||||
import WidgetSidebar from '.';
|
||||
|
||||
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
|
||||
jest.mock('widgets/ProductRecommendations/hooks', () => ({
|
||||
useShowRecommendationsFooter: jest.fn(),
|
||||
}));
|
||||
jest.mock('@openedx/frontend-plugin-framework', () => ({
|
||||
PluginSlot: 'PluginSlot',
|
||||
}));
|
||||
|
||||
describe('WidgetSidebar', () => {
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
const props = {
|
||||
setSidebarShowing: jest.fn(),
|
||||
};
|
||||
|
||||
describe('snapshots', () => {
|
||||
test('default', () => {
|
||||
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
|
||||
mockFooterRecommendationsHook.activeControl,
|
||||
);
|
||||
const wrapper = shallow(<WidgetSidebar {...props} />);
|
||||
|
||||
expect(props.setSidebarShowing).toHaveBeenCalledWith(true);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
test('is hidden when the has the default values', () => {
|
||||
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
|
||||
mockFooterRecommendationsHook.default,
|
||||
);
|
||||
const wrapper = shallow(<WidgetSidebar {...props} />);
|
||||
|
||||
expect(props.setSidebarShowing).not.toHaveBeenCalled();
|
||||
expect(wrapper.shallowWrapper).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.shallowWrapper).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`WidgetFooter snapshots default 1`] = `
|
||||
<div
|
||||
className="widget-footer"
|
||||
>
|
||||
<ProductRecommendations />
|
||||
</div>
|
||||
`;
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import ProductRecommendations from 'widgets/ProductRecommendations';
|
||||
import hooks from 'widgets/ProductRecommendations/hooks';
|
||||
|
||||
export const WidgetFooter = () => {
|
||||
hooks.useActivateRecommendationsExperiment();
|
||||
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
|
||||
|
||||
if (inRecommendationsVariant && isExperimentActive) {
|
||||
return (
|
||||
<div className="widget-footer">
|
||||
<ProductRecommendations />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default WidgetFooter;
|
||||
@@ -1,45 +0,0 @@
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import hooks from 'widgets/ProductRecommendations/hooks';
|
||||
import { mockFooterRecommendationsHook } from 'widgets/ProductRecommendations/testData';
|
||||
import WidgetFooter from '.';
|
||||
|
||||
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
|
||||
jest.mock('widgets/ProductRecommendations/hooks', () => ({
|
||||
useActivateRecommendationsExperiment: jest.fn(),
|
||||
useShowRecommendationsFooter: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('WidgetFooter', () => {
|
||||
describe('snapshots', () => {
|
||||
test('default', () => {
|
||||
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
|
||||
mockFooterRecommendationsHook.activeTreatment,
|
||||
);
|
||||
const wrapper = shallow(<WidgetFooter />);
|
||||
|
||||
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
test('is hidden when the experiment has the default values', () => {
|
||||
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
|
||||
mockFooterRecommendationsHook.default,
|
||||
);
|
||||
const wrapper = shallow(<WidgetFooter />);
|
||||
|
||||
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
|
||||
expect(wrapper.shallowWrapper).toBeNull();
|
||||
});
|
||||
|
||||
test('is hidden when the experiment has the control values', () => {
|
||||
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
|
||||
mockFooterRecommendationsHook.activeControl,
|
||||
);
|
||||
const wrapper = shallow(<WidgetFooter />);
|
||||
|
||||
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
|
||||
expect(wrapper.shallowWrapper).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`WidgetSidebar snapshots default 1`] = `
|
||||
exports[`WidgetSidebar snapshots 1`] = `
|
||||
<div
|
||||
className="widget-sidebar px-2"
|
||||
>
|
||||
23
src/containers/WidgetContainers/WidgetSidebar/index.jsx
Normal file
23
src/containers/WidgetContainers/WidgetSidebar/index.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
// eslint-disable-next-line arrow-body-style
|
||||
export const WidgetSidebar = () => {
|
||||
const hasCourses = reduxHooks.useHasCourses();
|
||||
|
||||
const widgetSidebarClassNames = classNames('widget-sidebar', { 'px-2': !hasCourses });
|
||||
const innerWrapperClassNames = classNames('d-flex', { 'flex-column': hasCourses });
|
||||
|
||||
return (
|
||||
<div className={widgetSidebarClassNames}>
|
||||
<div className={innerWrapperClassNames}>
|
||||
<PluginSlot id="widget_sidebar_plugin_slot" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WidgetSidebar;
|
||||
18
src/containers/WidgetContainers/WidgetSidebar/index.test.jsx
Normal file
18
src/containers/WidgetContainers/WidgetSidebar/index.test.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import WidgetSidebar from '.';
|
||||
|
||||
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
|
||||
|
||||
jest.mock('@openedx/frontend-plugin-framework', () => ({
|
||||
PluginSlot: 'PluginSlot',
|
||||
}));
|
||||
|
||||
describe('WidgetSidebar', () => {
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
test('snapshots', () => {
|
||||
const wrapper = shallow(<WidgetSidebar />);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -42,9 +42,7 @@ jest.unmock('react-redux');
|
||||
jest.unmock('reselect');
|
||||
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('containers/WidgetContainers/WidgetSidebar', () => jest.fn(() => 'widget-sidebar'));
|
||||
jest.mock('components/NoticesWrapper', () => 'notices-wrapper');
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
@@ -61,10 +59,6 @@ jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getLoginRedirectUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('ExperimentContext', () => ({
|
||||
ExperimentProvider: 'div'
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-enterprise-hotjar', () => ({
|
||||
initializeHotjar: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ProductRecommendations matches snapshot 1`] = `
|
||||
<LoadedView
|
||||
crossProductCourses={
|
||||
[
|
||||
{
|
||||
"courseRunKey": "course-v1:Test+Course+2022T2",
|
||||
"courseType": "executive-education-2u",
|
||||
"image": {
|
||||
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||
},
|
||||
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
|
||||
"owners": [
|
||||
{
|
||||
"key": "HarvardX",
|
||||
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||
"name": "Harvard University",
|
||||
},
|
||||
],
|
||||
"title": "Introduction to Computer Science",
|
||||
},
|
||||
{
|
||||
"courseRunKey": "course-v1:Test+Course+2022T2",
|
||||
"courseType": "bootcamp-2u",
|
||||
"image": {
|
||||
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||
},
|
||||
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
|
||||
"owners": [
|
||||
{
|
||||
"key": "HarvardX",
|
||||
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||
"name": "Harvard University",
|
||||
},
|
||||
],
|
||||
"title": "Introduction to Computer Science",
|
||||
},
|
||||
]
|
||||
}
|
||||
openCourses={
|
||||
[
|
||||
{
|
||||
"courseRunKey": "course-v1:Test+Course+2022T2",
|
||||
"courseType": "verified-audit",
|
||||
"image": {
|
||||
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||
},
|
||||
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
|
||||
"owners": [
|
||||
{
|
||||
"key": "HarvardX",
|
||||
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||
"name": "Harvard University",
|
||||
},
|
||||
],
|
||||
"title": "Introduction to Computer Science",
|
||||
},
|
||||
{
|
||||
"courseRunKey": "course-v1:Test+Course+2022T2",
|
||||
"courseType": "audit",
|
||||
"image": {
|
||||
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||
},
|
||||
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
|
||||
"owners": [
|
||||
{
|
||||
"key": "HarvardX",
|
||||
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||
"name": "Harvard University",
|
||||
},
|
||||
],
|
||||
"title": "Introduction to Computer Science",
|
||||
},
|
||||
{
|
||||
"courseRunKey": "course-v1:Test+Course+2022T2",
|
||||
"courseType": "verified",
|
||||
"image": {
|
||||
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||
},
|
||||
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
|
||||
"owners": [
|
||||
{
|
||||
"key": "HarvardX",
|
||||
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||
"name": "Harvard University",
|
||||
},
|
||||
],
|
||||
"title": "Introduction to Computer Science",
|
||||
},
|
||||
{
|
||||
"courseRunKey": "course-v1:Test+Course+2022T2",
|
||||
"courseType": "course",
|
||||
"image": {
|
||||
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||
},
|
||||
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
|
||||
"owners": [
|
||||
{
|
||||
"key": "HarvardX",
|
||||
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||
"name": "Harvard University",
|
||||
},
|
||||
],
|
||||
"title": "Introduction to Computer Science",
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
`;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { get, stringifyUrl } from 'data/services/lms/utils';
|
||||
import urls from 'data/services/lms/urls';
|
||||
|
||||
export const crossProductAndAmplitudeRecommendationsUrl = (courseId) => `${urls.getApiUrl()}/edx_recommendations/learner_dashboard/cross_product/${courseId}/`;
|
||||
export const amplitudeRecommendationsUrl = () => `${urls.getApiUrl()}/edx_recommendations/learner_dashboard/amplitude/v2/`;
|
||||
export const recommendationsContextUrl = () => `${urls.getApiUrl()}/edx_recommendations/learner_dashboard/recommendations_context/`;
|
||||
|
||||
const fetchRecommendationsContext = () => get(stringifyUrl(recommendationsContextUrl()));
|
||||
|
||||
const fetchCrossProductRecommendations = (courseId) => (
|
||||
get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl(courseId)))
|
||||
);
|
||||
const fetchAmplitudeRecommendations = () => get(stringifyUrl(amplitudeRecommendationsUrl()));
|
||||
|
||||
export default {
|
||||
fetchCrossProductRecommendations,
|
||||
fetchAmplitudeRecommendations,
|
||||
fetchRecommendationsContext,
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { get, stringifyUrl } from 'data/services/lms/utils';
|
||||
|
||||
import api, { crossProductAndAmplitudeRecommendationsUrl, amplitudeRecommendationsUrl, recommendationsContextUrl } from './api';
|
||||
|
||||
jest.mock('data/services/lms/utils', () => ({
|
||||
stringifyUrl: (...args) => ({ stringifyUrl: args }),
|
||||
get: (...args) => ({ get: args }),
|
||||
}));
|
||||
|
||||
describe('productRecommendationCourses api', () => {
|
||||
describe('fetchCrossProductRecommendations', () => {
|
||||
it('calls get with the correct recommendation courses URL', () => {
|
||||
expect(api.fetchCrossProductRecommendations('CourseRunKey')).toEqual(
|
||||
get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl('CourseRunKey'))),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAmplitudeRecommendations', () => {
|
||||
it('calls get with the correct recommendation courses URL', () => {
|
||||
expect(api.fetchAmplitudeRecommendations()).toEqual(
|
||||
get(stringifyUrl(amplitudeRecommendationsUrl())),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchRecommendationsContext', () => {
|
||||
it('calls get with the correct recommendation courses URL', () => {
|
||||
expect(api.fetchRecommendationsContext()).toEqual(
|
||||
get(stringifyUrl(recommendationsContextUrl())),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Container } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
import { courseShape, courseTypeToProductTypeMap } from '../utils';
|
||||
import ProductCardContainer from './ProductCardContainer';
|
||||
|
||||
const LoadedView = ({ crossProductCourses, openCourses }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const includesCrossProductTypes = crossProductCourses.length === 2;
|
||||
|
||||
const finalProductList = useMemo(() => {
|
||||
if (includesCrossProductTypes) {
|
||||
const openCourseList = openCourses.slice(0, 2);
|
||||
return crossProductCourses.concat(openCourseList);
|
||||
}
|
||||
return openCourses;
|
||||
}, [crossProductCourses, openCourses, includesCrossProductTypes]);
|
||||
|
||||
const courseTypes = [...new Set(finalProductList.map((item) => courseTypeToProductTypeMap[item.courseType]))];
|
||||
|
||||
return (
|
||||
<Container
|
||||
size="lg"
|
||||
className="recommendations-container pt-sm-5 pt-4.5 pb-2 pb-sm-4.5"
|
||||
>
|
||||
<h2>
|
||||
{formatMessage(messages.recommendationsHeading)}
|
||||
</h2>
|
||||
<ProductCardContainer finalProductList={finalProductList} courseTypes={courseTypes} />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
LoadedView.propTypes = {
|
||||
crossProductCourses: PropTypes.arrayOf(
|
||||
PropTypes.shape(courseShape),
|
||||
).isRequired,
|
||||
openCourses: PropTypes.arrayOf(
|
||||
PropTypes.shape(courseShape),
|
||||
).isRequired,
|
||||
};
|
||||
|
||||
export default LoadedView;
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import { mockCrossProductCourses, mockOpenCourses } from '../testData';
|
||||
import LoadedView from './LoadedView';
|
||||
|
||||
describe('ProductRecommendations LoadedView', () => {
|
||||
it('matches snapshot', () => {
|
||||
expect(
|
||||
shallow(
|
||||
<LoadedView
|
||||
crossProductCourses={mockCrossProductCourses}
|
||||
openCourses={mockOpenCourses}
|
||||
/>,
|
||||
).snapshot,
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
describe('with less than 2 cross product courses', () => {
|
||||
it('passes in one course type and 4 open courses to the ProductCardContainer props', () => {
|
||||
const wrapper = shallow(
|
||||
<LoadedView
|
||||
crossProductCourses={[mockCrossProductCourses[0]]}
|
||||
openCourses={mockOpenCourses}
|
||||
/>,
|
||||
);
|
||||
|
||||
const productCardContainerProps = wrapper.instance.findByType('ProductCardContainer')[0].props;
|
||||
|
||||
expect(productCardContainerProps.courseTypes.length).toEqual(1);
|
||||
expect(productCardContainerProps.courseTypes[0]).toEqual('Course');
|
||||
expect(productCardContainerProps.finalProductList).toEqual(mockOpenCourses);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Skeleton } from '@openedx/paragon';
|
||||
|
||||
export const LoadingView = () => (
|
||||
<Skeleton height={100} />
|
||||
);
|
||||
|
||||
export default LoadingView;
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import LoadingView from './LoadingView';
|
||||
|
||||
describe('ProductRecommendations LoadingView', () => {
|
||||
it('matches snapshot', () => {
|
||||
expect(shallow(<LoadingView />).snapshot).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Truncate,
|
||||
Hyperlink,
|
||||
} from '@openedx/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,
|
||||
}) => {
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
<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,
|
||||
};
|
||||
|
||||
export default ProductCard;
|
||||
@@ -1,83 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import { mockCrossProductCourses, mockOpenCourses, mockFallbackOpenCourse } from '../testData';
|
||||
import { trackProductCardClicked, trackCourseCardClicked } from '../optimizelyExperiment';
|
||||
import { productCardClicked, discoveryCardClicked } from '../track';
|
||||
import ProductCard from './ProductCard';
|
||||
import { courseTypeToProductTypeMap } from '../utils';
|
||||
|
||||
jest.mock('../optimizelyExperiment', () => ({
|
||||
trackProductCardClicked: jest.fn(),
|
||||
trackCourseCardClicked: jest.fn(),
|
||||
}));
|
||||
|
||||
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 {...crossProductProps} />).snapshot).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('has the query string parameter attached to a fallback recommendations url', () => {
|
||||
const wrapper = shallow(<ProductCard {...fallbackOpenCourseProps} />);
|
||||
const cardUrl = wrapper.instance.findByType('Card')[0].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.instance.props.onClick();
|
||||
|
||||
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.instance.props.onClick();
|
||||
|
||||
expect(trackProductCardClicked).toHaveBeenCalledWith('1');
|
||||
expect(productCardClicked).toHaveBeenCalledWith(courseRunKey, title, courseType, `${url}&linked_from=recommender`);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
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';
|
||||
|
||||
const ProductCardContainer = ({ finalProductList, courseTypes }) => (
|
||||
<div className="product-card-container d-flex">
|
||||
{finalProductList
|
||||
&& courseTypes.map((type) => (
|
||||
<div key={type}>
|
||||
<ProductCardHeader courseType={type} />
|
||||
<div
|
||||
className={classNames('d-flex', {
|
||||
'course-subcontainer': type === course,
|
||||
})}
|
||||
>
|
||||
{finalProductList
|
||||
.filter((courseObj) => courseTypeToProductTypeMap[courseObj.courseType] === type)
|
||||
.map((item) => (
|
||||
<ProductCard
|
||||
key={item.title}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
ProductCardContainer.propTypes = {
|
||||
finalProductList: PropTypes.arrayOf(
|
||||
PropTypes.shape(courseShape),
|
||||
).isRequired,
|
||||
courseTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
export default ProductCardContainer;
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import { mockCrossProductCourses, mockOpenCourses } from '../testData';
|
||||
import ProductCardContainer from './ProductCardContainer';
|
||||
import { executiveEducation, bootCamp, course } from '../constants';
|
||||
|
||||
describe('ProductRecommendations ProductCardContainer', () => {
|
||||
const props = {
|
||||
finalProductList: [...mockCrossProductCourses, ...mockOpenCourses],
|
||||
courseTypes: [executiveEducation, bootCamp, course],
|
||||
};
|
||||
|
||||
it('matches snapshot', () => {
|
||||
expect(shallow(<ProductCardContainer {...props} />).snapshot).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('with finalCourseList containing cross product and open courses', () => {
|
||||
it('renders 3 ProductCardHeaders with the 3 different course types', () => {
|
||||
const wrapper = shallow(<ProductCardContainer {...props} />);
|
||||
const productCardHeaders = wrapper.instance.findByType('ProductCardHeader');
|
||||
|
||||
expect(productCardHeaders.length).toEqual(3);
|
||||
productCardHeaders.forEach((header, index) => {
|
||||
expect(header.props.courseType).toEqual(props.courseTypes[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with finalCourseList containing only open courses', () => {
|
||||
it('renders 1 ProductHeader with the one course type', () => {
|
||||
const openCoursesProps = {
|
||||
finalProductList: [...mockOpenCourses, ...mockOpenCourses],
|
||||
courseTypes: ['Course'],
|
||||
};
|
||||
|
||||
const wrapper = shallow(<ProductCardContainer {...openCoursesProps} />);
|
||||
const productCardHeaders = wrapper.instance.findByType('ProductCardHeader');
|
||||
|
||||
expect(productCardHeaders.length).toEqual(1);
|
||||
expect(productCardHeaders[0].props.courseType).toEqual(openCoursesProps.courseTypes[0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
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 '@openedx/paragon';
|
||||
import { ChevronRight } from '@openedx/paragon/icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { trackProductHeaderClicked } from '../optimizelyExperiment';
|
||||
import { recommendationsHeaderClicked } from '../track';
|
||||
import { executiveEducation, bootCamp } from '../constants';
|
||||
import messages from '../messages';
|
||||
|
||||
const ProductCardHeader = ({ courseType }) => {
|
||||
const getProductTypeDetail = (type) => {
|
||||
switch (type) {
|
||||
case executiveEducation:
|
||||
return {
|
||||
heading: messages.executiveEducationHeading,
|
||||
description: messages.executiveEducationDescription,
|
||||
url: '/executive-education?linked_from=recommender',
|
||||
};
|
||||
case bootCamp:
|
||||
return {
|
||||
heading: messages.bootcampHeading,
|
||||
description: messages.bootcampDescription,
|
||||
url: '/boot-camps?linked_from=recommender',
|
||||
};
|
||||
default: {
|
||||
return {
|
||||
heading: messages.courseHeading,
|
||||
description: messages.courseDescription,
|
||||
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 = `${getConfig().MARKETING_SITE_BASE_URL}${productTypeDetail.url}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Hyperlink
|
||||
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">
|
||||
{formatMessage(productTypeDetail.heading)}
|
||||
</h3>
|
||||
<Icon src={ChevronRight} className="text-primary-500 ml-2.5" />
|
||||
</div>
|
||||
</Hyperlink>
|
||||
<p className="text-gray-500 x-small mt-2 mb-2">
|
||||
{formatMessage(productTypeDetail.description)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCardHeader.propTypes = {
|
||||
courseType: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ProductCardHeader;
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
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 coursesType = 'Courses';
|
||||
|
||||
it('matches snapshot', () => {
|
||||
expect(shallow(<ProductCardHeader courseType={executiveEducation} />).snapshot).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('with bootcamp courseType prop', () => {
|
||||
it('renders a bootcamp header', () => {
|
||||
const wrapper = shallow(<ProductCardHeader courseType={bootCamp} />);
|
||||
|
||||
expect(wrapper.instance.findByType('h3')[0].children[0].el).toEqual(bootCamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with courses courseType prop', () => {
|
||||
it('renders a courses header', () => {
|
||||
const wrapper = shallow(<ProductCardHeader courseType={coursesType} />);
|
||||
|
||||
expect(wrapper.instance.findByType('h3')[0].children[0].el).toEqual(coursesType);
|
||||
});
|
||||
});
|
||||
|
||||
it('send outs experiment events when clicked', () => {
|
||||
const wrapper = shallow(<ProductCardHeader courseType={executiveEducation} />);
|
||||
const hyperLink = wrapper.instance.findByType('Hyperlink')[0];
|
||||
const execEdLink = 'http://localhost:18000/executive-education?linked_from=recommender';
|
||||
|
||||
hyperLink.props.onClick();
|
||||
|
||||
expect(trackProductHeaderClicked).toHaveBeenCalledWith('1');
|
||||
expect(recommendationsHeaderClicked).toHaveBeenCalledWith(executiveEducation, execEdLink);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ProductRecommendations LoadedView matches snapshot 1`] = `
|
||||
<Container
|
||||
className="recommendations-container pt-sm-5 pt-4.5 pb-2 pb-sm-4.5"
|
||||
size="lg"
|
||||
>
|
||||
<h2>
|
||||
You might also like
|
||||
</h2>
|
||||
<ProductCardContainer
|
||||
courseTypes={
|
||||
[
|
||||
"Executive Education",
|
||||
"Boot Camp",
|
||||
"Course",
|
||||
]
|
||||
}
|
||||
finalProductList={
|
||||
[
|
||||
{
|
||||
"courseRunKey": "course-v1:Test+Course+2022T2",
|
||||
"courseType": "executive-education-2u",
|
||||
"image": {
|
||||
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||
},
|
||||
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
|
||||
"owners": [
|
||||
{
|
||||
"key": "HarvardX",
|
||||
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||
"name": "Harvard University",
|
||||
},
|
||||
],
|
||||
"title": "Introduction to Computer Science",
|
||||
},
|
||||
{
|
||||
"courseRunKey": "course-v1:Test+Course+2022T2",
|
||||
"courseType": "bootcamp-2u",
|
||||
"image": {
|
||||
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||
},
|
||||
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
|
||||
"owners": [
|
||||
{
|
||||
"key": "HarvardX",
|
||||
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||
"name": "Harvard University",
|
||||
},
|
||||
],
|
||||
"title": "Introduction to Computer Science",
|
||||
},
|
||||
{
|
||||
"courseRunKey": "course-v1:Test+Course+2022T2",
|
||||
"courseType": "verified-audit",
|
||||
"image": {
|
||||
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||
},
|
||||
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
|
||||
"owners": [
|
||||
{
|
||||
"key": "HarvardX",
|
||||
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||
"name": "Harvard University",
|
||||
},
|
||||
],
|
||||
"title": "Introduction to Computer Science",
|
||||
},
|
||||
{
|
||||
"courseRunKey": "course-v1:Test+Course+2022T2",
|
||||
"courseType": "audit",
|
||||
"image": {
|
||||
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||
},
|
||||
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
|
||||
"owners": [
|
||||
{
|
||||
"key": "HarvardX",
|
||||
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||
"name": "Harvard University",
|
||||
},
|
||||
],
|
||||
"title": "Introduction to Computer Science",
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
`;
|
||||
@@ -1,7 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ProductRecommendations LoadingView matches snapshot 1`] = `
|
||||
<Skeleton
|
||||
height={100}
|
||||
/>
|
||||
`;
|
||||
@@ -1,49 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ProductRecommendations ProductCard matches snapshot 1`] = `
|
||||
<Card
|
||||
as="Hyperlink"
|
||||
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"
|
||||
logoSrc="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||
src="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||
srcAlt="header image for Introduction to Computer Science"
|
||||
/>
|
||||
<Card.Header
|
||||
className="mt-2"
|
||||
subtitle={
|
||||
<Truncate
|
||||
className="product-card-subtitle font-weight-normal"
|
||||
lines={1}
|
||||
>
|
||||
Harvard University
|
||||
</Truncate>
|
||||
}
|
||||
title={
|
||||
<Truncate
|
||||
className="product-card-title font-weight-bold"
|
||||
ellipsis="…"
|
||||
lines={3}
|
||||
>
|
||||
Introduction to Computer Science
|
||||
</Truncate>
|
||||
}
|
||||
/>
|
||||
<Card.Section>
|
||||
<div
|
||||
className="product-badge position-absolute"
|
||||
>
|
||||
<Badge
|
||||
className="bg-light-500 text-dark-500"
|
||||
>
|
||||
Executive Education
|
||||
</Badge>
|
||||
</div>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
`;
|
||||
@@ -1,101 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ProductRecommendations ProductCardContainer matches snapshot 1`] = `
|
||||
<div
|
||||
className="product-card-container d-flex"
|
||||
>
|
||||
<div
|
||||
key="Executive Education"
|
||||
>
|
||||
<ProductCardHeader
|
||||
courseType="Executive Education"
|
||||
/>
|
||||
<div
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
key="Boot Camp"
|
||||
>
|
||||
<ProductCardHeader
|
||||
courseType="Boot Camp"
|
||||
/>
|
||||
<div
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
key="Course"
|
||||
>
|
||||
<ProductCardHeader
|
||||
courseType="Course"
|
||||
/>
|
||||
<div
|
||||
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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,30 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ProductRecommendations ProductCardHeader matches snapshot 1`] = `
|
||||
<div>
|
||||
<Hyperlink
|
||||
className="base-card-link text-decoration-none"
|
||||
destination="http://localhost:18000/executive-education?linked_from=recommender"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div
|
||||
className="d-flex align-items-center border-bottom"
|
||||
>
|
||||
<h3
|
||||
className="h3 mb-2 text-left"
|
||||
>
|
||||
Executive Education
|
||||
</h3>
|
||||
<Icon
|
||||
className="text-primary-500 ml-2.5"
|
||||
src={[MockFunction icons.ChevronRight]}
|
||||
/>
|
||||
</div>
|
||||
</Hyperlink>
|
||||
<p
|
||||
className="text-gray-500 x-small mt-2 mb-2"
|
||||
>
|
||||
Short Courses to develop leadership skills
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,7 +0,0 @@
|
||||
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,161 +0,0 @@
|
||||
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 '@openedx/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';
|
||||
|
||||
export const state = StrictDict({
|
||||
requestState: (val) => useState(val), // eslint-disable-line
|
||||
data: (val) => useState(val), // eslint-disable-line
|
||||
});
|
||||
|
||||
export const useIsMobile = () => {
|
||||
const { width } = useWindowSize();
|
||||
return width < breakpoints.small.minWidth;
|
||||
};
|
||||
|
||||
export const useMostRecentCourseRunKey = () => {
|
||||
const mostRecentCourseRunKey = reduxHooks.useCurrentCourseList({
|
||||
sortBy: SortKeys.enrolled,
|
||||
filters: [],
|
||||
pageSize: 0,
|
||||
}).visible[0]?.courseRun?.courseId;
|
||||
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const handleSuccess = (response) => {
|
||||
if (isMounted) {
|
||||
setData(response.data);
|
||||
setRequestState(RequestStates.completed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
if (isMounted) {
|
||||
setRequestState(RequestStates.failed);
|
||||
}
|
||||
};
|
||||
|
||||
if (courseRunKey) {
|
||||
api
|
||||
.fetchCrossProductRecommendations(courseRunKey)
|
||||
.then(handleSuccess)
|
||||
.catch(handleError);
|
||||
} else {
|
||||
api
|
||||
.fetchAmplitudeRecommendations()
|
||||
.then(handleSuccess)
|
||||
.catch(handleError);
|
||||
}
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
/* eslint-disable */
|
||||
}, []);
|
||||
};
|
||||
|
||||
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,
|
||||
isLoading: requestState === RequestStates.pending,
|
||||
isLoaded: requestState === RequestStates.completed,
|
||||
hasFailed: requestState === RequestStates.failed
|
||||
};
|
||||
};
|
||||
|
||||
export default { useProductRecommendationsData, useShowRecommendationsFooter, useIsMobile, useActivateRecommendationsExperiment };
|
||||
@@ -1,548 +0,0 @@
|
||||
import React from 'react';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import { MockUseState } from 'testUtils';
|
||||
import { RequestStates } from 'data/constants/requests';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useWindowSize } from '@openedx/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';
|
||||
|
||||
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(),
|
||||
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';
|
||||
|
||||
const courses = [
|
||||
{
|
||||
courseRun: {
|
||||
courseId: mostRecentCourseRunKey,
|
||||
},
|
||||
},
|
||||
{
|
||||
courseRun: {
|
||||
courseId: 'course ID 2',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const populatedCourseListData = {
|
||||
visible: courses,
|
||||
numPages: 0,
|
||||
};
|
||||
|
||||
const emptyCourseListData = {
|
||||
visible: [],
|
||||
numPages: 0,
|
||||
};
|
||||
|
||||
let output;
|
||||
describe('ProductRecommendations hooks', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
getAuthenticatedUser.mockImplementation(() => ({ userId: '1' }));
|
||||
});
|
||||
|
||||
describe('state fields', () => {
|
||||
state.testGetter(state.keys.requestState);
|
||||
state.testGetter(state.keys.data);
|
||||
});
|
||||
|
||||
describe('useMostRecentCourseRunKey', () => {
|
||||
it('returns the courseId of the first course in the sorted visible array', () => {
|
||||
reduxHooks.useCurrentCourseList.mockReturnValueOnce(populatedCourseListData);
|
||||
|
||||
expect(hooks.useMostRecentCourseRunKey()).toBe(mostRecentCourseRunKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useIsMobile', () => {
|
||||
it('returns false if the width of the window is greater than or equal to 576px', () => {
|
||||
useWindowSize
|
||||
.mockReturnValueOnce({ width: 576, height: 943 })
|
||||
.mockReturnValueOnce({ width: 1400, height: 943 });
|
||||
|
||||
expect(hooks.useIsMobile()).toBeFalsy();
|
||||
expect(hooks.useIsMobile()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('returns true if the width of the window is less than 576px', () => {
|
||||
useWindowSize.mockReturnValueOnce({ width: 575, height: 943 });
|
||||
|
||||
expect(hooks.useIsMobile()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useShowRecommendationsFooter', () => {
|
||||
it('returns the experiment object, stating if the experiment has activated and the variant', () => {
|
||||
useExperimentContext
|
||||
.mockImplementationOnce(() => ({ experiment: { inRecommendationsVariant: true, isExperimentActive: false } }));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useFetchRecommendations', () => {
|
||||
describe('behavior', () => {
|
||||
describe('useEffect call', () => {
|
||||
let calls;
|
||||
let cb;
|
||||
const response = { data: 'response data' };
|
||||
const setRequestState = jest.fn();
|
||||
const setData = jest.fn();
|
||||
|
||||
const setUp = (mockCourseListData) => {
|
||||
reduxHooks.useCurrentCourseList.mockReturnValue(mockCourseListData);
|
||||
hooks.useFetchRecommendations(setRequestState, setData);
|
||||
({ calls } = React.useEffect.mock);
|
||||
([[cb]] = calls);
|
||||
};
|
||||
|
||||
it('calls useEffect once', () => {
|
||||
setUp(populatedCourseListData);
|
||||
expect(calls.length).toEqual(1);
|
||||
});
|
||||
describe('without no courseId due to no enrolled courses', () => {
|
||||
it('calls fetchAmplitudeRecommendations', () => {
|
||||
setUp(emptyCourseListData);
|
||||
api.fetchAmplitudeRecommendations.mockReturnValueOnce(Promise.resolve(response));
|
||||
cb();
|
||||
expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('with most recently enrolled courseId', () => {
|
||||
it('calls fetchCrossProductRecommendations with the most recently enrolled courseId', () => {
|
||||
setUp(populatedCourseListData);
|
||||
api.fetchCrossProductRecommendations.mockReturnValueOnce(Promise.resolve(response));
|
||||
cb();
|
||||
expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
|
||||
});
|
||||
});
|
||||
describe('fetching cross product recommendations', () => {
|
||||
beforeEach(() => setUp(populatedCourseListData));
|
||||
|
||||
describe('successful fetch on mounted component', () => {
|
||||
it('sets the request state to completed and loads response', async () => {
|
||||
let resolveFn;
|
||||
api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise(resolve => {
|
||||
resolveFn = resolve;
|
||||
}));
|
||||
cb();
|
||||
expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
resolveFn(response);
|
||||
await waitFor(() => {
|
||||
expect(setRequestState).toHaveBeenCalledWith(RequestStates.completed);
|
||||
expect(setData).toHaveBeenCalledWith(response.data);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('successful fetch on unmounted component', () => {
|
||||
it('does not set the state', async () => {
|
||||
let resolveFn;
|
||||
api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise(resolve => {
|
||||
resolveFn = resolve;
|
||||
}));
|
||||
const unMount = cb();
|
||||
expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
unMount();
|
||||
resolveFn(response);
|
||||
await wait(10);
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('unsuccessful fetch on mounted component', () => {
|
||||
it('sets the request state to failed and does not set the data state', async () => {
|
||||
let rejectFn;
|
||||
api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
|
||||
rejectFn = reject;
|
||||
}));
|
||||
cb();
|
||||
expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
rejectFn();
|
||||
await waitFor(() => {
|
||||
expect(setRequestState).toHaveBeenCalledWith(RequestStates.failed);
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('unsuccessful fetch on unmounted component', () => {
|
||||
it('does not set the state', async () => {
|
||||
let rejectFn;
|
||||
api.fetchCrossProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
|
||||
rejectFn = reject;
|
||||
}));
|
||||
const unMount = cb();
|
||||
expect(api.fetchCrossProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
unMount();
|
||||
rejectFn();
|
||||
await wait(10);
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('fetching Amplitude recommendations', () => {
|
||||
beforeEach(() => setUp(emptyCourseListData));
|
||||
|
||||
describe('successful fetch on mounted component', () => {
|
||||
it('sets the request state to completed and loads response', async () => {
|
||||
let resolveFn;
|
||||
api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise(resolve => {
|
||||
resolveFn = resolve;
|
||||
}));
|
||||
cb();
|
||||
expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled();
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
resolveFn(response);
|
||||
await waitFor(() => {
|
||||
expect(setRequestState).toHaveBeenCalledWith(RequestStates.completed);
|
||||
expect(setData).toHaveBeenCalledWith(response.data);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('successful fetch on unmounted component', () => {
|
||||
it('does not set the state', async () => {
|
||||
let resolveFn;
|
||||
api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise(resolve => {
|
||||
resolveFn = resolve;
|
||||
}));
|
||||
const unMount = cb();
|
||||
expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled();
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
unMount();
|
||||
resolveFn(response);
|
||||
await wait(10);
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('unsuccessful fetch on mounted component', () => {
|
||||
it('sets the request state to failed and does not set the data state', async () => {
|
||||
let rejectFn;
|
||||
api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
|
||||
rejectFn = reject;
|
||||
}));
|
||||
cb();
|
||||
expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled();
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
rejectFn();
|
||||
await waitFor(() => {
|
||||
expect(setRequestState).toHaveBeenCalledWith(RequestStates.failed);
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('unsuccessful fetch on unmounted component', () => {
|
||||
it('does not set the state', async () => {
|
||||
let rejectFn;
|
||||
api.fetchAmplitudeRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
|
||||
rejectFn = reject;
|
||||
}));
|
||||
const unMount = cb();
|
||||
expect(api.fetchAmplitudeRecommendations).toHaveBeenCalled();
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
unMount();
|
||||
rejectFn();
|
||||
await wait(10);
|
||||
expect(setRequestState).not.toHaveBeenCalled();
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('useProductRecommendationsData', () => {
|
||||
let fetchRecommendationsSpy;
|
||||
let sendViewedEventsSpy;
|
||||
beforeEach(() => {
|
||||
state.mock();
|
||||
fetchRecommendationsSpy = jest.spyOn(hooks, 'useFetchRecommendations').mockImplementationOnce(() => {});
|
||||
sendViewedEventsSpy = jest.spyOn(hooks, 'useSendViewedEvents').mockImplementationOnce(() => {});
|
||||
output = hooks.useProductRecommendationsData();
|
||||
});
|
||||
it('calls useFetchRecommendations with setRequestState and setData', () => {
|
||||
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);
|
||||
});
|
||||
describe('return values', () => {
|
||||
describe('when the request is completed, with returned response object', () => {
|
||||
const mockResponse = { crossProductCourses: {}, amplitudeCourses: {} };
|
||||
beforeEach(() => {
|
||||
state.mockVal(state.keys.requestState, RequestStates.completed);
|
||||
state.mockVal(state.keys.data, mockResponse);
|
||||
output = hooks.useProductRecommendationsData();
|
||||
});
|
||||
it('is not loading', () => {
|
||||
expect(output.isLoading).toEqual(false);
|
||||
});
|
||||
it('is loaded', () => {
|
||||
expect(output.isLoaded).toEqual(true);
|
||||
});
|
||||
it('has not failed', () => {
|
||||
expect(output.hasFailed).toEqual(false);
|
||||
});
|
||||
it('returns country code', () => {
|
||||
expect(output.productRecommendations).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
describe('when the request is pending', () => {
|
||||
beforeEach(() => {
|
||||
state.mockVal(state.keys.requestState, RequestStates.pending);
|
||||
state.mockVal(state.keys.data, {});
|
||||
output = hooks.useProductRecommendationsData();
|
||||
});
|
||||
it('is loading', () => {
|
||||
expect(output.isLoading).toEqual(true);
|
||||
});
|
||||
it('is not loaded', () => {
|
||||
expect(output.isLoaded).toEqual(false);
|
||||
});
|
||||
it('has not failed', () => {
|
||||
expect(output.hasFailed).toEqual(false);
|
||||
});
|
||||
it('returns empty object', () => {
|
||||
expect(output.productRecommendations).toEqual({});
|
||||
});
|
||||
});
|
||||
describe('when the request has failed', () => {
|
||||
beforeEach(() => {
|
||||
state.mockVal(state.keys.requestState, RequestStates.failed);
|
||||
state.mockVal(state.keys.data, {});
|
||||
output = hooks.useProductRecommendationsData();
|
||||
});
|
||||
it('is not loading', () => {
|
||||
expect(output.isLoading).toEqual(false);
|
||||
});
|
||||
it('is not loaded', () => {
|
||||
expect(output.isLoaded).toEqual(false);
|
||||
});
|
||||
it('has failed', () => {
|
||||
expect(output.hasFailed).toEqual(true);
|
||||
});
|
||||
it('returns empty object', () => {
|
||||
expect(output.productRecommendations).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import './index.scss';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import NoCoursesView from 'containers/CoursesPanel/NoCoursesView';
|
||||
import LoadingView from './components/LoadingView';
|
||||
import LoadedView from './components/LoadedView';
|
||||
import hooks from './hooks';
|
||||
|
||||
const ProductRecommendations = () => {
|
||||
const checkEmptyResponse = (obj) => {
|
||||
const values = Object.values(obj);
|
||||
const result = values.filter((item) => item.length === 0);
|
||||
return result.length === values.length;
|
||||
};
|
||||
|
||||
const { productRecommendations, isLoading, isLoaded } = hooks.useProductRecommendationsData();
|
||||
const isMobile = hooks.useIsMobile();
|
||||
const hasCourses = reduxHooks.useHasCourses();
|
||||
const shouldShowPlaceholder = checkEmptyResponse(productRecommendations);
|
||||
|
||||
if (isLoading && !isMobile) {
|
||||
return <LoadingView />;
|
||||
}
|
||||
|
||||
if (isLoaded && !isMobile && !shouldShowPlaceholder) {
|
||||
return (
|
||||
<LoadedView
|
||||
openCourses={productRecommendations.amplitudeCourses}
|
||||
crossProductCourses={productRecommendations.crossProductCourses || []}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoaded && hasCourses && !isMobile && shouldShowPlaceholder) {
|
||||
return <NoCoursesView />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ProductRecommendations;
|
||||
@@ -1,73 +0,0 @@
|
||||
@import "@openedx/paragon/scss/core/core";
|
||||
|
||||
$horizontal-card-gap: 20px;
|
||||
$vertical-card-gap: 24px;
|
||||
|
||||
.base-card {
|
||||
height: 332px;
|
||||
width: 270px !important;
|
||||
|
||||
.pgn__card-image-cap {
|
||||
height: 104px;
|
||||
object: {
|
||||
fit: cover;
|
||||
position: top center;
|
||||
}
|
||||
}
|
||||
|
||||
.pgn__card-logo-cap {
|
||||
bottom: -1.5rem;
|
||||
object: {
|
||||
fit: scale-down;
|
||||
position: center center;
|
||||
}
|
||||
}
|
||||
|
||||
.product-card-title {
|
||||
font: {
|
||||
size: 1.125rem;
|
||||
}
|
||||
|
||||
line-height: 24px ;
|
||||
}
|
||||
|
||||
.product-card-subtitle {
|
||||
font: {
|
||||
size: 0.875rem;
|
||||
}
|
||||
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.product-badge {
|
||||
bottom: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.product-card-container {
|
||||
gap: $vertical-card-gap $horizontal-card-gap;
|
||||
margin: 0 (-$horizontal-card-gap);
|
||||
padding: 1rem $horizontal-card-gap;
|
||||
|
||||
.course-subcontainer {
|
||||
gap: $vertical-card-gap $horizontal-card-gap;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(lg) {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround for giving the sub-container a greyish background that stretches to the full width of the browser window
|
||||
// while being placed within the boundaries of the parent container's dimensions
|
||||
.recommendations-container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
background-color: $light-200;
|
||||
z-index: -1;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import hooks from './hooks';
|
||||
import ProductRecommendations from './index';
|
||||
import LoadingView from './components/LoadingView';
|
||||
import LoadedView from './components/LoadedView';
|
||||
import NoCoursesView from '../../containers/CoursesPanel/NoCoursesView';
|
||||
import { mockCrossProductResponse, mockAmplitudeResponse } from './testData';
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
useProductRecommendationsData: jest.fn(),
|
||||
useIsMobile: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useHasCourses: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./components/LoadingView', () => 'LoadingView');
|
||||
jest.mock('./components/LoadedView', () => 'LoadedView');
|
||||
jest.mock('containers/CoursesPanel/NoCoursesView', () => 'NoCoursesView');
|
||||
|
||||
describe('ProductRecommendations', () => {
|
||||
const defaultValues = {
|
||||
productRecommendations: {},
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
hasFailed: false,
|
||||
};
|
||||
|
||||
const successfulLoadValues = {
|
||||
...defaultValues,
|
||||
isLoaded: true,
|
||||
productRecommendations: mockCrossProductResponse,
|
||||
};
|
||||
|
||||
beforeEach(() => reduxHooks.useHasCourses.mockReturnValueOnce(true));
|
||||
|
||||
it('matches snapshot', () => {
|
||||
hooks.useIsMobile.mockReturnValueOnce(false);
|
||||
hooks.useProductRecommendationsData.mockReturnValueOnce({
|
||||
...successfulLoadValues,
|
||||
});
|
||||
|
||||
expect(shallow(<ProductRecommendations />).snapshot).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders the LoadingView if the request is pending', () => {
|
||||
hooks.useIsMobile.mockReturnValueOnce(false);
|
||||
hooks.useProductRecommendationsData.mockReturnValueOnce({
|
||||
...defaultValues,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
expect({ ...shallow(<ProductRecommendations />).shallowWrapper, children: expect.any(Array) }).toMatchObject(
|
||||
shallow(<LoadingView />),
|
||||
);
|
||||
});
|
||||
it('renders nothing if the request has failed', () => {
|
||||
hooks.useIsMobile.mockReturnValueOnce(false);
|
||||
hooks.useProductRecommendationsData.mockReturnValueOnce({
|
||||
...defaultValues,
|
||||
hasFailed: true,
|
||||
});
|
||||
|
||||
const wrapper = shallow(<ProductRecommendations />);
|
||||
|
||||
expect(wrapper.shallowWrapper).toBeNull();
|
||||
});
|
||||
it('renders nothing if the user is on the mobile view', () => {
|
||||
hooks.useIsMobile.mockReturnValueOnce(true);
|
||||
hooks.useProductRecommendationsData.mockReturnValueOnce({
|
||||
...successfulLoadValues,
|
||||
});
|
||||
|
||||
const wrapper = shallow(<ProductRecommendations />);
|
||||
|
||||
expect(wrapper.shallowWrapper).toBeNull();
|
||||
});
|
||||
|
||||
it('renders NoCoursesView if the request is loaded, user has courses, and the response is empty', () => {
|
||||
hooks.useIsMobile.mockReturnValueOnce(false);
|
||||
hooks.useProductRecommendationsData.mockReturnValueOnce({
|
||||
...successfulLoadValues,
|
||||
productRecommendations: {
|
||||
amplitudeCourses: [],
|
||||
crossProductCourses: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect({ ...shallow(<ProductRecommendations />).shallowWrapper, children: expect.any(Array) }).toMatchObject(
|
||||
shallow(<NoCoursesView />),
|
||||
);
|
||||
});
|
||||
|
||||
describe('LoadedView', () => {
|
||||
it('renders with cross product data if the request completed and the user has courses', () => {
|
||||
hooks.useIsMobile.mockReturnValueOnce(false);
|
||||
hooks.useProductRecommendationsData.mockReturnValueOnce({
|
||||
...successfulLoadValues,
|
||||
});
|
||||
|
||||
expect({ ...shallow(<ProductRecommendations />).shallowWrapper, children: expect.any(Array) }).toMatchObject(
|
||||
shallow(
|
||||
<LoadedView
|
||||
openCourses={mockCrossProductResponse.amplitudeCourses}
|
||||
crossProductCourses={mockCrossProductResponse.crossProductCourses}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the LoadedView with Amplitude course data if the request completed', () => {
|
||||
hooks.useIsMobile.mockReturnValueOnce(false);
|
||||
hooks.useProductRecommendationsData.mockReturnValueOnce({
|
||||
...successfulLoadValues,
|
||||
productRecommendations: mockAmplitudeResponse,
|
||||
});
|
||||
|
||||
expect({ ...shallow(<ProductRecommendations />).shallowWrapper, children: expect.any(Array) }).toMatchObject(
|
||||
shallow(
|
||||
<LoadedView
|
||||
openCourses={mockCrossProductResponse.amplitudeCourses}
|
||||
crossProductCourses={[]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
recommendationsHeading: {
|
||||
id: 'ProductRecommendations.recommendationsHeading',
|
||||
defaultMessage: 'You might also like',
|
||||
description: 'Title for a list of recommended courses',
|
||||
},
|
||||
executiveEducationHeading: {
|
||||
id: 'ProductRecommendations.executiveEducationHeading',
|
||||
defaultMessage: 'Executive Education',
|
||||
description: 'Heading for an executive education course recommendation',
|
||||
},
|
||||
executiveEducationDescription: {
|
||||
id: 'ProductRecommendations.executiveEducationDescription',
|
||||
defaultMessage: 'Short Courses to develop leadership skills',
|
||||
description: 'Short description of an executive education course',
|
||||
},
|
||||
bootcampHeading: {
|
||||
id: 'ProductRecommendations.bootcampHeading',
|
||||
defaultMessage: 'Boot Camp',
|
||||
description: 'Heading for a bootcamp course recommendation',
|
||||
},
|
||||
bootcampDescription: {
|
||||
id: 'ProductRecommendations.bootcampDescription',
|
||||
defaultMessage: 'Intensive, hands-on, project based training',
|
||||
description: 'Short description of a bootcamp course',
|
||||
},
|
||||
courseHeading: {
|
||||
id: 'ProductRecommendations.courseHeading',
|
||||
defaultMessage: 'Courses',
|
||||
description: 'Heading for an open course recommendation',
|
||||
},
|
||||
courseDescription: {
|
||||
id: 'ProductRecommendations.courseDescription',
|
||||
defaultMessage: 'Find new interests and advance your career',
|
||||
description: 'Heading for an open course recommendation',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createInstance, setLogLevel } from '@optimizely/react-sdk';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
const OPTIMIZELY_SDK_KEY = getConfig().OPTIMIZELY_FULL_STACK_SDK_KEY;
|
||||
|
||||
const configureClient = () => {
|
||||
setLogLevel('error');
|
||||
|
||||
return createInstance({
|
||||
sdkKey: OPTIMIZELY_SDK_KEY,
|
||||
});
|
||||
};
|
||||
|
||||
const optimizelyClient = configureClient();
|
||||
|
||||
export default optimizelyClient;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createInstance, setLogLevel } from '@optimizely/react-sdk';
|
||||
import optimizelyClient from './optimizely';
|
||||
|
||||
jest.mock('@optimizely/react-sdk', () => ({
|
||||
createInstance: jest.fn(() => 'mockedClient'),
|
||||
setLogLevel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: jest.fn(() => ({ OPTIMIZELY_FULL_STACK_SDK_KEY: 'SDK Key' })),
|
||||
}));
|
||||
|
||||
describe('optimizelyClient', () => {
|
||||
it('should configure an Optimizely client instance with the correct SDK key', () => {
|
||||
expect(optimizelyClient).toBeDefined();
|
||||
expect(setLogLevel).toHaveBeenCalledWith('error');
|
||||
expect(createInstance).toHaveBeenCalledWith({ sdkKey: 'SDK Key' });
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { StrictDict } from 'utils';
|
||||
import optimizelyClient from './optimizely';
|
||||
|
||||
export const PRODUCT_RECOMMENDATIONS_EXP_KEY = 'learner_dashboard_product_recommendations_exp';
|
||||
export const PRODUCT_RECOMMENDATIONS_EXP_VARIATION = 'learner_dashboard_product_recommendations_enabled';
|
||||
|
||||
export const eventNames = StrictDict({
|
||||
productRecommendationsViewed: 'product_recommendations_viewed',
|
||||
productHeaderClicked: 'product_header_clicked',
|
||||
productCardClicked: 'product_card_clicked',
|
||||
courseCardClicked: 'course_card_clicked',
|
||||
});
|
||||
|
||||
export const activateProductRecommendationsExperiment = (userId, userAttributes) => {
|
||||
const variation = optimizelyClient?.activate(
|
||||
PRODUCT_RECOMMENDATIONS_EXP_KEY,
|
||||
userId,
|
||||
userAttributes,
|
||||
);
|
||||
|
||||
return {
|
||||
experimentActivated: variation !== null,
|
||||
inExperimentVariant: variation === PRODUCT_RECOMMENDATIONS_EXP_VARIATION,
|
||||
};
|
||||
};
|
||||
|
||||
export const trackProductRecommendationsViewed = (userId, userAttributes = {}) => {
|
||||
optimizelyClient.track(eventNames.productRecommendationsViewed, userId, userAttributes);
|
||||
};
|
||||
|
||||
export const trackProductHeaderClicked = (userId, userAttributes = {}) => {
|
||||
optimizelyClient.track(eventNames.productHeaderClicked, userId, userAttributes);
|
||||
};
|
||||
|
||||
export const trackProductCardClicked = (userId, userAttributes = {}) => {
|
||||
optimizelyClient.track(eventNames.productCardClicked, userId, userAttributes);
|
||||
};
|
||||
|
||||
export const trackCourseCardClicked = (userId, userAttributes = {}) => {
|
||||
optimizelyClient.track(eventNames.courseCardClicked, userId, userAttributes);
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
import optimizelyClient from './optimizely';
|
||||
import {
|
||||
eventNames,
|
||||
PRODUCT_RECOMMENDATIONS_EXP_KEY,
|
||||
PRODUCT_RECOMMENDATIONS_EXP_VARIATION,
|
||||
activateProductRecommendationsExperiment,
|
||||
trackProductRecommendationsViewed,
|
||||
trackProductHeaderClicked,
|
||||
trackProductCardClicked,
|
||||
trackCourseCardClicked,
|
||||
} from './optimizelyExperiment';
|
||||
|
||||
jest.mock('./optimizely', () => ({
|
||||
activate: jest.fn(),
|
||||
track: jest.fn(),
|
||||
}));
|
||||
|
||||
const userId = '1';
|
||||
const userAttributes = {
|
||||
is_enterprise_user: false,
|
||||
location: 'us',
|
||||
is_mobile_user: false,
|
||||
};
|
||||
|
||||
describe('Optimizely events', () => {
|
||||
describe('activateProductRecommendationsExperiment', () => {
|
||||
it('activates the experiment and returns in experiment variant', () => {
|
||||
optimizelyClient.activate.mockReturnValueOnce(PRODUCT_RECOMMENDATIONS_EXP_VARIATION);
|
||||
const experiment = activateProductRecommendationsExperiment(userId, userAttributes);
|
||||
|
||||
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,
|
||||
userAttributes,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('trackProductRecommendationsViewed', () => {
|
||||
it('sends the productRecommendationsViewed event', () => {
|
||||
trackProductRecommendationsViewed(userId);
|
||||
expect(optimizelyClient.track).toHaveBeenCalledWith(
|
||||
eventNames.productRecommendationsViewed,
|
||||
userId,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('trackProductHeaderClicked', () => {
|
||||
it('sends the productHeaderClicked event', () => {
|
||||
trackProductHeaderClicked(userId);
|
||||
expect(optimizelyClient.track).toHaveBeenCalledWith(
|
||||
eventNames.productHeaderClicked,
|
||||
userId,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('trackProductCardClicked', () => {
|
||||
it('sends the productCardClicked event', () => {
|
||||
trackProductCardClicked(userId);
|
||||
expect(optimizelyClient.track).toHaveBeenCalledWith(
|
||||
eventNames.productCardClicked,
|
||||
userId,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('trackCourseCardClicked', () => {
|
||||
it('sends the courseCardClicked event', () => {
|
||||
trackCourseCardClicked(userId);
|
||||
expect(optimizelyClient.track).toHaveBeenCalledWith(
|
||||
eventNames.courseCardClicked,
|
||||
userId,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
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,
|
||||
courseType: type,
|
||||
image: {
|
||||
src: 'https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg',
|
||||
},
|
||||
owners: [
|
||||
{
|
||||
key: 'HarvardX',
|
||||
name: 'Harvard University',
|
||||
logoImageUrl: 'http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return courses;
|
||||
};
|
||||
|
||||
export const mockFooterRecommendationsHook = {
|
||||
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,
|
||||
amplitudeCourses: mockOpenCourses,
|
||||
};
|
||||
|
||||
export const mockAmplitudeResponse = {
|
||||
amplitudeCourses: mockOpenCourses,
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
import { StrictDict } from 'utils';
|
||||
import { createLinkTracker, createEventTracker } from 'data/services/segment/utils';
|
||||
import { courseTypeToProductLineMap, convertCourseRunKeyToCourseKey } from './utils';
|
||||
|
||||
export const eventNames = StrictDict({
|
||||
productCardClicked: 'edx.bi.2u-product-card.clicked',
|
||||
discoveryCardClicked: 'edx.bi.user.discovery.card.click',
|
||||
recommendationsHeaderClicked: 'edx.bi.link.recommendations.header.clicked',
|
||||
recommendationsViewed: 'edx.bi.user.recommendations.viewed',
|
||||
});
|
||||
|
||||
export const productCardClicked = (courseRunKey, courseTitle, courseType, href) => {
|
||||
createLinkTracker(
|
||||
createEventTracker(eventNames.productCardClicked, {
|
||||
label: courseTitle,
|
||||
courserun_key: courseRunKey,
|
||||
page: 'dashboard',
|
||||
product_line: courseTypeToProductLineMap[courseType],
|
||||
}),
|
||||
href,
|
||||
);
|
||||
};
|
||||
|
||||
export const discoveryCardClicked = (courseRunKey, courseTitle, href) => {
|
||||
createLinkTracker(
|
||||
createEventTracker(eventNames.discoveryCardClicked, {
|
||||
label: courseTitle,
|
||||
courserun_key: courseRunKey,
|
||||
page: 'dashboard',
|
||||
product_line: 'open-course',
|
||||
}),
|
||||
href,
|
||||
);
|
||||
};
|
||||
|
||||
export const recommendationsHeaderClicked = (courseType, href) => {
|
||||
createLinkTracker(
|
||||
createEventTracker(eventNames.recommendationsHeaderClicked, {
|
||||
page: 'dashboard',
|
||||
product_line: courseTypeToProductLineMap[courseType],
|
||||
}),
|
||||
href,
|
||||
);
|
||||
};
|
||||
|
||||
export const recommendationsViewed = (isControl, recommenderGroup, courseRunKey) => {
|
||||
createEventTracker(eventNames.recommendationsViewed, {
|
||||
is_control: isControl,
|
||||
productRecommenderGroup: recommenderGroup,
|
||||
page: 'dashboard',
|
||||
course_key: courseRunKey ? convertCourseRunKeyToCourseKey(courseRunKey) : '',
|
||||
});
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
import { createLinkTracker, createEventTracker } from 'data/services/segment/utils';
|
||||
import { bootCamp, treatment, control } from './constants';
|
||||
import {
|
||||
eventNames,
|
||||
productCardClicked,
|
||||
discoveryCardClicked,
|
||||
recommendationsHeaderClicked,
|
||||
recommendationsViewed,
|
||||
} from './track';
|
||||
|
||||
jest.mock('data/services/segment/utils', () => ({
|
||||
createEventTracker: jest.fn((args) => ({ createEventTracker: args })),
|
||||
createLinkTracker: jest.fn((args) => ({ createLinkTracker: args })),
|
||||
}));
|
||||
|
||||
const courseKey = 'MITx+5.0.01';
|
||||
const courseRunKeyNew = `course-v1:${courseKey}+2022T2`;
|
||||
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';
|
||||
|
||||
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, treatment, courseRunKeyOld);
|
||||
expect(createEventTracker).toHaveBeenCalledWith(
|
||||
eventNames.recommendationsViewed,
|
||||
{
|
||||
is_control: false,
|
||||
productRecommenderGroup: treatment,
|
||||
page: 'dashboard',
|
||||
course_key: courseKey,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with new course run key format', () => {
|
||||
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 = {
|
||||
product_line: 'open-courses',
|
||||
page: 'dashboard',
|
||||
};
|
||||
const args = [eventNames.recommendationsHeaderClicked, attributes];
|
||||
|
||||
recommendationsHeaderClicked('Course', headerLink);
|
||||
expect(createEventTracker).toHaveBeenCalledWith(...args);
|
||||
expect(createLinkTracker).toHaveBeenCalledWith(createEventTracker(...args), headerLink);
|
||||
});
|
||||
});
|
||||
describe('discoveryCardClicked', () => {
|
||||
it('creates a link tracker for when a open course card is clicked', () => {
|
||||
const attributes = {
|
||||
label,
|
||||
courserun_key: courseRunKeyNew,
|
||||
page: 'dashboard',
|
||||
product_line: 'open-course',
|
||||
};
|
||||
const args = [eventNames.discoveryCardClicked, attributes];
|
||||
|
||||
discoveryCardClicked(courseRunKeyNew, label, courseUrl);
|
||||
expect(createEventTracker).toHaveBeenCalledWith(...args);
|
||||
expect(createLinkTracker).toHaveBeenCalledWith(createEventTracker(...args), courseUrl);
|
||||
});
|
||||
});
|
||||
describe('productCardClicked', () => {
|
||||
it('creates a link tracker for when a cross product course card is clicked', () => {
|
||||
const attributes = {
|
||||
label,
|
||||
courserun_key: courseRunKeyNew,
|
||||
page: 'dashboard',
|
||||
product_line: 'boot-camps',
|
||||
};
|
||||
const args = [eventNames.productCardClicked, attributes];
|
||||
|
||||
productCardClicked(courseRunKeyNew, label, bootCamp, courseUrl);
|
||||
expect(createEventTracker).toHaveBeenCalledWith(...args);
|
||||
expect(createLinkTracker).toHaveBeenCalledWith(createEventTracker(...args), courseUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { executiveEducation, course, bootCamp } from './constants';
|
||||
|
||||
export const courseShape = {
|
||||
uuid: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
image: PropTypes.shape({
|
||||
src: PropTypes.string,
|
||||
}),
|
||||
prospectusPath: PropTypes.string,
|
||||
owners: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
logoImageUrl: PropTypes.string,
|
||||
}),
|
||||
),
|
||||
activeCourseRun: PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
marketingUrl: PropTypes.string,
|
||||
}),
|
||||
courseType: PropTypes.string,
|
||||
};
|
||||
|
||||
export const courseTypeToProductTypeMap = {
|
||||
course,
|
||||
'verified-audit': course,
|
||||
verified: course,
|
||||
audit: course,
|
||||
'credit-verified-audit': course,
|
||||
'spoc-verified-audit': course,
|
||||
professional: 'Professional Certificate',
|
||||
'bootcamp-2u': bootCamp,
|
||||
'executive-education-2u': executiveEducation,
|
||||
'executive-education': executiveEducation,
|
||||
masters: "Master's",
|
||||
'masters-verified-audit': "Master's",
|
||||
};
|
||||
|
||||
export const courseTypeToProductLineMap = {
|
||||
[executiveEducation]: 'executive-education',
|
||||
[bootCamp]: 'boot-camps',
|
||||
[course]: 'open-courses',
|
||||
};
|
||||
|
||||
export const convertCourseRunKeyToCourseKey = (courseRunKey) => {
|
||||
const newKeyFormat = courseRunKey.includes('+');
|
||||
if (newKeyFormat) {
|
||||
const splitCourseRunKey = courseRunKey.split(':').slice(-1)[0];
|
||||
const splitCourseKey = splitCourseRunKey.split('+').slice(0, 2);
|
||||
return `${splitCourseKey[0]}+${splitCourseKey[1]}`;
|
||||
}
|
||||
const splitCourseKey = courseRunKey.split('/').slice(0, 2);
|
||||
return `${splitCourseKey[0]}+${splitCourseKey[1]}`;
|
||||
};
|
||||
|
||||
export const wait = (time) => new Promise((resolve) => {
|
||||
setTimeout(resolve, time);
|
||||
});
|
||||
Reference in New Issue
Block a user