diff --git a/src/containers/Dashboard/index.jsx b/src/containers/Dashboard/index.jsx index bab2361..bb31684 100644 --- a/src/containers/Dashboard/index.jsx +++ b/src/containers/Dashboard/index.jsx @@ -13,7 +13,7 @@ import LoadingView from './LoadingView'; import DashboardLayout from './DashboardLayout'; import hooks from './hooks'; import './index.scss'; -import ProductRecommendationsContainer from '../../widgets/ProductRecommendations/components/ProductRecommendationsContainer'; +import ProductRecommendations from '../../widgets/ProductRecommendations'; export const Dashboard = () => { hooks.useInitializeDashboard(); @@ -22,6 +22,8 @@ export const Dashboard = () => { const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards(); const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize); const showSelectSessionModal = reduxHooks.useShowSelectSessionModal(); + const shouldShowProductRecommendations = !initIsPending && !hasAvailableDashboards && hasCourses && false; + return (

{pageTitle}

@@ -40,7 +42,7 @@ export const Dashboard = () => { )}
- + {shouldShowProductRecommendations && } ); }; 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 index aac8efb..7ed2283 100644 --- a/src/widgets/ProductRecommendations/api.js +++ b/src/widgets/ProductRecommendations/api.js @@ -1,10 +1,10 @@ import { get, stringifyUrl } from 'data/services/lms/utils'; import urls from 'data/services/lms/urls'; -export const crossProductRecommendationsUrl = (courseId) => `${urls.api}/learner_recommendations/cross_product/${courseId}`; +export const crossProductRecommendationsUrl = (courseId) => `${urls.api}/learner_recommendations/product_recommendations/${courseId}/`; -const fetchCrossProductCourses = (courseId) => get(stringifyUrl(crossProductRecommendationsUrl(courseId))); +const fetchProductRecommendations = (courseId) => get(stringifyUrl(crossProductRecommendationsUrl(courseId))); export default { - fetchCrossProductCourses, + fetchProductRecommendations, }; diff --git a/src/widgets/ProductRecommendations/api.test.js b/src/widgets/ProductRecommendations/api.test.js new file mode 100644 index 0000000..cb24410 --- /dev/null +++ b/src/widgets/ProductRecommendations/api.test.js @@ -0,0 +1,17 @@ +import { get, stringifyUrl } from 'data/services/lms/utils'; +import api, { crossProductRecommendationsUrl } 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(crossProductRecommendationsUrl('CourseRunKey'))), + ); + }); + }); +}); diff --git a/src/widgets/ProductRecommendations/components/LoadedView.jsx b/src/widgets/ProductRecommendations/components/LoadedView.jsx new file mode 100644 index 0000000..37d5360 --- /dev/null +++ b/src/widgets/ProductRecommendations/components/LoadedView.jsx @@ -0,0 +1,51 @@ +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 MAX_OPEN_COURSE_RECOMMENDATIONS = 4; +const MIN_OPEN_COURSE_RECOMMENDATIONS = 2; + +const LoadedView = ({ crossProductCourses, openCourses }) => { + const { formatMessage } = useIntl(); + const includesCrossProductTypes = crossProductCourses.length === 2; + + const finalProductList = useMemo(() => { + if (includesCrossProductTypes) { + const openCourseList = openCourses ? openCourses.slice(0, MIN_OPEN_COURSE_RECOMMENDATIONS) : []; + return crossProductCourses.concat(openCourseList); + } + return openCourses.slice(0, MAX_OPEN_COURSE_RECOMMENDATIONS); + }, [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..1d57a89 --- /dev/null +++ b/src/widgets/ProductRecommendations/components/LoadedView.test.jsx @@ -0,0 +1,18 @@ +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(); + }); +}); 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 index b58498b..03179d7 100644 --- a/src/widgets/ProductRecommendations/components/ProductCard.jsx +++ b/src/widgets/ProductRecommendations/components/ProductCard.jsx @@ -14,57 +14,38 @@ const ProductCard = ({ schoolLogo, courseType, url, -}) => { - 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", - }; - - const productType = courseTypeToProductTypeMap[courseType]; - - return ( -
- - - - - {title} - - )} - subtitle={( - - {subtitle} - - )} - /> - -
- {productType} -
-
-
-
-
- ); -}; +}) => ( +
+ + + + + {title} + + )} + subtitle={( + + {subtitle} + + )} + /> + +
+ {courseType} +
+
+
+
+
+); ProductCard.propTypes = { title: PropTypes.string.isRequired, 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 index 126f124..9ad93b6 100644 --- a/src/widgets/ProductRecommendations/components/ProductCardContainer.jsx +++ b/src/widgets/ProductRecommendations/components/ProductCardContainer.jsx @@ -1,67 +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 = ({ courses }) => { - const courseTypes = [...new Set(courses.map((item) => item.courseType))]; - - return ( -
- {courses - && courseTypes.map((type) => ( -
- -
- {courses - .filter((course) => course.courseType === type) - .map((item) => ( - - ))} -
+const ProductCardContainer = ({ finalProductList, courseTypes }) => ( +
+ {finalProductList + && courseTypes.map((type) => ( +
+ +
+ {finalProductList + .filter((course) => courseTypeToProductTypeMap[course.courseType] === type) + .map((item) => ( + + ))}
- ))} -
- ); -}; +
+ ))} +
+); ProductCardContainer.propTypes = { - courses: PropTypes.arrayOf( - PropTypes.shape({ - key: PropTypes.string, - 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, - }), + 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 index 741aae1..21e204a 100644 --- a/src/widgets/ProductRecommendations/components/ProductCardHeader.jsx +++ b/src/widgets/ProductRecommendations/components/ProductCardHeader.jsx @@ -12,14 +12,13 @@ function ProductCardHeader({ courseType }) { const getProductTypeDetail = (type) => { switch (type) { - case 'executive-education': - case 'executive-education-2u': + case 'Executive Education': return { heading: messages.executiveEducationHeading, description: messages.executiveEducationDescription, url: '/executive-education', }; - case 'bootcamp-2u': + case 'Boot Camp': return { heading: messages.bootcampHeading, description: messages.bootcampDescription, diff --git a/src/widgets/ProductRecommendations/components/ProductCardHeader.test.jsx b/src/widgets/ProductRecommendations/components/ProductCardHeader.test.jsx new file mode 100644 index 0000000..5fc7907 --- /dev/null +++ b/src/widgets/ProductRecommendations/components/ProductCardHeader.test.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { mockCrossProductCourses } from '../testData'; +import ProductCardHeader from './ProductCardHeader'; +import { courseTypeToProductTypeMap } from '../utils'; + +describe('ProductRecommendations ProductCardHeader', () => { + const course = mockCrossProductCourses[0]; + + it('matches snapshot', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/src/widgets/ProductRecommendations/components/ProductRecommendationsContainer.jsx b/src/widgets/ProductRecommendations/components/ProductRecommendationsContainer.jsx deleted file mode 100644 index 493d008..0000000 --- a/src/widgets/ProductRecommendations/components/ProductRecommendationsContainer.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { Container } from '@edx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; - -import './index.scss'; -import messages from '../messages'; -import useCrossProductRecommendationsData from '../hooks'; -import ProductCardContainer from './ProductCardContainer'; -import mockCrossProductRecommendations from '../mockData'; - -const ProductRecommendationsContainer = () => { - const { formatMessage } = useIntl(); - const mockRecommendations = mockCrossProductRecommendations.courses; - const { courses, isLoading } = useCrossProductRecommendationsData(); - - return ( -
- {!isLoading && ( - -

- {formatMessage(messages.recommendationsHeading)} -

- -
- )} -
- ); -}; - -export default ProductRecommendationsContainer; 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..e1acc21 --- /dev/null +++ b/src/widgets/ProductRecommendations/components/__snapshots__/LoadedView.test.jsx.snap @@ -0,0 +1,89 @@ +// 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..7138134 --- /dev/null +++ b/src/widgets/ProductRecommendations/components/__snapshots__/ProductCard.test.jsx.snap @@ -0,0 +1,52 @@ +// 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..52cb8cf --- /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..5dd50f6 --- /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 index d9ac808..d9bf203 100644 --- a/src/widgets/ProductRecommendations/hooks.js +++ b/src/widgets/ProductRecommendations/hooks.js @@ -1,61 +1,65 @@ -import { useState, useEffect } from "react"; -import { StrictDict } from 'utils'; +import { useState, useEffect } from 'react'; import { RequestStates } 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 - courses: (val) => useState(val), // eslint-disable-line + data: (val) => useState(val), // eslint-disable-line }); -const useFetchMostRecentCourse = () => { - // Gets the most recent course a user is enrolled in - // Goes through the courses enrollment property and checks the lastEnrolled property? - // Can we assume that the order the courses are listed on the Dashbaord is the order that the course was enrolled in? +export const useMostRecentCourseRunKey = () => { + const mostRecentCourse = reduxHooks.useCurrentCourseList({ + sortBy: SortKeys.enrolled, + filters: [], + pageSize: 0, + }).visible[0].courseRun.courseId; + + return mostRecentCourse; }; -const useFetchCrossProductCourses = (setRequestState, setCourses) => { +export const useFetchProductRecommendations = (setRequestState, setData) => { + const courseRunKey = module.useMostRecentCourseRunKey(); useEffect(() => { let isMounted = true; - if (isMounted) { - setTimeout(() => { - console.log("timing") - api - .fetchCrossProductCourses('course-v1:IBM+IBMBCC001+1T2022') - .then((response) => { - console.log("Here is the response", response) - if (response.status === 200) { - setRequestState(RequestStates.completed); - setCourses(response.data.courses); - } - }).catch(err => { - console.log("here is the error", err); - }); - }, 5000); - - } - return () => { isMounted = false; }; - /* eslint-disable */ - }, []); // most recent course ID will be the dependancy; + api + .fetchProductRecommendations(courseRunKey) + .then((response) => { + if (isMounted) { + setData(response.data); + setRequestState(RequestStates.completed); + } + }) + .catch(() => { + if (isMounted) { + setRequestState(RequestStates.failed); + } + }); + return () => { + isMounted = false; + }; + /* eslint-disable */ + }, []); }; -const useFetchAlgoliaRecommendations = () => { - // This hook will fetch algolia reccs and return the reccs -} - -const useCrossProductRecommendationsData = () => { - const [requestState, setRequestState] = state.requestState(RequestStates.pending); - const [courses, setCourses] = state.courses([]); - useFetchCrossProductCourses(setRequestState, setCourses); +export const useProductRecommendationsData = () => { + const [requestState, setRequestState] = module.state.requestState( + RequestStates.pending + ); + const [data, setData] = module.state.data({}); + module.useFetchProductRecommendations(setRequestState, setData); return { - courses, - isLoading: requestState === RequestStates.pending - } + productRecommendations: data, + isLoading: requestState === RequestStates.pending, + isLoaded: requestState === RequestStates.completed, + hasFailed: requestState === RequestStates.failed + }; +}; -} - -export default useCrossProductRecommendationsData; +export default { useProductRecommendationsData }; diff --git a/src/widgets/ProductRecommendations/hooks.test.js b/src/widgets/ProductRecommendations/hooks.test.js new file mode 100644 index 0000000..639a08b --- /dev/null +++ b/src/widgets/ProductRecommendations/hooks.test.js @@ -0,0 +1,190 @@ +import React from 'react'; + +import { MockUseState } from 'testUtils'; +import { RequestStates } from 'data/constants/requests'; +import { reduxHooks } from 'hooks'; + +import api from './api'; +import * as hooks from './hooks'; + +jest.mock('./api', () => ({ + fetchProductRecommendations: jest.fn(), +})); + +jest.mock('hooks', () => ({ + reduxHooks: { + useCurrentCourseList: 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('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 request state to completed and loads api 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.toHaveBeenCalledWith(response.data); + await resolveFn(response); + expect(setRequestState).toHaveBeenCalledWith(RequestStates.completed); + expect(setData).toHaveBeenCalledWith(response.data); + }); + }); + describe('successful fetch on unmounted component', () => { + it('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.toHaveBeenCalledWith(response.data); + unMount(); + await resolveFn(response); + 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('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('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('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 index e69de29..b830225 100644 --- a/src/widgets/ProductRecommendations/index.jsx +++ b/src/widgets/ProductRecommendations/index.jsx @@ -0,0 +1,35 @@ +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, + hasFailed, + } = useProductRecommendationsData(); + + const { width } = useWindowSize(); + const isMobile = width < breakpoints.small.minWidth; + + if (isLoading && !isMobile && !hasFailed) { + return ; + } + + if (isLoaded && !isMobile && !hasFailed) { + return ( + + ); + } + + return null; +}; + +export default ProductRecommendations; diff --git a/src/widgets/ProductRecommendations/components/index.scss b/src/widgets/ProductRecommendations/index.scss similarity index 53% rename from src/widgets/ProductRecommendations/components/index.scss rename to src/widgets/ProductRecommendations/index.scss index d5cb491..5094d46 100644 --- a/src/widgets/ProductRecommendations/components/index.scss +++ b/src/widgets/ProductRecommendations/index.scss @@ -2,24 +2,21 @@ $horizontal-card-gap: 20px; $vertical-card-gap: 24px; -$card-height: 332px; -$header-height: 104px; -$card-width: 270px; -.recommendations-container { - border: solid; -} +// .recommendations-container { +// border: solid; +// } .base-card { - height: $card-height; - width: $card-width !important; + height: 332px; + width: 270px !important; p { margin-bottom: 0; } .pgn__card-image-cap { - height: $header-height; + height: 104px; object: { fit: cover; position: top center; @@ -83,33 +80,8 @@ $card-width: 270px; } } - &.dark { - background-color: $primary-500; - - .pgn__card-header-title-md { - color: $white; - } - - .pgn__card-header-subtitle-md { - color: $light-200; - } - - .title { - color: $white; - } - - .subtitle { - color: $light-200; - } - - .badge { - background-color: $dark-200; - color: $white; - } - - .footer-content { - color: $light-200; - } + &:hover { + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15), 0 0.125rem 0.5rem rgba(0, 0, 0, 0.15); } } @@ -117,10 +89,6 @@ $card-width: 270px; text-decoration: none; } -.base-card:hover { - box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15), 0 0.125rem 0.5rem rgba(0, 0, 0, 0.15); -} - .base-card-link .base-card { display: flex; } @@ -128,26 +96,14 @@ $card-width: 270px; .product-card-container { gap: $vertical-card-gap $horizontal-card-gap; margin: 0 (-$horizontal-card-gap); - padding: 0 $horizontal-card-gap; + padding: 1rem $horizontal-card-gap; .course-subcontainer { display: flex; gap: $vertical-card-gap $horizontal-card-gap; } - // .base-card-wrapper { - // flex: 0 1 calc(100% - #{$horizontal-card-gap}); - - // @include media-breakpoint-up(sm) { - // flex: 0 1 calc(50% - #{$horizontal-card-gap}); - // } - - // @include media-breakpoint-up(md) { - // flex: 0 1 calc(33.333% - #{$horizontal-card-gap}); - // } - - // @include media-breakpoint-up(xl) { - // flex: 0 1 calc(25% - #{$horizontal-card-gap}); - // } - // } -} \ No newline at end of file + @include media-breakpoint-down(lg) { + overflow-x: scroll; + } +} 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/mockData.js b/src/widgets/ProductRecommendations/mockData.js deleted file mode 100644 index 550db97..0000000 --- a/src/widgets/ProductRecommendations/mockData.js +++ /dev/null @@ -1,100 +0,0 @@ -const mockCrossProductRecommendations = { - courses: [ - { - key: 'HarvardX+CRS', - uuid: 'fee0ed87-a122-46a8-b233-3e5c75c755d1', - title: 'CRISPR: Gene-editing Applications', - image: { - src: 'https://prod-discovery.edx-cdn.org/media/course/image/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg', - }, - prospectusPath: - 'course/harvard-vpal-crispr-gene-editing-applications-online-short-course', - owners: [ - { - key: 'HarvardX', - name: 'Harvard University', - logoImageUrl: - 'http://localhost:18381/media/organization/logos/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png', - }, - ], - activeCourseRun: { - key: 'course-v1:HarvardX+CRS+1T2023', - marketingUrl: - 'course/crispr-gene-editing-applications-course-v1-harvardx-crs-1t2023?utm_source=discovery_worker&utm_medium=affiliate_partner', - }, - courseType: 'executive-education-2u', - }, - { - key: 'AdelaideX+BC24CYB', - uuid: 'dacb53da-35c7-4c6b-b8d0-d7d8c0c78cef', - title: 'Cybersecurity Boot Camp', - image: { - src: 'https://prod-discovery.edx-cdn.org/media/course/image/fbb62b99-9f85-4563-a67c-34ac827560bd-e3e6263b98fd.small.png', - }, - prospectusPath: - 'course/the-university-of-adelaide-cybersecurity-boot-camp', - owners: [ - { - key: 'AdelaideX', - name: 'University of Adelaide', - logoImageUrl: - 'http://localhost:18381/media/organization/logos/51d054b4-9589-4376-9e9f-15656f3c8d0e-f10fb90c3ff1.png', - }, - ], - activeCourseRun: { - key: 'course-v1:AdelaideX+BC24CYB+1T2023', - marketingUrl: - 'course/cybersecurity-boot-camp-course-v1-adelaidex-bc24cyb-1t2023?utm_source=discovery_worker&utm_medium=affiliate_partner', - }, - courseType: 'bootcamp-2u', - }, - { - key: 'AA+AA101', - uuid: '1e2cae8c-1c67-4067-a3c0-360543e6a9b8', - title: 'Data Analytics for Business', - image: { - src: 'https://prod-discovery.edx-cdn.org/media/course/image/1e2cae8c-1c67-4067-a3c0-360543e6a9b8-50beebb61f2e.small.png', - }, - prospectusPath: '/course/data-analytics-for-business', - owners: [ - { - key: 'GTx', - name: 'The Georgia Institute of Technology', - logoImageUrl: - 'https://prod-discovery.edx-cdn.org/organization/logos/8537d31f-01b4-40fd-b652-e17b38eefe41-7956b2a3cd04.png', - }, - ], - activeCourseRun: { - key: 'course-v1:GTx+MGT6203x+2T2023', - marketingUrl: - 'https://www.edx.org/course/data-analytics-for-business-course-v1gtxmgt6203x2t2023?utm_source=prospectus_worker&utm_medium=affiliate_partner', - }, - courseType: 'course', - }, - { - key: 'AA+AA101', - uuid: '876a65e7-425b-437b-bced-bdf8059fec81', - title: 'Western and Chinese Art: Masters and Classics', - image: { - src: 'https://prod-discovery.edx-cdn.org/media/course/image/876a65e7-425b-437b-bced-bdf8059fec81.small.jpg', - }, - prospectusPath: '/course/western-and-chinese-art-masters-and-classics', - owners: [ - { - key: 'TsinghuaX', - name: 'Tsinghua University', - logoImageUrl: - 'https://prod-discovery.edx-cdn.org/organization/logos/b5714409-b5f4-4c9d-9348-b0fecbaaddd6-780fbb6c72c7.png', - }, - ], - activeCourseRun: { - key: 'course-v1:TsinghuaX+00691153.x+1T2015', - marketingUrl: - 'https://www.edx.org/course/western-chinese-art-masters-classics-tsinghuax-00691153x?utm_source=prospectus_worker&utm_medium=affiliate_partner', - }, - courseType: 'course', - }, - ], -}; - -export default mockCrossProductRecommendations; diff --git a/src/widgets/ProductRecommendations/testData.js b/src/widgets/ProductRecommendations/testData.js new file mode 100644 index 0000000..8c904e8 --- /dev/null +++ b/src/widgets/ProductRecommendations/testData.js @@ -0,0 +1,31 @@ +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..f2bd65a --- /dev/null +++ b/src/widgets/ProductRecommendations/utils.js @@ -0,0 +1,42 @@ +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 default { + courseShape, + courseTypeToProductTypeMap, +}; diff --git a/src/widgets/RecommendationsPanel/index.jsx b/src/widgets/RecommendationsPanel/index.jsx index b6c68b5..c091a8d 100644 --- a/src/widgets/RecommendationsPanel/index.jsx +++ b/src/widgets/RecommendationsPanel/index.jsx @@ -4,7 +4,6 @@ import LookingForChallengeWidget from 'widgets/LookingForChallengeWidget'; import LoadingView from './LoadingView'; import LoadedView from './LoadedView'; import hooks from './hooks'; -import recommendedCoursesData from "../RecommendationsPanel/mockData"; export const RecommendationsPanel = () => { const { @@ -18,17 +17,14 @@ export const RecommendationsPanel = () => { if (isLoading) { return (); } - - const newCourses = recommendedCoursesData.courses; - - // if (newCourses.length > 0) { - // return ( - // - // ); - // } - // if (isFailed) { - // return (); - // } + if (isLoaded && courses.length > 0) { + return ( + + ); + } + if (isFailed) { + return (); + } // default fallback return (); };