Compare commits
13 Commits
master
...
jodybailey
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e4e2d414 | ||
|
|
f4c27f02ba | ||
|
|
55a647bb5b | ||
|
|
c3b98d954e | ||
|
|
91a694736a | ||
|
|
7fe3bf7ab8 | ||
|
|
68db9a9829 | ||
|
|
bdf3870808 | ||
|
|
69e7c71885 | ||
|
|
cd7650ab42 | ||
|
|
7bd3452dc3 | ||
|
|
be1e1bf7d9 | ||
|
|
807d9f70b8 |
@@ -13,6 +13,7 @@ import LoadingView from './LoadingView';
|
|||||||
import DashboardLayout from './DashboardLayout';
|
import DashboardLayout from './DashboardLayout';
|
||||||
import hooks from './hooks';
|
import hooks from './hooks';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
import ProductRecommendations from '../../widgets/ProductRecommendations';
|
||||||
|
|
||||||
export const Dashboard = () => {
|
export const Dashboard = () => {
|
||||||
hooks.useInitializeDashboard();
|
hooks.useInitializeDashboard();
|
||||||
@@ -21,6 +22,10 @@ export const Dashboard = () => {
|
|||||||
const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards();
|
const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards();
|
||||||
const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
|
const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
|
||||||
const showSelectSessionModal = reduxHooks.useShowSelectSessionModal();
|
const showSelectSessionModal = reduxHooks.useShowSelectSessionModal();
|
||||||
|
|
||||||
|
// Hard coded to not show until experiment set-up logic is implemented
|
||||||
|
const showProductRecommendations = false && !initIsPending && !hasAvailableDashboards && hasCourses;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="dashboard-container" className="d-flex flex-column p-2 pt-0">
|
<div id="dashboard-container" className="d-flex flex-column p-2 pt-0">
|
||||||
<h1 className="sr-only">{pageTitle}</h1>
|
<h1 className="sr-only">{pageTitle}</h1>
|
||||||
@@ -39,6 +44,7 @@ export const Dashboard = () => {
|
|||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showProductRecommendations && <ProductRecommendations />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
|
|||||||
Sheet: 'Sheet',
|
Sheet: 'Sheet',
|
||||||
StatefulButton: 'StatefulButton',
|
StatefulButton: 'StatefulButton',
|
||||||
TextFilter: 'TextFilter',
|
TextFilter: 'TextFilter',
|
||||||
|
Truncate: 'Truncate',
|
||||||
|
Skeleton: 'Skeleton',
|
||||||
Spinner: 'Spinner',
|
Spinner: 'Spinner',
|
||||||
PageBanner: 'PageBanner',
|
PageBanner: 'PageBanner',
|
||||||
Pagination: 'Pagination',
|
Pagination: 'Pagination',
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ProductRecommendations matches snapshot 1`] = `
|
||||||
|
<LoadedView
|
||||||
|
crossProductCourses={
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"courseType": "executive-education-2u",
|
||||||
|
"image": Object {
|
||||||
|
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||||
|
},
|
||||||
|
"owners": Array [
|
||||||
|
Object {
|
||||||
|
"key": "HarvardX",
|
||||||
|
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||||
|
"name": "Harvard University",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prospectusPath": "course/introduction-to-computer-sceince",
|
||||||
|
"title": "Introduction to Computer Science",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"courseType": "bootcamp-2u",
|
||||||
|
"image": Object {
|
||||||
|
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||||
|
},
|
||||||
|
"owners": Array [
|
||||||
|
Object {
|
||||||
|
"key": "HarvardX",
|
||||||
|
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||||
|
"name": "Harvard University",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prospectusPath": "course/introduction-to-computer-sceince",
|
||||||
|
"title": "Introduction to Computer Science",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openCourses={
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"courseType": "verified-audit",
|
||||||
|
"image": Object {
|
||||||
|
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||||
|
},
|
||||||
|
"owners": Array [
|
||||||
|
Object {
|
||||||
|
"key": "HarvardX",
|
||||||
|
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||||
|
"name": "Harvard University",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prospectusPath": "course/introduction-to-computer-sceince",
|
||||||
|
"title": "Introduction to Computer Science",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"courseType": "audit",
|
||||||
|
"image": Object {
|
||||||
|
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||||
|
},
|
||||||
|
"owners": Array [
|
||||||
|
Object {
|
||||||
|
"key": "HarvardX",
|
||||||
|
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||||
|
"name": "Harvard University",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prospectusPath": "course/introduction-to-computer-sceince",
|
||||||
|
"title": "Introduction to Computer Science",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"courseType": "verified",
|
||||||
|
"image": Object {
|
||||||
|
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||||
|
},
|
||||||
|
"owners": Array [
|
||||||
|
Object {
|
||||||
|
"key": "HarvardX",
|
||||||
|
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||||
|
"name": "Harvard University",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prospectusPath": "course/introduction-to-computer-sceince",
|
||||||
|
"title": "Introduction to Computer Science",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"courseType": "course",
|
||||||
|
"image": Object {
|
||||||
|
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||||
|
},
|
||||||
|
"owners": Array [
|
||||||
|
Object {
|
||||||
|
"key": "HarvardX",
|
||||||
|
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||||
|
"name": "Harvard University",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prospectusPath": "course/introduction-to-computer-sceince",
|
||||||
|
"title": "Introduction to Computer Science",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
`;
|
||||||
10
src/widgets/ProductRecommendations/api.js
Normal file
10
src/widgets/ProductRecommendations/api.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
17
src/widgets/ProductRecommendations/api.test.js
Normal file
17
src/widgets/ProductRecommendations/api.test.js
Normal file
@@ -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'))),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
48
src/widgets/ProductRecommendations/components/LoadedView.jsx
Normal file
48
src/widgets/ProductRecommendations/components/LoadedView.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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 ? openCourses.slice(0, 2) : [];
|
||||||
|
return crossProductCourses.concat(openCourseList);
|
||||||
|
}
|
||||||
|
return openCourses;
|
||||||
|
}, [crossProductCourses, openCourses, includesCrossProductTypes]);
|
||||||
|
|
||||||
|
const courseTypes = [...new Set(finalProductList.map((item) => courseTypeToProductTypeMap[item.courseType]))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-light-200">
|
||||||
|
<Container
|
||||||
|
size="lg"
|
||||||
|
className="recommendations-container pt-sm-5 pt-4.5 pb-2 pb-sm-4.5"
|
||||||
|
>
|
||||||
|
<h2>
|
||||||
|
{formatMessage(messages.recommendationsHeading)}
|
||||||
|
</h2>
|
||||||
|
<ProductCardContainer finalProductList={finalProductList} courseTypes={courseTypes} />
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
LoadedView.propTypes = {
|
||||||
|
crossProductCourses: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape(courseShape),
|
||||||
|
).isRequired,
|
||||||
|
openCourses: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape(courseShape),
|
||||||
|
).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadedView;
|
||||||
@@ -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(
|
||||||
|
<LoadedView
|
||||||
|
crossProductCourses={mockCrossProductCourses}
|
||||||
|
openCourses={mockOpenCourses}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
describe('with less than 2 cross product courses', () => {
|
||||||
|
it('passes in one course type and 4 open courses to the ProductCardContainer props', () => {
|
||||||
|
const wrapper = shallow(
|
||||||
|
<LoadedView
|
||||||
|
crossProductCourses={[mockCrossProductCourses[0]]}
|
||||||
|
openCourses={mockOpenCourses}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const productCardContainerProps = wrapper.find('ProductCardContainer').props();
|
||||||
|
|
||||||
|
expect(productCardContainerProps.courseTypes.length).toEqual(1);
|
||||||
|
expect(productCardContainerProps.courseTypes[0]).toEqual('Course');
|
||||||
|
expect(productCardContainerProps.finalProductList).toEqual(mockOpenCourses);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Skeleton } from '@edx/paragon';
|
||||||
|
|
||||||
|
export const LoadingView = () => (
|
||||||
|
<Skeleton height={100} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export default LoadingView;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import LoadingView from './LoadingView';
|
||||||
|
|
||||||
|
describe('ProductRecommendations LoadingView', () => {
|
||||||
|
it('matches snapshot', () => {
|
||||||
|
expect(shallow(<LoadingView />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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,
|
||||||
|
}) => (
|
||||||
|
<div className="base-card-wrapper">
|
||||||
|
<Hyperlink destination={url}>
|
||||||
|
<Card className="base-card light" variant="light">
|
||||||
|
<Card.ImageCap
|
||||||
|
src={headerImage}
|
||||||
|
srcAlt={`header image for ${title}`}
|
||||||
|
logoSrc={schoolLogo}
|
||||||
|
logoAlt={`logo for ${subtitle}`}
|
||||||
|
/>
|
||||||
|
<Card.Header
|
||||||
|
className="mt-2"
|
||||||
|
title={(
|
||||||
|
<Truncate lines={3} ellipsis="…" className="product-card-title">
|
||||||
|
{title}
|
||||||
|
</Truncate>
|
||||||
|
)}
|
||||||
|
subtitle={(
|
||||||
|
<Truncate lines={1} className="product-card-subtitle">
|
||||||
|
{subtitle}
|
||||||
|
</Truncate>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Card.Section>
|
||||||
|
<div className="product-badge">
|
||||||
|
<Badge>{courseType}</Badge>
|
||||||
|
</div>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</Hyperlink>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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(<ProductCard {...props} />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }) => (
|
||||||
|
<div className="product-card-container d-flex">
|
||||||
|
{finalProductList
|
||||||
|
&& courseTypes.map((type) => (
|
||||||
|
<div key={type}>
|
||||||
|
<ProductCardHeader courseType={type} />
|
||||||
|
<div
|
||||||
|
className={classNames({
|
||||||
|
'course-subcontainer': type === 'Course',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{finalProductList
|
||||||
|
.filter((course) => courseTypeToProductTypeMap[course.courseType] === type)
|
||||||
|
.map((item) => (
|
||||||
|
<ProductCard
|
||||||
|
key={item.title}
|
||||||
|
url={`https://www.edx.org/${item.prospectusPath}`}
|
||||||
|
title={item.title}
|
||||||
|
subtitle={item.owners[0].name}
|
||||||
|
headerImage={item.image.src}
|
||||||
|
schoolLogo={item.owners[0].logoImageUrl}
|
||||||
|
courseType={type}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
ProductCardContainer.propTypes = {
|
||||||
|
finalProductList: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape(courseShape),
|
||||||
|
).isRequired,
|
||||||
|
courseTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCardContainer;
|
||||||
@@ -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(<ProductCardContainer {...props} />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with finalCourseList containing cross product and open courses', () => {
|
||||||
|
it('renders 3 ProductCardHeaders with the 3 different course types', () => {
|
||||||
|
const wrapper = shallow(<ProductCardContainer {...props} />);
|
||||||
|
const productCardHeaders = wrapper.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(<ProductCardContainer {...openCoursesProps} />);
|
||||||
|
const productCardHeaders = wrapper.find('ProductCardHeader');
|
||||||
|
|
||||||
|
expect(productCardHeaders.length).toEqual(1);
|
||||||
|
expect(productCardHeaders.at(0).props().courseType).toEqual(openCoursesProps.courseTypes[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
case 'Boot Camp':
|
||||||
|
return {
|
||||||
|
heading: messages.bootcampHeading,
|
||||||
|
description: messages.bootcampDescription,
|
||||||
|
url: '/boot-camps',
|
||||||
|
};
|
||||||
|
default: {
|
||||||
|
return {
|
||||||
|
heading: messages.courseHeading,
|
||||||
|
description: messages.courseDescription,
|
||||||
|
url: '/search?tab=course',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const productTypeDetail = getProductTypeDetail(courseType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Hyperlink
|
||||||
|
destination={`https://www.edx.org${productTypeDetail.url}`}
|
||||||
|
className="base-card-link"
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center border-bottom">
|
||||||
|
<h3 className={classNames('h3 mb-2 text-left')}>
|
||||||
|
{formatMessage(productTypeDetail.heading)}
|
||||||
|
</h3>
|
||||||
|
<Icon src={ChevronRight} className="text-primary-500 ml-2.5" />
|
||||||
|
</div>
|
||||||
|
</Hyperlink>
|
||||||
|
<p className="text-gray-500 x-small mt-2 mb-2">
|
||||||
|
{formatMessage(productTypeDetail.description)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProductCardHeader.propTypes = {
|
||||||
|
courseType: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCardHeader;
|
||||||
@@ -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(<ProductCardHeader courseType={executiveEducationType} />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
describe('with bootcamp courseType prop', () => {
|
||||||
|
it('renders a bootcamp header', () => {
|
||||||
|
const wrapper = shallow(<ProductCardHeader courseType={bootCampType} />);
|
||||||
|
|
||||||
|
expect(wrapper.find('h3').text()).toEqual(bootCampType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with course courseType prop', () => {
|
||||||
|
it('renders a courses header', () => {
|
||||||
|
const wrapper = shallow(<ProductCardHeader courseType={courseType} />);
|
||||||
|
|
||||||
|
expect(wrapper.find('h3').text()).toEqual(courseType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ProductRecommendations LoadedView matches snapshot 1`] = `
|
||||||
|
<div
|
||||||
|
className="bg-light-200"
|
||||||
|
>
|
||||||
|
<Container
|
||||||
|
className="recommendations-container pt-sm-5 pt-4.5 pb-2 pb-sm-4.5"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<h2>
|
||||||
|
You might also like
|
||||||
|
</h2>
|
||||||
|
<ProductCardContainer
|
||||||
|
courseTypes={
|
||||||
|
Array [
|
||||||
|
"Executive Education",
|
||||||
|
"Boot Camp",
|
||||||
|
"Course",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
finalProductList={
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"courseType": "executive-education-2u",
|
||||||
|
"image": Object {
|
||||||
|
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||||
|
},
|
||||||
|
"owners": Array [
|
||||||
|
Object {
|
||||||
|
"key": "HarvardX",
|
||||||
|
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||||
|
"name": "Harvard University",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prospectusPath": "course/introduction-to-computer-sceince",
|
||||||
|
"title": "Introduction to Computer Science",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"courseType": "bootcamp-2u",
|
||||||
|
"image": Object {
|
||||||
|
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||||
|
},
|
||||||
|
"owners": Array [
|
||||||
|
Object {
|
||||||
|
"key": "HarvardX",
|
||||||
|
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||||
|
"name": "Harvard University",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prospectusPath": "course/introduction-to-computer-sceince",
|
||||||
|
"title": "Introduction to Computer Science",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"courseType": "verified-audit",
|
||||||
|
"image": Object {
|
||||||
|
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||||
|
},
|
||||||
|
"owners": Array [
|
||||||
|
Object {
|
||||||
|
"key": "HarvardX",
|
||||||
|
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||||
|
"name": "Harvard University",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prospectusPath": "course/introduction-to-computer-sceince",
|
||||||
|
"title": "Introduction to Computer Science",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"courseType": "audit",
|
||||||
|
"image": Object {
|
||||||
|
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
|
||||||
|
},
|
||||||
|
"owners": Array [
|
||||||
|
Object {
|
||||||
|
"key": "HarvardX",
|
||||||
|
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
|
||||||
|
"name": "Harvard University",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prospectusPath": "course/introduction-to-computer-sceince",
|
||||||
|
"title": "Introduction to Computer Science",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ProductRecommendations LoadingView matches snapshot 1`] = `
|
||||||
|
<Skeleton
|
||||||
|
height={100}
|
||||||
|
/>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ProductRecommendations ProductCard matches snapshot 1`] = `
|
||||||
|
<div
|
||||||
|
className="base-card-wrapper"
|
||||||
|
>
|
||||||
|
<Hyperlink
|
||||||
|
destination="https://www.edx.org/course/introduction-to-computer-sceince"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className="base-card light"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
<Card.ImageCap
|
||||||
|
logoAlt="logo for Harvard University"
|
||||||
|
logoSrc="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||||
|
src="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||||
|
srcAlt="header image for Introduction to Computer Science"
|
||||||
|
/>
|
||||||
|
<Card.Header
|
||||||
|
className="mt-2"
|
||||||
|
subtitle={
|
||||||
|
<Truncate
|
||||||
|
className="product-card-subtitle"
|
||||||
|
lines={1}
|
||||||
|
>
|
||||||
|
Harvard University
|
||||||
|
</Truncate>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<Truncate
|
||||||
|
className="product-card-title"
|
||||||
|
ellipsis="…"
|
||||||
|
lines={3}
|
||||||
|
>
|
||||||
|
Introduction to Computer Science
|
||||||
|
</Truncate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Card.Section>
|
||||||
|
<div
|
||||||
|
className="product-badge"
|
||||||
|
>
|
||||||
|
<Badge>
|
||||||
|
Executive Education
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</Hyperlink>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ProductRecommendations ProductCardContainer matches snapshot 1`] = `
|
||||||
|
<div
|
||||||
|
className="product-card-container d-flex"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
key="Executive Education"
|
||||||
|
>
|
||||||
|
<ProductCardHeader
|
||||||
|
courseType="Executive Education"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className=""
|
||||||
|
>
|
||||||
|
<ProductCard
|
||||||
|
courseType="Executive Education"
|
||||||
|
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||||
|
key="Introduction to Computer Science"
|
||||||
|
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||||
|
subtitle="Harvard University"
|
||||||
|
title="Introduction to Computer Science"
|
||||||
|
url="https://www.edx.org/course/introduction-to-computer-sceince"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key="Boot Camp"
|
||||||
|
>
|
||||||
|
<ProductCardHeader
|
||||||
|
courseType="Boot Camp"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className=""
|
||||||
|
>
|
||||||
|
<ProductCard
|
||||||
|
courseType="Boot Camp"
|
||||||
|
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||||
|
key="Introduction to Computer Science"
|
||||||
|
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||||
|
subtitle="Harvard University"
|
||||||
|
title="Introduction to Computer Science"
|
||||||
|
url="https://www.edx.org/course/introduction-to-computer-sceince"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key="Course"
|
||||||
|
>
|
||||||
|
<ProductCardHeader
|
||||||
|
courseType="Course"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="course-subcontainer"
|
||||||
|
>
|
||||||
|
<ProductCard
|
||||||
|
courseType="Course"
|
||||||
|
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||||
|
key="Introduction to Computer Science"
|
||||||
|
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||||
|
subtitle="Harvard University"
|
||||||
|
title="Introduction to Computer Science"
|
||||||
|
url="https://www.edx.org/course/introduction-to-computer-sceince"
|
||||||
|
/>
|
||||||
|
<ProductCard
|
||||||
|
courseType="Course"
|
||||||
|
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||||
|
key="Introduction to Computer Science"
|
||||||
|
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||||
|
subtitle="Harvard University"
|
||||||
|
title="Introduction to Computer Science"
|
||||||
|
url="https://www.edx.org/course/introduction-to-computer-sceince"
|
||||||
|
/>
|
||||||
|
<ProductCard
|
||||||
|
courseType="Course"
|
||||||
|
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||||
|
key="Introduction to Computer Science"
|
||||||
|
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||||
|
subtitle="Harvard University"
|
||||||
|
title="Introduction to Computer Science"
|
||||||
|
url="https://www.edx.org/course/introduction-to-computer-sceince"
|
||||||
|
/>
|
||||||
|
<ProductCard
|
||||||
|
courseType="Course"
|
||||||
|
headerImage="https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg"
|
||||||
|
key="Introduction to Computer Science"
|
||||||
|
schoolLogo="http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png"
|
||||||
|
subtitle="Harvard University"
|
||||||
|
title="Introduction to Computer Science"
|
||||||
|
url="https://www.edx.org/course/introduction-to-computer-sceince"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ProductRecommendations ProductCardHeader matches snapshot 1`] = `
|
||||||
|
<div>
|
||||||
|
<Hyperlink
|
||||||
|
className="base-card-link"
|
||||||
|
destination="https://www.edx.org/executive-education"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center border-bottom"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className="h3 mb-2 text-left"
|
||||||
|
>
|
||||||
|
Executive Education
|
||||||
|
</h3>
|
||||||
|
<Icon
|
||||||
|
className="text-primary-500 ml-2.5"
|
||||||
|
src={[MockFunction icons.ChevronRight]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Hyperlink>
|
||||||
|
<p
|
||||||
|
className="text-gray-500 x-small mt-2 mb-2"
|
||||||
|
>
|
||||||
|
Short Courses to develop leadership skills
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
63
src/widgets/ProductRecommendations/hooks.js
Normal file
63
src/widgets/ProductRecommendations/hooks.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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
|
||||||
|
data: (val) => useState(val), // eslint-disable-line
|
||||||
|
});
|
||||||
|
|
||||||
|
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 };
|
||||||
190
src/widgets/ProductRecommendations/hooks.test.js
Normal file
190
src/widgets/ProductRecommendations/hooks.test.js
Normal file
@@ -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 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();
|
||||||
|
await resolveFn(response);
|
||||||
|
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();
|
||||||
|
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('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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
35
src/widgets/ProductRecommendations/index.jsx
Normal file
35
src/widgets/ProductRecommendations/index.jsx
Normal file
@@ -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 <LoadingView />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoaded && !isMobile && !hasFailed) {
|
||||||
|
return (
|
||||||
|
<LoadedView
|
||||||
|
openCourses={productRecommendations.amplitudeCourses}
|
||||||
|
crossProductCourses={productRecommendations.crossProductCourses}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductRecommendations;
|
||||||
105
src/widgets/ProductRecommendations/index.scss
Normal file
105
src/widgets/ProductRecommendations/index.scss
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
@import "@edx/paragon/scss/core/core";
|
||||||
|
|
||||||
|
$horizontal-card-gap: 20px;
|
||||||
|
$vertical-card-gap: 24px;
|
||||||
|
|
||||||
|
.base-card {
|
||||||
|
height: 332px;
|
||||||
|
width: 270px !important;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
line-height: 24px ;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-subtitle {
|
||||||
|
font: {
|
||||||
|
size: 0.875rem;
|
||||||
|
weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.light {
|
||||||
|
background-color: $white;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: $black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
background-color: $light-500;
|
||||||
|
color: $black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&: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:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-card-link .base-card {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-container {
|
||||||
|
gap: $vertical-card-gap $horizontal-card-gap;
|
||||||
|
margin: 0 (-$horizontal-card-gap);
|
||||||
|
padding: 1rem $horizontal-card-gap;
|
||||||
|
|
||||||
|
.course-subcontainer {
|
||||||
|
display: flex;
|
||||||
|
gap: $vertical-card-gap $horizontal-card-gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-down(lg) {
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/widgets/ProductRecommendations/index.test.jsx
Normal file
91
src/widgets/ProductRecommendations/index.test.jsx
Normal file
@@ -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(<ProductRecommendations />)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
it('renders the LoadedView with course data if the request completed', () => {
|
||||||
|
useWindowSize.mockReturnValueOnce(desktopWindowSize);
|
||||||
|
hooks.useProductRecommendationsData.mockReturnValueOnce({
|
||||||
|
...successfullLoadValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shallow(<ProductRecommendations />)).toMatchObject(
|
||||||
|
shallow(
|
||||||
|
<LoadedView
|
||||||
|
openCourses={mockResponse.amplitudeCourses}
|
||||||
|
crossProductCourses={mockResponse.crossProductCourses}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('renders the LoadingView if the request is pending', () => {
|
||||||
|
useWindowSize.mockReturnValueOnce(desktopWindowSize);
|
||||||
|
hooks.useProductRecommendationsData.mockReturnValueOnce({
|
||||||
|
...defaultValues,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shallow(<ProductRecommendations />)).toMatchObject(
|
||||||
|
shallow(<LoadingView />),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('renders nothing if the request has failed', () => {
|
||||||
|
useWindowSize.mockReturnValueOnce(desktopWindowSize);
|
||||||
|
hooks.useProductRecommendationsData.mockReturnValueOnce({
|
||||||
|
...defaultValues,
|
||||||
|
hasFailed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = shallow(<ProductRecommendations />);
|
||||||
|
|
||||||
|
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(<ProductRecommendations />);
|
||||||
|
|
||||||
|
expect(wrapper.type()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
src/widgets/ProductRecommendations/messages.js
Normal file
41
src/widgets/ProductRecommendations/messages.js
Normal file
@@ -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;
|
||||||
31
src/widgets/ProductRecommendations/testData.js
Normal file
31
src/widgets/ProductRecommendations/testData.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
42
src/widgets/ProductRecommendations/utils.js
Normal file
42
src/widgets/ProductRecommendations/utils.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user