From 93a4dfb4d954f2fe0db33d294abbe3cd697666e0 Mon Sep 17 00:00:00 2001
From: Jody Bailey <110463597+JodyBaileyy@users.noreply.github.com>
Date: Thu, 15 Jun 2023 15:06:32 +0200
Subject: [PATCH] feat: Added cross product recommendations experiment initial
render + query logic (#158)
---
src/containers/Dashboard/DashboardLayout.jsx | 6 +
.../Dashboard/DashboardLayout.test.jsx | 5 +
.../DashboardLayout.test.jsx.snap | 10 +
src/containers/Dashboard/index.jsx | 1 +
.../WidgetContainers/LoadedSidebar/index.jsx | 23 +-
.../LoadedSidebar/index.test.jsx | 11 +
.../NoCoursesSidebar/index.jsx | 23 +-
.../NoCoursesSidebar/index.test.jsx | 11 +
.../__snapshots__/index.test.jsx.snap | 9 +
.../WidgetContainers/WidgetFooter/index.jsx | 20 ++
.../WidgetFooter/index.test.jsx | 25 ++
src/setupTest.jsx | 2 +
.../__snapshots__/index.test.jsx.snap | 104 ++++++++
src/widgets/ProductRecommendations/api.js | 10 +
.../ProductRecommendations/api.test.js | 17 ++
.../components/LoadedView.jsx | 46 ++++
.../components/LoadedView.test.jsx | 34 +++
.../components/LoadingView.jsx | 8 +
.../components/LoadingView.test.jsx | 10 +
.../components/ProductCard.jsx | 60 +++++
.../components/ProductCard.test.jsx | 29 +++
.../components/ProductCardContainer.jsx | 46 ++++
.../components/ProductCardContainer.test.jsx | 43 ++++
.../components/ProductCardHeader.jsx | 62 +++++
.../components/ProductCardHeader.test.jsx | 29 +++
.../__snapshots__/LoadedView.test.jsx.snap | 85 ++++++
.../__snapshots__/LoadingView.test.jsx.snap | 7 +
.../__snapshots__/ProductCard.test.jsx.snap | 48 ++++
.../ProductCardContainer.test.jsx.snap | 95 +++++++
.../ProductCardHeader.test.jsx.snap | 29 +++
src/widgets/ProductRecommendations/hooks.js | 72 ++++++
.../ProductRecommendations/hooks.test.js | 243 ++++++++++++++++++
src/widgets/ProductRecommendations/index.jsx | 29 +++
src/widgets/ProductRecommendations/index.scss | 73 ++++++
.../ProductRecommendations/index.test.jsx | 91 +++++++
.../ProductRecommendations/messages.js | 41 +++
.../ProductRecommendations/testData.js | 31 +++
src/widgets/ProductRecommendations/utils.js | 41 +++
38 files changed, 1515 insertions(+), 14 deletions(-)
create mode 100644 src/containers/WidgetContainers/WidgetFooter/__snapshots__/index.test.jsx.snap
create mode 100644 src/containers/WidgetContainers/WidgetFooter/index.jsx
create mode 100644 src/containers/WidgetContainers/WidgetFooter/index.test.jsx
create mode 100644 src/widgets/ProductRecommendations/__snapshots__/index.test.jsx.snap
create mode 100644 src/widgets/ProductRecommendations/api.js
create mode 100644 src/widgets/ProductRecommendations/api.test.js
create mode 100644 src/widgets/ProductRecommendations/components/LoadedView.jsx
create mode 100644 src/widgets/ProductRecommendations/components/LoadedView.test.jsx
create mode 100644 src/widgets/ProductRecommendations/components/LoadingView.jsx
create mode 100644 src/widgets/ProductRecommendations/components/LoadingView.test.jsx
create mode 100644 src/widgets/ProductRecommendations/components/ProductCard.jsx
create mode 100644 src/widgets/ProductRecommendations/components/ProductCard.test.jsx
create mode 100644 src/widgets/ProductRecommendations/components/ProductCardContainer.jsx
create mode 100644 src/widgets/ProductRecommendations/components/ProductCardContainer.test.jsx
create mode 100644 src/widgets/ProductRecommendations/components/ProductCardHeader.jsx
create mode 100644 src/widgets/ProductRecommendations/components/ProductCardHeader.test.jsx
create mode 100644 src/widgets/ProductRecommendations/components/__snapshots__/LoadedView.test.jsx.snap
create mode 100644 src/widgets/ProductRecommendations/components/__snapshots__/LoadingView.test.jsx.snap
create mode 100644 src/widgets/ProductRecommendations/components/__snapshots__/ProductCard.test.jsx.snap
create mode 100644 src/widgets/ProductRecommendations/components/__snapshots__/ProductCardContainer.test.jsx.snap
create mode 100644 src/widgets/ProductRecommendations/components/__snapshots__/ProductCardHeader.test.jsx.snap
create mode 100644 src/widgets/ProductRecommendations/hooks.js
create mode 100644 src/widgets/ProductRecommendations/hooks.test.js
create mode 100644 src/widgets/ProductRecommendations/index.jsx
create mode 100644 src/widgets/ProductRecommendations/index.scss
create mode 100644 src/widgets/ProductRecommendations/index.test.jsx
create mode 100644 src/widgets/ProductRecommendations/messages.js
create mode 100644 src/widgets/ProductRecommendations/testData.js
create mode 100644 src/widgets/ProductRecommendations/utils.js
diff --git a/src/containers/Dashboard/DashboardLayout.jsx b/src/containers/Dashboard/DashboardLayout.jsx
index f17e4ad..08e7809 100644
--- a/src/containers/Dashboard/DashboardLayout.jsx
+++ b/src/containers/Dashboard/DashboardLayout.jsx
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { Container, Col, Row } from '@edx/paragon';
+import WidgetFooter from 'containers/WidgetContainers/WidgetFooter';
import hooks from './hooks';
export const columnConfig = {
@@ -30,6 +31,11 @@ export const DashboardLayout = ({ children, sidebar }) => {
{sidebar}
+
+
+
+
+
);
};
diff --git a/src/containers/Dashboard/DashboardLayout.test.jsx b/src/containers/Dashboard/DashboardLayout.test.jsx
index b1eb8e5..27dacb6 100644
--- a/src/containers/Dashboard/DashboardLayout.test.jsx
+++ b/src/containers/Dashboard/DashboardLayout.test.jsx
@@ -1,6 +1,7 @@
import { shallow } from 'enzyme';
import { Col, Row } from '@edx/paragon';
+import WidgetFooter from 'containers/WidgetContainers/WidgetFooter';
import hooks from './hooks';
import DashboardLayout, { columnConfig } from './DashboardLayout';
@@ -32,6 +33,10 @@ describe('DashboardLayout', () => {
const columns = render().find(Row).find(Col);
expect(columns.at(1).contains(props.sidebar)).toEqual(true);
});
+ it('displays a footer in the second row', () => {
+ const columns = render().find(Row).at(1).find(Col);
+ expect(columns.at(0).containsMatchingElement()).toBeTruthy();
+ });
};
const testSnapshot = () => {
test('snapshot', () => {
diff --git a/src/containers/Dashboard/__snapshots__/DashboardLayout.test.jsx.snap b/src/containers/Dashboard/__snapshots__/DashboardLayout.test.jsx.snap
index a622da4..10528f2 100644
--- a/src/containers/Dashboard/__snapshots__/DashboardLayout.test.jsx.snap
+++ b/src/containers/Dashboard/__snapshots__/DashboardLayout.test.jsx.snap
@@ -41,6 +41,11 @@ exports[`DashboardLayout collapsed snapshot 1`] = `
test-sidebar-content
+
+
+
+
+
`;
@@ -90,5 +95,10 @@ exports[`DashboardLayout not collapsed snapshot 1`] = `
test-sidebar-content
+
+
+
+
+
`;
diff --git a/src/containers/Dashboard/index.jsx b/src/containers/Dashboard/index.jsx
index a13d376..f7b3695 100644
--- a/src/containers/Dashboard/index.jsx
+++ b/src/containers/Dashboard/index.jsx
@@ -21,6 +21,7 @@ export const Dashboard = () => {
const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards();
const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
const showSelectSessionModal = reduxHooks.useShowSelectSessionModal();
+
return (
{pageTitle}
diff --git a/src/containers/WidgetContainers/LoadedSidebar/index.jsx b/src/containers/WidgetContainers/LoadedSidebar/index.jsx
index 1ebe8c1..582632a 100644
--- a/src/containers/WidgetContainers/LoadedSidebar/index.jsx
+++ b/src/containers/WidgetContainers/LoadedSidebar/index.jsx
@@ -1,13 +1,22 @@
import React from 'react';
import RecommendationsPanel from 'widgets/RecommendationsPanel';
+import hooks from 'widgets/ProductRecommendations/hooks';
-export const WidgetSidebar = () => (
-
-);
+export const WidgetSidebar = () => {
+ const showRecommendationsFooter = hooks.useShowRecommendationsFooter();
+
+ if (!showRecommendationsFooter) {
+ return (
+
+ );
+ }
+
+ return null;
+};
export default WidgetSidebar;
diff --git a/src/containers/WidgetContainers/LoadedSidebar/index.test.jsx b/src/containers/WidgetContainers/LoadedSidebar/index.test.jsx
index 68daf6f..be4af29 100644
--- a/src/containers/WidgetContainers/LoadedSidebar/index.test.jsx
+++ b/src/containers/WidgetContainers/LoadedSidebar/index.test.jsx
@@ -1,14 +1,25 @@
import { shallow } from 'enzyme';
+import hooks from 'widgets/ProductRecommendations/hooks';
import WidgetSidebar from '.';
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
+jest.mock('widgets/ProductRecommendations/hooks', () => ({
+ useShowRecommendationsFooter: jest.fn(),
+}));
describe('WidgetSidebar', () => {
describe('snapshots', () => {
test('default', () => {
+ hooks.useShowRecommendationsFooter.mockReturnValueOnce(false);
const wrapper = shallow(
);
expect(wrapper).toMatchSnapshot();
});
});
+
+ test('is hidden if footer is shown', () => {
+ hooks.useShowRecommendationsFooter.mockReturnValueOnce(true);
+ const wrapper = shallow(
);
+ expect(wrapper.type()).toBeNull();
+ });
});
diff --git a/src/containers/WidgetContainers/NoCoursesSidebar/index.jsx b/src/containers/WidgetContainers/NoCoursesSidebar/index.jsx
index 6380e21..21b8266 100644
--- a/src/containers/WidgetContainers/NoCoursesSidebar/index.jsx
+++ b/src/containers/WidgetContainers/NoCoursesSidebar/index.jsx
@@ -1,13 +1,22 @@
import React from 'react';
import RecommendationsPanel from 'widgets/RecommendationsPanel';
+import hooks from 'widgets/ProductRecommendations/hooks';
-export const WidgetSidebar = () => (
-
-);
+export const WidgetSidebar = () => {
+ const showRecommendationsFooter = hooks.useShowRecommendationsFooter();
+
+ if (!showRecommendationsFooter) {
+ return (
+
+ );
+ }
+
+ return null;
+};
export default WidgetSidebar;
diff --git a/src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx b/src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx
index 68daf6f..be4af29 100644
--- a/src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx
+++ b/src/containers/WidgetContainers/NoCoursesSidebar/index.test.jsx
@@ -1,14 +1,25 @@
import { shallow } from 'enzyme';
+import hooks from 'widgets/ProductRecommendations/hooks';
import WidgetSidebar from '.';
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
+jest.mock('widgets/ProductRecommendations/hooks', () => ({
+ useShowRecommendationsFooter: jest.fn(),
+}));
describe('WidgetSidebar', () => {
describe('snapshots', () => {
test('default', () => {
+ hooks.useShowRecommendationsFooter.mockReturnValueOnce(false);
const wrapper = shallow(
);
expect(wrapper).toMatchSnapshot();
});
});
+
+ test('is hidden if footer is shown', () => {
+ hooks.useShowRecommendationsFooter.mockReturnValueOnce(true);
+ const wrapper = shallow(
);
+ expect(wrapper.type()).toBeNull();
+ });
});
diff --git a/src/containers/WidgetContainers/WidgetFooter/__snapshots__/index.test.jsx.snap b/src/containers/WidgetContainers/WidgetFooter/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000..9168c23
--- /dev/null
+++ b/src/containers/WidgetContainers/WidgetFooter/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`WidgetFooter snapshots default 1`] = `
+
+`;
diff --git a/src/containers/WidgetContainers/WidgetFooter/index.jsx b/src/containers/WidgetContainers/WidgetFooter/index.jsx
new file mode 100644
index 0000000..86dbd52
--- /dev/null
+++ b/src/containers/WidgetContainers/WidgetFooter/index.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import ProductRecommendations from 'widgets/ProductRecommendations';
+import hooks from 'widgets/ProductRecommendations/hooks';
+
+export const WidgetFooter = () => {
+ const showRecommendationsFooter = hooks.useShowRecommendationsFooter();
+
+ if (showRecommendationsFooter) {
+ return (
+
+ );
+ }
+
+ return null;
+};
+
+export default WidgetFooter;
diff --git a/src/containers/WidgetContainers/WidgetFooter/index.test.jsx b/src/containers/WidgetContainers/WidgetFooter/index.test.jsx
new file mode 100644
index 0000000..9854462
--- /dev/null
+++ b/src/containers/WidgetContainers/WidgetFooter/index.test.jsx
@@ -0,0 +1,25 @@
+import { shallow } from 'enzyme';
+
+import hooks from 'widgets/ProductRecommendations/hooks';
+import WidgetFooter from '.';
+
+jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
+jest.mock('widgets/ProductRecommendations/hooks', () => ({
+ useShowRecommendationsFooter: jest.fn(),
+}));
+
+describe('WidgetFooter', () => {
+ describe('snapshots', () => {
+ test('default', () => {
+ hooks.useShowRecommendationsFooter.mockReturnValueOnce(true);
+ const wrapper = shallow(
);
+ expect(wrapper).toMatchSnapshot();
+ });
+ });
+
+ test('is hidden when hook returns false', () => {
+ hooks.useShowRecommendationsFooter.mockReturnValueOnce(false);
+ const wrapper = shallow(
);
+ expect(wrapper.type()).toBeNull();
+ });
+});
diff --git a/src/setupTest.jsx b/src/setupTest.jsx
index 7aba2bd..0cbd1fb 100755
--- a/src/setupTest.jsx
+++ b/src/setupTest.jsx
@@ -144,6 +144,8 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
Sheet: 'Sheet',
StatefulButton: 'StatefulButton',
TextFilter: 'TextFilter',
+ Truncate: 'Truncate',
+ Skeleton: 'Skeleton',
Spinner: 'Spinner',
PageBanner: 'PageBanner',
Pagination: 'Pagination',
diff --git a/src/widgets/ProductRecommendations/__snapshots__/index.test.jsx.snap b/src/widgets/ProductRecommendations/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000..0a2c156
--- /dev/null
+++ b/src/widgets/ProductRecommendations/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,104 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ProductRecommendations matches snapshot 1`] = `
+
+`;
diff --git a/src/widgets/ProductRecommendations/api.js b/src/widgets/ProductRecommendations/api.js
new file mode 100644
index 0000000..f216070
--- /dev/null
+++ b/src/widgets/ProductRecommendations/api.js
@@ -0,0 +1,10 @@
+import { get, stringifyUrl } from 'data/services/lms/utils';
+import urls from 'data/services/lms/urls';
+
+export const productRecommendationsUrl = (courseId) => `${urls.api}/learner_recommendations/product_recommendations/${courseId}/`;
+
+const fetchProductRecommendations = (courseId) => get(stringifyUrl(productRecommendationsUrl(courseId)));
+
+export default {
+ fetchProductRecommendations,
+};
diff --git a/src/widgets/ProductRecommendations/api.test.js b/src/widgets/ProductRecommendations/api.test.js
new file mode 100644
index 0000000..3be3f9e
--- /dev/null
+++ b/src/widgets/ProductRecommendations/api.test.js
@@ -0,0 +1,17 @@
+import { get, stringifyUrl } from 'data/services/lms/utils';
+import api, { productRecommendationsUrl } from './api';
+
+jest.mock('data/services/lms/utils', () => ({
+ stringifyUrl: (...args) => ({ stringifyUrl: args }),
+ get: (...args) => ({ get: args }),
+}));
+
+describe('productRecommendationCourses api', () => {
+ describe('fetchProductRecommendations', () => {
+ it('calls get with the correct recommendation courses URL', () => {
+ expect(api.fetchProductRecommendations('CourseRunKey')).toEqual(
+ get(stringifyUrl(productRecommendationsUrl('CourseRunKey'))),
+ );
+ });
+ });
+});
diff --git a/src/widgets/ProductRecommendations/components/LoadedView.jsx b/src/widgets/ProductRecommendations/components/LoadedView.jsx
new file mode 100644
index 0000000..f75050d
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/LoadedView.jsx
@@ -0,0 +1,46 @@
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+import { Container } from '@edx/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 (
+
+
+ {formatMessage(messages.recommendationsHeading)}
+
+
+
+ );
+};
+
+LoadedView.propTypes = {
+ crossProductCourses: PropTypes.arrayOf(
+ PropTypes.shape(courseShape),
+ ).isRequired,
+ openCourses: PropTypes.arrayOf(
+ PropTypes.shape(courseShape),
+ ).isRequired,
+};
+
+export default LoadedView;
diff --git a/src/widgets/ProductRecommendations/components/LoadedView.test.jsx b/src/widgets/ProductRecommendations/components/LoadedView.test.jsx
new file mode 100644
index 0000000..dc6c9fd
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/LoadedView.test.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { mockCrossProductCourses, mockOpenCourses } from '../testData';
+import LoadedView from './LoadedView';
+
+describe('ProductRecommendations LoadedView', () => {
+ it('matches snapshot', () => {
+ expect(
+ shallow(
+
,
+ ),
+ ).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(
+
,
+ );
+
+ const productCardContainerProps = wrapper.find('ProductCardContainer').props();
+
+ expect(productCardContainerProps.courseTypes.length).toEqual(1);
+ expect(productCardContainerProps.courseTypes[0]).toEqual('Course');
+ expect(productCardContainerProps.finalProductList).toEqual(mockOpenCourses);
+ });
+ });
+});
diff --git a/src/widgets/ProductRecommendations/components/LoadingView.jsx b/src/widgets/ProductRecommendations/components/LoadingView.jsx
new file mode 100644
index 0000000..82d6a25
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/LoadingView.jsx
@@ -0,0 +1,8 @@
+import React from 'react';
+import { Skeleton } from '@edx/paragon';
+
+export const LoadingView = () => (
+
+);
+
+export default LoadingView;
diff --git a/src/widgets/ProductRecommendations/components/LoadingView.test.jsx b/src/widgets/ProductRecommendations/components/LoadingView.test.jsx
new file mode 100644
index 0000000..3afc66c
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/LoadingView.test.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import LoadingView from './LoadingView';
+
+describe('ProductRecommendations LoadingView', () => {
+ it('matches snapshot', () => {
+ expect(shallow(
)).toMatchSnapshot();
+ });
+});
diff --git a/src/widgets/ProductRecommendations/components/ProductCard.jsx b/src/widgets/ProductRecommendations/components/ProductCard.jsx
new file mode 100644
index 0000000..cabb154
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/ProductCard.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ Badge,
+ Card,
+ Truncate,
+ Hyperlink,
+} from '@edx/paragon';
+
+const ProductCard = ({
+ title,
+ subtitle,
+ headerImage,
+ schoolLogo,
+ courseType,
+ url,
+}) => (
+
+
+
+ {title}
+
+ )}
+ subtitle={(
+
+ {subtitle}
+
+ )}
+ />
+
+
+ {courseType}
+
+
+
+);
+
+ProductCard.propTypes = {
+ title: PropTypes.string.isRequired,
+ subtitle: PropTypes.string.isRequired,
+ headerImage: PropTypes.string.isRequired,
+ schoolLogo: PropTypes.string.isRequired,
+ courseType: PropTypes.string.isRequired,
+ url: PropTypes.string.isRequired,
+};
+
+export default ProductCard;
diff --git a/src/widgets/ProductRecommendations/components/ProductCard.test.jsx b/src/widgets/ProductRecommendations/components/ProductCard.test.jsx
new file mode 100644
index 0000000..ba643a5
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/ProductCard.test.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { mockCrossProductCourses } from '../testData';
+import ProductCard from './ProductCard';
+import { courseTypeToProductTypeMap } from '../utils';
+
+describe('ProductRecommendations ProductCard', () => {
+ const course = mockCrossProductCourses[0];
+ const {
+ title,
+ owners: [{ name: subtitle }],
+ image: { src: headerImage },
+ owners: [{ logoImageUrl: schoolLogo }],
+ } = course;
+
+ const props = {
+ title,
+ subtitle,
+ headerImage,
+ schoolLogo,
+ courseType: courseTypeToProductTypeMap[course.courseType],
+ url: `https://www.edx.org/${course.prospectusPath}`,
+ };
+
+ it('matches snapshot', () => {
+ expect(shallow(
)).toMatchSnapshot();
+ });
+});
diff --git a/src/widgets/ProductRecommendations/components/ProductCardContainer.jsx b/src/widgets/ProductRecommendations/components/ProductCardContainer.jsx
new file mode 100644
index 0000000..e9ab225
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/ProductCardContainer.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import { courseShape, courseTypeToProductTypeMap } from '../utils';
+import ProductCard from './ProductCard';
+import ProductCardHeader from './ProductCardHeader';
+
+const ProductCardContainer = ({ finalProductList, courseTypes }) => (
+
+ {finalProductList
+ && courseTypes.map((type) => (
+
+
+
+ {finalProductList
+ .filter((course) => courseTypeToProductTypeMap[course.courseType] === type)
+ .map((item) => (
+
+ ))}
+
+
+ ))}
+
+);
+
+ProductCardContainer.propTypes = {
+ finalProductList: PropTypes.arrayOf(
+ PropTypes.shape(courseShape),
+ ).isRequired,
+ courseTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
+};
+
+export default ProductCardContainer;
diff --git a/src/widgets/ProductRecommendations/components/ProductCardContainer.test.jsx b/src/widgets/ProductRecommendations/components/ProductCardContainer.test.jsx
new file mode 100644
index 0000000..c737461
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/ProductCardContainer.test.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { mockCrossProductCourses, mockOpenCourses } from '../testData';
+import ProductCardContainer from './ProductCardContainer';
+
+describe('ProductRecommendations ProductCardContainer', () => {
+ const props = {
+ finalProductList: [...mockCrossProductCourses, ...mockOpenCourses],
+ courseTypes: ['Executive Education', 'Boot Camp', 'Course'],
+ };
+
+ it('matches snapshot', () => {
+ expect(shallow(
)).toMatchSnapshot();
+ });
+
+ describe('with finalCourseList containing cross product and open courses', () => {
+ it('renders 3 ProductCardHeaders with the 3 different course types', () => {
+ const wrapper = shallow(
);
+ const productCardHeaders = wrapper.find('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(
);
+ const productCardHeaders = wrapper.find('ProductCardHeader');
+
+ expect(productCardHeaders.length).toEqual(1);
+ expect(productCardHeaders.at(0).props().courseType).toEqual(openCoursesProps.courseTypes[0]);
+ });
+ });
+});
diff --git a/src/widgets/ProductRecommendations/components/ProductCardHeader.jsx b/src/widgets/ProductRecommendations/components/ProductCardHeader.jsx
new file mode 100644
index 0000000..8baf873
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/ProductCardHeader.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import { Icon, Hyperlink } from '@edx/paragon';
+import { ChevronRight } from '@edx/paragon/icons';
+import messages from '../messages';
+
+const ProductCardHeader = ({ courseType }) => {
+ const { formatMessage } = useIntl();
+
+ const getProductTypeDetail = (type) => {
+ switch (type) {
+ case 'Executive Education':
+ return {
+ heading: messages.executiveEducationHeading,
+ description: messages.executiveEducationDescription,
+ url: '/executive-education?linked_from=recommender',
+ };
+ case 'Boot Camp':
+ 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?linked_from=recommender',
+ };
+ }
+ }
+ };
+
+ const productTypeDetail = getProductTypeDetail(courseType);
+
+ return (
+
+
+
+
+ {formatMessage(productTypeDetail.heading)}
+
+
+
+
+
+ {formatMessage(productTypeDetail.description)}
+
+
+ );
+};
+
+ProductCardHeader.propTypes = {
+ courseType: PropTypes.string.isRequired,
+};
+
+export default ProductCardHeader;
diff --git a/src/widgets/ProductRecommendations/components/ProductCardHeader.test.jsx b/src/widgets/ProductRecommendations/components/ProductCardHeader.test.jsx
new file mode 100644
index 0000000..185230a
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/ProductCardHeader.test.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import ProductCardHeader from './ProductCardHeader';
+
+describe('ProductRecommendations ProductCardHeader', () => {
+ const bootCampType = 'Boot Camp';
+ const executiveEducationType = 'Executive Education';
+ const courseType = 'Courses';
+
+ it('matches snapshot', () => {
+ expect(shallow(
)).toMatchSnapshot();
+ });
+ describe('with bootcamp courseType prop', () => {
+ it('renders a bootcamp header', () => {
+ const wrapper = shallow(
);
+
+ expect(wrapper.find('h3').text()).toEqual(bootCampType);
+ });
+ });
+
+ describe('with course courseType prop', () => {
+ it('renders a courses header', () => {
+ const wrapper = shallow(
);
+
+ expect(wrapper.find('h3').text()).toEqual(courseType);
+ });
+ });
+});
diff --git a/src/widgets/ProductRecommendations/components/__snapshots__/LoadedView.test.jsx.snap b/src/widgets/ProductRecommendations/components/__snapshots__/LoadedView.test.jsx.snap
new file mode 100644
index 0000000..15f7fd7
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/__snapshots__/LoadedView.test.jsx.snap
@@ -0,0 +1,85 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ProductRecommendations LoadedView matches snapshot 1`] = `
+
+
+ You might also like
+
+
+
+`;
diff --git a/src/widgets/ProductRecommendations/components/__snapshots__/LoadingView.test.jsx.snap b/src/widgets/ProductRecommendations/components/__snapshots__/LoadingView.test.jsx.snap
new file mode 100644
index 0000000..1203443
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/__snapshots__/LoadingView.test.jsx.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ProductRecommendations LoadingView matches snapshot 1`] = `
+
+`;
diff --git a/src/widgets/ProductRecommendations/components/__snapshots__/ProductCard.test.jsx.snap b/src/widgets/ProductRecommendations/components/__snapshots__/ProductCard.test.jsx.snap
new file mode 100644
index 0000000..55775ce
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/__snapshots__/ProductCard.test.jsx.snap
@@ -0,0 +1,48 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ProductRecommendations ProductCard matches snapshot 1`] = `
+
+
+
+ Harvard University
+
+ }
+ title={
+
+ Introduction to Computer Science
+
+ }
+ />
+
+
+
+ Executive Education
+
+
+
+
+`;
diff --git a/src/widgets/ProductRecommendations/components/__snapshots__/ProductCardContainer.test.jsx.snap b/src/widgets/ProductRecommendations/components/__snapshots__/ProductCardContainer.test.jsx.snap
new file mode 100644
index 0000000..600df35
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/__snapshots__/ProductCardContainer.test.jsx.snap
@@ -0,0 +1,95 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ProductRecommendations ProductCardContainer matches snapshot 1`] = `
+
+`;
diff --git a/src/widgets/ProductRecommendations/components/__snapshots__/ProductCardHeader.test.jsx.snap b/src/widgets/ProductRecommendations/components/__snapshots__/ProductCardHeader.test.jsx.snap
new file mode 100644
index 0000000..dda93de
--- /dev/null
+++ b/src/widgets/ProductRecommendations/components/__snapshots__/ProductCardHeader.test.jsx.snap
@@ -0,0 +1,29 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ProductRecommendations ProductCardHeader matches snapshot 1`] = `
+
+
+
+
+ Executive Education
+
+
+
+
+
+ Short Courses to develop leadership skills
+
+
+`;
diff --git a/src/widgets/ProductRecommendations/hooks.js b/src/widgets/ProductRecommendations/hooks.js
new file mode 100644
index 0000000..a97da65
--- /dev/null
+++ b/src/widgets/ProductRecommendations/hooks.js
@@ -0,0 +1,72 @@
+import { useState, useEffect } from 'react';
+
+import { RequestStates, RequestKeys } from 'data/constants/requests';
+import { StrictDict } from 'utils';
+import { reduxHooks } from 'hooks';
+import { SortKeys } from 'data/constants/app';
+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 useShowRecommendationsFooter = () => {
+ const hasCourses = reduxHooks.useHasCourses();
+ const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards();
+ const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
+
+ // Hardcoded to not show until experiment related code is implemented
+ return !initIsPending && hasCourses && !hasAvailableDashboards && false;
+};
+
+export const useMostRecentCourseRunKey = () => {
+ const mostRecentCourse = reduxHooks.useCurrentCourseList({
+ sortBy: SortKeys.enrolled,
+ filters: [],
+ pageSize: 0,
+ }).visible[0].courseRun.courseId;
+
+ return mostRecentCourse;
+};
+
+export const useFetchProductRecommendations = (setRequestState, setData) => {
+ const courseRunKey = module.useMostRecentCourseRunKey();
+
+ useEffect(() => {
+ let isMounted = true;
+ api
+ .fetchProductRecommendations(courseRunKey)
+ .then((response) => {
+ if (isMounted) {
+ setData(response.data);
+ setRequestState(RequestStates.completed);
+ }
+ })
+ .catch(() => {
+ if (isMounted) {
+ setRequestState(RequestStates.failed);
+ }
+ });
+ return () => {
+ isMounted = false;
+ };
+ /* eslint-disable */
+ }, []);
+};
+
+export const useProductRecommendationsData = () => {
+ const [requestState, setRequestState] = module.state.requestState(RequestStates.pending);
+ const [data, setData] = module.state.data({});
+ module.useFetchProductRecommendations(setRequestState, setData);
+
+ return {
+ productRecommendations: data,
+ isLoading: requestState === RequestStates.pending,
+ isLoaded: requestState === RequestStates.completed,
+ hasFailed: requestState === RequestStates.failed
+ };
+};
+
+export default { useProductRecommendationsData, useShowRecommendationsFooter };
diff --git a/src/widgets/ProductRecommendations/hooks.test.js b/src/widgets/ProductRecommendations/hooks.test.js
new file mode 100644
index 0000000..1531997
--- /dev/null
+++ b/src/widgets/ProductRecommendations/hooks.test.js
@@ -0,0 +1,243 @@
+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 { wait } from './utils';
+
+import api from './api';
+import * as hooks from './hooks';
+
+jest.mock('./api', () => ({
+ fetchProductRecommendations: jest.fn(),
+}));
+
+jest.mock('hooks', () => ({
+ reduxHooks: {
+ useCurrentCourseList: jest.fn(),
+ useHasCourses: jest.fn(),
+ useHasAvailableDashboards: jest.fn(),
+ useRequestIsPending: jest.fn(),
+ },
+}));
+
+const state = new MockUseState(hooks);
+const mostRecentCourseRunKey = 'course ID 1';
+
+const courses = [
+ {
+ courseRun: {
+ courseId: mostRecentCourseRunKey,
+ },
+ },
+ {
+ courseRun: {
+ courseId: 'course ID 2',
+ },
+ },
+];
+
+const courseListData = {
+ visible: courses,
+ numPages: 0,
+};
+
+let output;
+describe('ProductRecommendations hooks', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ 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(courseListData);
+
+ expect(hooks.useMostRecentCourseRunKey()).toBe(mostRecentCourseRunKey);
+ });
+ });
+
+ describe('useShowRecommendationsFooter', () => {
+ // TODO: Update when hardcoded value is removed
+ it('returns whether the footer widget should show', () => {
+ reduxHooks.useHasCourses.mockReturnValueOnce(true);
+ reduxHooks.useHasAvailableDashboards.mockReturnValueOnce(false);
+ reduxHooks.useRequestIsPending.mockReturnValueOnce(false);
+
+ expect(hooks.useShowRecommendationsFooter()).toBeFalsy();
+ });
+ });
+
+ describe('useFetchProductRecommendations', () => {
+ describe('behavior', () => {
+ describe('useEffect call', () => {
+ let calls;
+ let cb;
+ const response = { data: 'response data' };
+ const setRequestState = jest.fn();
+ const setData = jest.fn();
+ beforeEach(() => {
+ reduxHooks.useCurrentCourseList.mockReturnValue(courseListData);
+ hooks.useFetchProductRecommendations(setRequestState, setData);
+ ({ calls } = React.useEffect.mock);
+ ([[cb]] = calls);
+ });
+ it('calls useEffect once', () => {
+ expect(calls.length).toEqual(1);
+ });
+ it('calls fetchProductRecommendations with the most recently enrolled courseId', () => {
+ api.fetchProductRecommendations.mockReturnValueOnce(Promise.resolve(response));
+ cb();
+ expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
+ });
+ describe('successful fetch on mounted component', () => {
+ it('sets the request state to completed and loads response', async () => {
+ let resolveFn;
+ api.fetchProductRecommendations.mockReturnValueOnce(new Promise(resolve => {
+ resolveFn = resolve;
+ }));
+ cb();
+ expect(api.fetchProductRecommendations).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.fetchProductRecommendations.mockReturnValueOnce(new Promise(resolve => {
+ resolveFn = resolve;
+ }));
+ const unMount = cb();
+ expect(api.fetchProductRecommendations).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.fetchProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
+ rejectFn = reject;
+ }));
+ cb();
+ expect(api.fetchProductRecommendations).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.fetchProductRecommendations.mockReturnValueOnce(new Promise((resolve, reject) => {
+ rejectFn = reject;
+ }));
+ const unMount = cb();
+ expect(api.fetchProductRecommendations).toHaveBeenCalledWith(mostRecentCourseRunKey);
+ expect(setRequestState).not.toHaveBeenCalled();
+ expect(setData).not.toHaveBeenCalled();
+ unMount();
+ rejectFn();
+ await wait(10);
+ expect(setRequestState).not.toHaveBeenCalled();
+ expect(setData).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
+ });
+ describe('useProductRecommendationsData', () => {
+ let fetchSpy;
+ beforeEach(() => {
+ state.mock();
+ fetchSpy = jest.spyOn(hooks, 'useFetchProductRecommendations').mockImplementationOnce(() => {});
+ output = hooks.useProductRecommendationsData();
+ });
+ it('calls useFetchProductRecommendations with setRequestState and setData', () => {
+ expect(fetchSpy).toHaveBeenCalledWith(state.setState.requestState, state.setState.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({});
+ });
+ });
+ });
+ });
+});
diff --git a/src/widgets/ProductRecommendations/index.jsx b/src/widgets/ProductRecommendations/index.jsx
new file mode 100644
index 0000000..467ca3b
--- /dev/null
+++ b/src/widgets/ProductRecommendations/index.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import './index.scss';
+import { useWindowSize, breakpoints } from '@edx/paragon';
+import { useProductRecommendationsData } from './hooks';
+import LoadingView from './components/LoadingView';
+import LoadedView from './components/LoadedView';
+
+const ProductRecommendations = () => {
+ const { productRecommendations, isLoading, isLoaded } = useProductRecommendationsData();
+ const { width } = useWindowSize();
+ const isMobile = width < breakpoints.small.minWidth;
+
+ if (isLoading && !isMobile) {
+ return
;
+ }
+
+ if (isLoaded && !isMobile) {
+ return (
+
+ );
+ }
+
+ return null;
+};
+
+export default ProductRecommendations;
diff --git a/src/widgets/ProductRecommendations/index.scss b/src/widgets/ProductRecommendations/index.scss
new file mode 100644
index 0000000..c53d860
--- /dev/null
+++ b/src/widgets/ProductRecommendations/index.scss
@@ -0,0 +1,73 @@
+@import "@edx/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;
+}
diff --git a/src/widgets/ProductRecommendations/index.test.jsx b/src/widgets/ProductRecommendations/index.test.jsx
new file mode 100644
index 0000000..c100cc4
--- /dev/null
+++ b/src/widgets/ProductRecommendations/index.test.jsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { useWindowSize } from '@edx/paragon';
+import hooks from './hooks';
+import ProductRecommendations from './index';
+import LoadingView from './components/LoadingView';
+import LoadedView from './components/LoadedView';
+import { mockResponse } from './testData';
+
+jest.mock('./hooks', () => ({
+ useProductRecommendationsData: jest.fn(),
+}));
+jest.mock('./components/LoadingView', () => 'LoadingView');
+jest.mock('./components/LoadedView', () => 'LoadedView');
+
+describe('ProductRecommendations', () => {
+ const defaultValues = {
+ productRecommendations: {},
+ isLoading: false,
+ isLoaded: false,
+ hasFailed: false,
+ };
+
+ const successfullLoadValues = {
+ ...defaultValues,
+ isLoaded: true,
+ productRecommendations: mockResponse,
+ };
+
+ const desktopWindowSize = {
+ width: 1400,
+ height: 943,
+ };
+
+ it('matches snapshot', () => {
+ useWindowSize.mockReturnValueOnce(desktopWindowSize);
+ hooks.useProductRecommendationsData.mockReturnValueOnce({
+ ...successfullLoadValues,
+ });
+
+ expect(shallow(
)).toMatchSnapshot();
+ });
+ it('renders the LoadedView with course data if the request completed', () => {
+ useWindowSize.mockReturnValueOnce(desktopWindowSize);
+ hooks.useProductRecommendationsData.mockReturnValueOnce({
+ ...successfullLoadValues,
+ });
+
+ expect(shallow(
)).toMatchObject(
+ shallow(
+
,
+ ),
+ );
+ });
+ it('renders the LoadingView if the request is pending', () => {
+ useWindowSize.mockReturnValueOnce(desktopWindowSize);
+ hooks.useProductRecommendationsData.mockReturnValueOnce({
+ ...defaultValues,
+ isLoading: true,
+ });
+
+ expect(shallow(
)).toMatchObject(
+ shallow(
),
+ );
+ });
+ it('renders nothing if the request has failed', () => {
+ useWindowSize.mockReturnValueOnce(desktopWindowSize);
+ hooks.useProductRecommendationsData.mockReturnValueOnce({
+ ...defaultValues,
+ hasFailed: true,
+ });
+
+ const wrapper = shallow(
);
+
+ expect(wrapper.type()).toBeNull();
+ });
+ it('renders nothing if the width of the screen size is less than 576px (mobile view)', () => {
+ useWindowSize.mockReturnValueOnce({ width: 575, height: 976 });
+ hooks.useProductRecommendationsData.mockReturnValueOnce({
+ ...successfullLoadValues,
+ });
+
+ const wrapper = shallow(
);
+
+ expect(wrapper.type()).toBeNull();
+ });
+});
diff --git a/src/widgets/ProductRecommendations/messages.js b/src/widgets/ProductRecommendations/messages.js
new file mode 100644
index 0000000..1615181
--- /dev/null
+++ b/src/widgets/ProductRecommendations/messages.js
@@ -0,0 +1,41 @@
+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;
diff --git a/src/widgets/ProductRecommendations/testData.js b/src/widgets/ProductRecommendations/testData.js
new file mode 100644
index 0000000..71e233f
--- /dev/null
+++ b/src/widgets/ProductRecommendations/testData.js
@@ -0,0 +1,31 @@
+export const getCoursesWithType = (courseTypes) => {
+ const courses = [];
+
+ courseTypes.forEach((type) => {
+ courses.push({
+ title: 'Introduction to Computer Science',
+ image: {
+ src: 'https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg',
+ },
+ prospectusPath: 'course/introduction-to-computer-sceince',
+ owners: [
+ {
+ key: 'HarvardX',
+ name: 'Harvard University',
+ logoImageUrl: 'http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png',
+ },
+ ],
+ courseType: type,
+ });
+ });
+
+ return courses;
+};
+
+export const mockCrossProductCourses = getCoursesWithType(['executive-education-2u', 'bootcamp-2u']);
+export const mockOpenCourses = getCoursesWithType(['verified-audit', 'audit', 'verified', 'course']);
+
+export const mockResponse = {
+ crossProductCourses: mockCrossProductCourses,
+ amplitudeCourses: mockOpenCourses,
+};
diff --git a/src/widgets/ProductRecommendations/utils.js b/src/widgets/ProductRecommendations/utils.js
new file mode 100644
index 0000000..12ea711
--- /dev/null
+++ b/src/widgets/ProductRecommendations/utils.js
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+
+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: 'Course',
+ 'verified-audit': 'Course',
+ verified: 'Course',
+ audit: 'Course',
+ 'credit-verified-audit': 'Course',
+ 'spoc-verified-audit': 'Course',
+ professional: 'Professional Certificate',
+ 'bootcamp-2u': 'Boot Camp',
+ 'executive-education-2u': 'Executive Education',
+ 'executive-education': 'Executive Education',
+ masters: "Master's",
+ 'masters-verified-audit': "Master's",
+};
+
+export const wait = (time) => new Promise((resolve) => {
+ setTimeout(resolve, time);
+});