fix: remove Optimizely and surrounding components (#386)

This commit is contained in:
Maxwell Frank
2024-07-30 10:09:04 -04:00
committed by GitHub
parent e3b4e0956a
commit a5d1cb380d
64 changed files with 79 additions and 3087 deletions

1
.env
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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 };

View File

@@ -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 });
});
});
});

View File

@@ -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 />

View File

@@ -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">&nbsp;</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;

View File

@@ -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]);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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>
)}

View File

@@ -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,

View File

@@ -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>
`;

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -1,9 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`WidgetFooter snapshots default 1`] = `
<div
className="widget-footer"
>
<ProductRecommendations />
</div>
`;

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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"
>

View 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;

View 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();
});
});

View File

@@ -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(),
}));

View File

@@ -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",
},
]
}
/>
`;

View File

@@ -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,
};

View File

@@ -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())),
);
});
});
});

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -1,8 +0,0 @@
import React from 'react';
import { Skeleton } from '@openedx/paragon';
export const LoadingView = () => (
<Skeleton height={100} />
);
export default LoadingView;

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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`);
});
});

View File

@@ -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;

View File

@@ -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]);
});
});
});

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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>
`;

View File

@@ -1,7 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProductRecommendations LoadingView matches snapshot 1`] = `
<Skeleton
height={100}
/>
`;

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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';

View File

@@ -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 };

View File

@@ -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({});
});
});
});
});
});

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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={[]}
/>,
),
);
});
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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' });
});
});

View File

@@ -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);
};

View File

@@ -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,
{},
);
});
});
});

View File

@@ -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,
};

View File

@@ -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) : '',
});
};

View File

@@ -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);
});
});
});

View File

@@ -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);
});