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