Compare commits

..

13 Commits

Author SHA1 Message Date
Jody Bailey
b1e4e2d414 fix: Merge branch 'master' into development branch 2023-06-12 16:43:28 +02:00
Jody Bailey
f4c27f02ba fix: render logic for open courses and test coverage fix 2023-06-06 16:56:13 +02:00
Jody Bailey
55a647bb5b test: additional product card header tests 2023-06-06 13:45:38 +02:00
Jody Bailey
c3b98d954e fix: additional linting + clarity changes 2023-06-06 10:29:15 +02:00
Jody Bailey
91a694736a fix: final confirmed product changes 2023-06-05 17:23:08 +02:00
Jody Bailey
7fe3bf7ab8 fix: Merge branch 'master' into jodybaileyy/add-query-logic-to-CPR-container 2023-06-02 13:48:27 +02:00
Jody Bailey
68db9a9829 feat: Initial render logic for cross product recommendations experiment 2023-06-02 13:45:22 +02:00
Jody Bailey
bdf3870808 chore: attempt at fixing query to CPR endpoint 2023-05-16 11:04:25 +02:00
Jody Bailey
69e7c71885 fix: Merge branch 'master' into jodybaileyy/add-query-logic-to-CPR-container 2023-05-16 09:42:38 +02:00
Jody Bailey
cd7650ab42 chore: added logging for debugginh 2023-05-16 09:26:46 +02:00
Jody Bailey
7bd3452dc3 feat: inital query logic to cross product endpoint 2023-05-16 08:51:40 +02:00
Jody Bailey
be1e1bf7d9 fix: core mark-up and styling for cross product recommendations container 2023-05-15 15:25:33 +02:00
Jody Bailey
807d9f70b8 feat: add cross product recommendations widget 2023-05-11 11:48:08 +02:00
55 changed files with 1608 additions and 125 deletions

View File

@@ -5,7 +5,6 @@ const config = createConfig('eslint', {
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off',
'import/no-self-import': 'off',
'import/no-import-module-exports': 'off',
'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
},
});

View File

@@ -1,5 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseCardMenu disable and stop rendering buttons snapshot when no dropdown items exist 1`] = `
<Fragment>
<Dropdown>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
iconAs="Icon"
id="course-actions-dropdown-test-card-id"
src={[MockFunction icons.MoreVert]}
variant="primary"
/>
<Dropdown.Menu>
<Dropdown.Item
data-testid="unenrollModalToggle"
disabled={false}
onClick={[MockFunction unenrollShow]}
>
Unenroll
</Dropdown.Item>
<Dropdown.Item
data-testid="emailSettingsModalToggle"
disabled={false}
onClick={[MockFunction emailSettingShow]}
>
Email settings
</Dropdown.Item>
<FacebookShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction facebookShareClick]}
resetButtonStyle={false}
title="I'm taking test-course-name online with facebook-social-brand. Check it out!"
url="facebook-share-url"
>
Share to Facebook
</FacebookShareButton>
<TwitterShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction twitterShareClick]}
resetButtonStyle={false}
title="I'm taking test-course-name online with twitter-social-brand. Check it out!"
url="twitter-share-url"
>
Share to Twitter
</TwitterShareButton>
</Dropdown.Menu>
</Dropdown>
<UnenrollConfirmModal
cardId="test-card-id"
closeModal={[MockFunction unenrollHide]}
show={false}
/>
<EmailSettingsModal
cardId="test-card-id"
closeModal={[MockFunction emailSettingHide]}
show={false}
/>
</Fragment>
`;
exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1`] = `
<Fragment>
<Dropdown>
@@ -117,24 +176,3 @@ exports[`CourseCardMenu masquerading snapshot 1`] = `
/>
</Fragment>
`;
exports[`CourseCardMenu not enrolled, share disabled, email setting disabled snapshot 1`] = `
<Fragment>
<Dropdown>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
iconAs="Icon"
id="course-actions-dropdown-test-card-id"
src={[MockFunction icons.MoreVert]}
variant="primary"
/>
<Dropdown.Menu />
</Dropdown>
<UnenrollConfirmModal
cardId="test-card-id"
closeModal={[MockFunction unenrollHide]}
show={false}
/>
</Fragment>
`;

View File

@@ -25,6 +25,7 @@ export const CourseCardMenu = ({ cardId }) => {
const { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { isEarned } = reduxHooks.useCardCertificateData(cardId);
const handleTwitterShare = reduxHooks.useTrackCourseEvent(
track.socialShare,
cardId,
@@ -40,6 +41,13 @@ export const CourseCardMenu = ({ cardId }) => {
const unenrollModal = useUnenrollData();
const handleToggleDropdown = useHandleToggleDropdown(cardId);
const showUnenrollItem = isEnrolled && !isEarned;
const showDropdown = showUnenrollItem || isEmailEnabled || facebook.isEnabled || twitter.isEnabled;
if (!showDropdown) {
return null;
}
return (
<>
<Dropdown onToggle={handleToggleDropdown}>
@@ -52,7 +60,7 @@ export const CourseCardMenu = ({ cardId }) => {
alt={formatMessage(messages.dropdownAlt)}
/>
<Dropdown.Menu>
{isEnrolled && (
{showUnenrollItem && (
<Dropdown.Item
disabled={isMasquerading}
onClick={unenrollModal.show}

View File

@@ -14,6 +14,7 @@ jest.mock('hooks', () => ({
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useMasqueradeData: jest.fn(),
useCardCertificateData: jest.fn(),
useTrackCourseEvent: (_, __, site) => jest.fn().mockName(`${site}ShareClick`),
},
}));
@@ -53,17 +54,43 @@ let wrapper;
let el;
describe('CourseCardMenu', () => {
beforeEach(() => {
useEmailSettings.mockReturnValue(defaultEmailSettingsModal);
useUnenrollData.mockReturnValue(defaultUnenrollModal);
reduxHooks.useCardSocialSettingsData.mockReturnValue(defaultSocialShare);
reduxHooks.useCardCourseData.mockReturnValue({ courseName });
reduxHooks.useCardEnrollmentData.mockReturnValue({ isEnrolled: true, isEmailEnabled: true });
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
});
const mockCourseCardMenu = ({
isEnrolled,
isEmailEnabled,
isMasquerading,
facebook,
twitter,
isEarned,
}) => {
useEmailSettings.mockReturnValueOnce(defaultEmailSettingsModal);
useUnenrollData.mockReturnValueOnce(defaultUnenrollModal);
reduxHooks.useCardCourseData.mockReturnValueOnce({ courseName });
reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({
facebook: {
...defaultSocialShare.facebook,
...facebook,
},
twitter: {
...defaultSocialShare.twitter,
...twitter,
},
});
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
isEnrolled,
isEmailEnabled,
});
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading });
reduxHooks.useCardCertificateData.mockReturnValueOnce({ isEarned });
return shallow(<CourseCardMenu {...props} />);
};
describe('enrolled, share enabled, email setting enable', () => {
beforeEach(() => {
wrapper = shallow(<CourseCardMenu {...props} />);
wrapper = mockCourseCardMenu({
isEnrolled: true,
isEmailEnabled: true,
isMasquerading: false,
isEarned: false,
});
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
@@ -89,36 +116,84 @@ describe('CourseCardMenu', () => {
expect(el.props().disabled).toEqual(false);
});
});
describe('not enrolled, share disabled, email setting disabled', () => {
beforeEach(() => {
reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({
...defaultSocialShare,
twitter: { ...defaultSocialShare.twitter, isEnabled: false },
facebook: { ...defaultSocialShare.facebook, isEnabled: false },
describe('disable and stop rendering buttons', () => {
it('does not render unenroll dropdown item when certificate is already earned', () => {
wrapper = mockCourseCardMenu({
isEnrolled: true,
isEmailEnabled: true,
isMasquerading: false,
isEarned: true,
});
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: false, isEmailEnabled: false });
wrapper = shallow(<CourseCardMenu {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('does not renders share buttons', () => {
expect(wrapper.find('FacebookShareButton').length).toEqual(0);
expect(wrapper.find('TwitterShareButton').length).toEqual(0);
});
it('does not render unenroll modal toggle', () => {
el = wrapper.find({ 'data-testid': 'unenrollModalToggle' });
expect(el.length).toEqual(0);
});
it('does not render email settings modal toggle', () => {
it('does not render unenroll dropdown item when course is not enrolled', () => {
wrapper = mockCourseCardMenu({
isEnrolled: false,
isEmailEnabled: true,
isMasquerading: false,
isEarned: false,
});
el = wrapper.find({ 'data-testid': 'unenrollModalToggle' });
expect(el.length).toEqual(0);
});
it('does not render email settings modal toggle when email is not enabled', () => {
wrapper = mockCourseCardMenu({
isEnrolled: true,
isEmailEnabled: false,
isMasquerading: false,
isEarned: false,
});
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.length).toEqual(0);
});
it('does not render facebook share button when facebook is not enabled', () => {
wrapper = mockCourseCardMenu({
isEnrolled: true,
isEmailEnabled: true,
facebook: {
...defaultSocialShare.facebook,
isEnabled: false,
},
isMasquerading: false,
isEarned: false,
});
el = wrapper.find('FacebookShareButton');
expect(el.length).toEqual(0);
});
it('does not render twitter share button when twitter is not enabled', () => {
wrapper = mockCourseCardMenu({
isEnrolled: true,
isEmailEnabled: true,
twitter: {
...defaultSocialShare.twitter,
isEnabled: false,
},
isMasquerading: false,
isEarned: false,
});
el = wrapper.find('TwitterShareButton');
expect(el.length).toEqual(0);
});
it('snapshot when no dropdown items exist', () => {
wrapper = mockCourseCardMenu({
isEnrolled: true,
isEmailEnabled: true,
isMasquerading: false,
isEarned: false,
});
expect(wrapper).toMatchSnapshot();
expect(wrapper).toEqual({});
});
});
describe('masquerading', () => {
beforeEach(() => {
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
wrapper = shallow(<CourseCardMenu {...props} />);
wrapper = mockCourseCardMenu({
isEnrolled: true,
isEmailEnabled: true,
isMasquerading: true,
isEarned: false,
});
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();

View File

@@ -17,7 +17,7 @@ exports[`NoCoursesView snapshot 1`] = `
</p>
<Button
as="a"
href="http://localhost:18000/course-search-url"
href="course-search-url"
iconBefore={[MockFunction icons.Search]}
variant="brand"
>

View File

@@ -2,7 +2,6 @@ import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Image } from '@edx/paragon';
import { Search } from '@edx/paragon/icons';
import { baseAppUrl } from 'data/services/lms/urls';
import emptyCourseSVG from 'assets/empty-course.svg';
import { reduxHooks } from 'hooks';
@@ -28,7 +27,7 @@ export const NoCoursesView = () => {
<Button
variant="brand"
as="a"
href={baseAppUrl(courseSearchUrl)}
href={courseSearchUrl}
iconBefore={Search}
>
{formatMessage(messages.exploreCoursesButton)}

View File

@@ -6,7 +6,7 @@ import EmptyCourse from '.';
jest.mock('hooks', () => ({
reduxHooks: {
usePlatformSettingsData: jest.fn(() => ({
courseSearchUrl: '/course-search-url',
courseSearchUrl: 'course-search-url',
})),
},
}));

View File

@@ -13,6 +13,7 @@ import LoadingView from './LoadingView';
import DashboardLayout from './DashboardLayout';
import hooks from './hooks';
import './index.scss';
import ProductRecommendations from '../../widgets/ProductRecommendations';
export const Dashboard = () => {
hooks.useInitializeDashboard();
@@ -21,6 +22,10 @@ export const Dashboard = () => {
const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards();
const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
const showSelectSessionModal = reduxHooks.useShowSelectSessionModal();
// Hard coded to not show until experiment set-up logic is implemented
const showProductRecommendations = false && !initIsPending && !hasAvailableDashboards && hasCourses;
return (
<div id="dashboard-container" className="d-flex flex-column p-2 pt-0">
<h1 className="sr-only">{pageTitle}</h1>
@@ -39,6 +44,7 @@ export const Dashboard = () => {
</DashboardLayout>
)}
</div>
{showProductRecommendations && <ProductRecommendations />}
</div>
);
};

View File

@@ -21,7 +21,7 @@ export const CollapseMenuBody = ({ isOpen }) => {
const dashboard = reduxHooks.useEnterpriseDashboardData();
const { courseSearchUrl } = reduxHooks.usePlatformSettingsData();
const exploreCoursesClick = findCoursesNavDropdownClicked(urls.baseAppUrl(courseSearchUrl));
const exploreCoursesClick = findCoursesNavDropdownClicked(courseSearchUrl);
return (
isOpen && (
@@ -34,7 +34,7 @@ export const CollapseMenuBody = ({ isOpen }) => {
</Button>
<Button
as="a"
href={urls.baseAppUrl(courseSearchUrl)}
href={courseSearchUrl}
variant="inverse-primary"
onClick={exploreCoursesClick}
>

View File

@@ -17,7 +17,7 @@ jest.mock('hooks', () => ({
url: 'url',
}),
usePlatformSettingsData: () => ({
courseSearchUrl: '/courseSearchUrl',
courseSearchUrl: 'courseSearchUrl',
}),
},
}));

View File

@@ -20,8 +20,8 @@ exports[`CollapseMenuBody render 1`] = `
</Button>
<Button
as="a"
href="http://localhost:18000/courseSearchUrl"
onClick={[MockFunction findCoursesNavDropdownClicked("http://localhost:18000/courseSearchUrl")]}
href="courseSearchUrl"
onClick={[MockFunction findCoursesNavDropdownClicked("courseSearchUrl")]}
variant="inverse-primary"
>
Discover New
@@ -86,8 +86,8 @@ exports[`CollapseMenuBody render unauthenticated 1`] = `
</Button>
<Button
as="a"
href="http://localhost:18000/courseSearchUrl"
onClick={[MockFunction findCoursesNavDropdownClicked("http://localhost:18000/courseSearchUrl")]}
href="courseSearchUrl"
onClick={[MockFunction findCoursesNavDropdownClicked("courseSearchUrl")]}
variant="inverse-primary"
>
Discover New

View File

@@ -9,7 +9,6 @@ import { useIsCollapsed } from '../hooks';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('@edx/frontend-platform/react', () => ({
AppContext: {
authenticatedUser: {
@@ -18,13 +17,11 @@ jest.mock('@edx/frontend-platform/react', () => ({
},
},
}));
const COURSE_SEARCH_URL = 'test-course-search-url';
jest.mock('hooks', () => ({
reduxHooks: {
useEnterpriseDashboardData: jest.fn(),
usePlatformSettingsData: jest.fn(() => ({
courseSearchUrl: COURSE_SEARCH_URL,
courseSearchUrl: 'test-course-search-url',
})),
},
}));
@@ -33,11 +30,6 @@ jest.mock('../hooks', () => ({
findCoursesNavDropdownClicked: (href) => jest.fn().mockName(`findCoursesNavDropdownClicked('${href}')`),
}));
jest.mock('data/services/lms/urls', () => ({
baseAppUrl: (url) => (url),
programsUrl: 'http://localhost:18000/dashboard/programs',
}));
const config = {
ACCOUNT_PROFILE_URL: 'http://account-profile-url.test',
ACCOUNT_SETTINGS_URL: 'http://account-settings-url.test',
@@ -45,7 +37,6 @@ const config = {
ORDER_HISTORY_URL: 'http://order-history-url.test',
SUPPORT_URL: 'http://localhost:18000/support',
CAREER_LINK_URL: 'http://localhost:18000/career',
LMS_BASE_URL: 'http:/localhost:18000',
};
getConfig.mockReturnValue(config);

View File

@@ -27,8 +27,8 @@ exports[`ExpandedHeader render 1`] = `
<Button
as="a"
className="p-4"
href="http://localhost:18000/courseSearchUrl"
onClick={[MockFunction findCoursesNavClicked("http://localhost:18000/courseSearchUrl")]}
href="courseSearchUrl"
onClick={[MockFunction findCoursesNavClicked("courseSearchUrl")]}
variant="inverse-primary"
>
Discover New

View File

@@ -18,7 +18,7 @@ export const ExpandedHeader = () => {
const { courseSearchUrl } = reduxHooks.usePlatformSettingsData();
const isCollapsed = useIsCollapsed();
const exploreCoursesClick = findCoursesNavClicked(urls.baseAppUrl(courseSearchUrl));
const exploreCoursesClick = findCoursesNavClicked(courseSearchUrl);
return (
!isCollapsed && (
@@ -44,7 +44,7 @@ export const ExpandedHeader = () => {
</Button>
<Button
as="a"
href={urls.baseAppUrl(courseSearchUrl)}
href={courseSearchUrl}
variant="inverse-primary"
className="p-4"
onClick={exploreCoursesClick}

View File

@@ -6,13 +6,12 @@ import { useIsCollapsed } from '../hooks';
jest.mock('data/services/lms/urls', () => ({
programsUrl: 'programsUrl',
baseAppUrl: url => (`http://localhost:18000${url}`),
}));
jest.mock('hooks', () => ({
reduxHooks: {
usePlatformSettingsData: () => ({
courseSearchUrl: '/courseSearchUrl',
courseSearchUrl: 'courseSearchUrl',
}),
},
}));

View File

@@ -24,6 +24,7 @@ export const courseCard = StrictDict({
isDownloadable: certificate.isDownloadable,
isEarnedButUnavailable: certificate.isEarned && !isAvailable,
isRestricted: certificate.isRestricted,
isEarned: certificate.isEarned,
};
},
),

View File

@@ -17,7 +17,7 @@ import * as module from './api';
* GET Actions
*********************************************************************************/
export const initializeList = ({ user } = {}) => get(
stringifyUrl(urls.getInitApiUrl(), { [apiKeys.user]: user }),
stringifyUrl(urls.init, { [apiKeys.user]: user }),
);
export const updateEntitlementEnrollment = ({ uuid, courseId }) => post(

View File

@@ -43,7 +43,7 @@ describe('lms api methods', () => {
[apiKeys.user]: testUser,
};
expect(api.initializeList(userArg)).toEqual(
utils.get(utils.stringifyUrl(urls.getInitApiUrl(), userArg)),
utils.get(utils.stringifyUrl(urls.init, userArg)),
);
});
});

View File

@@ -1,41 +1,40 @@
import { StrictDict } from 'utils';
import { configuration } from 'config';
import { getConfig } from '@edx/frontend-platform';
const baseUrl = `${configuration.LMS_BASE_URL}`;
export const ecommerceUrl = `${configuration.ECOMMERCE_BASE_URL}`;
export const getEcommerceUrl = () => getConfig().ECOMMERCE_BASE_URL;
export const api = `${baseUrl}/api`;
const getBaseUrl = () => getConfig().LMS_BASE_URL;
// const init = `${api}learner_home/mock/init`; // mock endpoint for testing
const init = `${api}/learner_home/init`;
export const getApiUrl = () => (`${getConfig().LMS_BASE_URL}/api`);
const getInitApiUrl = () => (`${getApiUrl()}/learner_home/init`);
const event = `${getBaseUrl()}/event`;
const courseUnenroll = `${getBaseUrl()}/change_enrollment`;
const updateEmailSettings = `${getApiUrl()}/change_email_settings`;
const entitlementEnrollment = (uuid) => `${getApiUrl()}/entitlements/v1/entitlements/${uuid}/enrollments`;
const event = `${baseUrl}/event`;
const courseUnenroll = `${baseUrl}/change_enrollment`;
const updateEmailSettings = `${api}/change_email_settings`;
const entitlementEnrollment = (uuid) => `${api}/entitlements/v1/entitlements/${uuid}/enrollments`;
// if url is null or absolute, return it as is
export const updateUrl = (base, url) => ((url == null || url.startsWith('http://') || url.startsWith('https://')) ? url : `${base}${url}`);
const updateUrl = (base, url) => ((url == null || url.startsWith('http://') || url.startsWith('https://')) ? url : `${base}${url}`);
export const baseAppUrl = (url) => updateUrl(getBaseUrl(), url);
export const learningMfeUrl = (url) => updateUrl(getConfig().LEARNING_BASE_URL, url);
export const baseAppUrl = (url) => updateUrl(baseUrl, url);
export const learningMfeUrl = (url) => updateUrl(configuration.LEARNING_BASE_URL, url);
// static view url
const programsUrl = baseAppUrl('/dashboard/programs');
export const creditPurchaseUrl = (courseId) => `${getEcommerceUrl()}/credit/checkout/${courseId}/`;
export const creditRequestUrl = (providerId) => `${getApiUrl()}/credit/v1/providers/${providerId}/request/`;
export const creditPurchaseUrl = (courseId) => `${ecommerceUrl}/credit/checkout/${courseId}/`;
export const creditRequestUrl = (providerId) => `${api}/credit/v1/providers/${providerId}/request/`;
export default StrictDict({
getApiUrl,
api,
baseAppUrl,
courseUnenroll,
creditPurchaseUrl,
creditRequestUrl,
entitlementEnrollment,
event,
getInitApiUrl,
init,
learningMfeUrl,
programsUrl,
updateEmailSettings,

View File

@@ -1,4 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { configuration } from 'config';
import * as urls from './urls';
describe('urls', () => {
@@ -10,7 +10,7 @@ describe('urls', () => {
it('returns the url if it is relative', () => {
const url = '/edx.org';
expect(urls.baseAppUrl(url)).toEqual(
`${getConfig().LMS_BASE_URL}${url}`,
`${configuration.LMS_BASE_URL}${url}`,
);
});
it('return null if url is null', () => {
@@ -25,7 +25,7 @@ describe('urls', () => {
it('returns the url if it is relative', () => {
const url = '/edx.org';
expect(urls.learningMfeUrl(url)).toEqual(
`${getConfig().LEARNING_BASE_URL}${url}`,
`${configuration.LEARNING_BASE_URL}${url}`,
);
});
it('return null if url is null', () => {
@@ -36,6 +36,7 @@ describe('urls', () => {
it('builds from ecommerce url and loads courseId', () => {
const courseId = 'test-course-id';
const url = urls.creditPurchaseUrl(courseId);
expect(url.startsWith(urls.ecommerceUrl)).toEqual(true);
expect(url).toEqual(expect.stringContaining(courseId));
});
});
@@ -43,7 +44,7 @@ describe('urls', () => {
it('builds from api url and loads providerId', () => {
const providerId = 'test-provider-id';
const url = urls.creditRequestUrl(providerId);
expect(url.startsWith(urls.getApiUrl())).toEqual(true);
expect(url.startsWith(urls.api)).toEqual(true);
expect(url).toEqual(expect.stringContaining(providerId));
});
});

View File

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

View File

@@ -18,8 +18,8 @@ exports[`LookingForChallengeWidget snapshots default 1`] = `
<h5>
<Hyperlink
className="d-flex align-items-center"
destination="http://localhost:18000/course-search-url"
onClick={[MockFunction track.findCoursesWidgetClicked('http://localhost:18000/course-search-url')]}
destination="course-search-url"
onClick={[MockFunction track.findCoursesWidgetClicked('course-search-url')]}
variant="brand"
>
<format-message-function

View File

@@ -6,7 +6,6 @@ import { ArrowForward } from '@edx/paragon/icons';
import { reduxHooks } from 'hooks';
import moreCoursesSVG from 'assets/more-courses-sidewidget.svg';
import { baseAppUrl } from 'data/services/lms/urls';
import track from '../RecommendationsPanel/track';
import messages from './messages';
@@ -30,8 +29,8 @@ export const LookingForChallengeWidget = () => {
<h5>
<Hyperlink
variant="brand"
destination={baseAppUrl(courseSearchUrl)}
onClick={track.findCoursesWidgetClicked(baseAppUrl(courseSearchUrl))}
destination={courseSearchUrl}
onClick={track.findCoursesWidgetClicked(courseSearchUrl)}
className="d-flex align-items-center"
>
{formatMessage(messages.findCoursesButton, { arrow: arrowIcon })}

View File

@@ -5,7 +5,7 @@ import LookingForChallengeWidget from '.';
jest.mock('hooks', () => ({
reduxHooks: {
usePlatformSettingsData: () => ({
courseSearchUrl: 'http://localhost:18000/course-search-url',
courseSearchUrl: 'course-search-url',
}),
},
}));

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { Search } from '@edx/paragon/icons';
import { baseAppUrl } from 'data/services/lms/urls';
import { reduxHooks } from 'hooks';
import track from './track';
@@ -39,8 +38,8 @@ export const LoadedView = ({
variant="tertiary"
iconBefore={Search}
as="a"
href={baseAppUrl(courseSearchUrl)}
onClick={track.findCoursesWidgetClicked(baseAppUrl(courseSearchUrl))}
href={courseSearchUrl}
onClick={track.findCoursesWidgetClicked(courseSearchUrl)}
>
{formatMessage(messages.exploreCoursesButton)}
</Button>

View File

@@ -5,20 +5,17 @@ import LoadedView from './LoadedView';
import mockData from './mockData';
import messages from './messages';
jest.mock('./components/CourseCard', () => 'CourseCard');
jest.mock('hooks', () => ({
reduxHooks: {
usePlatformSettingsData: () => ({
courseSearchUrl: '/course-search-url',
courseSearchUrl: 'course-search-url',
}),
},
}));
jest.mock('data/services/lms/urls', () => ({
baseAppUrl: (url) => (`http://localhost:18000${url}`),
}));
jest.mock('./track', () => ({
findCoursesWidgetClicked: (href) => jest.fn().mockName(`track.findCoursesWidgetClicked('${href}')`),
}));
jest.mock('./components/CourseCard', () => 'CourseCard');
describe('RecommendationsPanel LoadedView', () => {
const props = {

View File

@@ -64,9 +64,9 @@ exports[`RecommendationsPanel LoadedView snapshot with personalize recommendatio
>
<Button
as="a"
href="http://localhost:18000/course-search-url"
href="course-search-url"
iconBefore={[MockFunction icons.Search]}
onClick={[MockFunction track.findCoursesWidgetClicked('http://localhost:18000/course-search-url')]}
onClick={[MockFunction track.findCoursesWidgetClicked('course-search-url')]}
variant="tertiary"
>
Explore courses
@@ -139,9 +139,9 @@ exports[`RecommendationsPanel LoadedView snapshot without personalize recommenda
>
<Button
as="a"
href="http://localhost:18000/course-search-url"
href="course-search-url"
iconBefore={[MockFunction icons.Search]}
onClick={[MockFunction track.findCoursesWidgetClicked('http://localhost:18000/course-search-url')]}
onClick={[MockFunction track.findCoursesWidgetClicked('course-search-url')]}
variant="tertiary"
>
Explore courses

View File

@@ -2,10 +2,10 @@ import { StrictDict } from 'utils';
import { get, stringifyUrl } from 'data/services/lms/utils';
import urls from 'data/services/lms/urls';
export const getFetchUrl = () => (`${urls.getApiUrl()}/learner_recommendations/courses/`);
export const fetchUrl = `${urls.api}/learner_recommendations/courses/`;
export const apiKeys = StrictDict({ user: 'user' });
const fetchRecommendedCourses = () => get(stringifyUrl(getFetchUrl()));
const fetchRecommendedCourses = () => get(stringifyUrl(fetchUrl));
export default {
fetchRecommendedCourses,

View File

@@ -1,5 +1,5 @@
import { get, stringifyUrl } from 'data/services/lms/utils';
import api, { getFetchUrl } from './api';
import api, { fetchUrl } from './api';
jest.mock('data/services/lms/utils', () => ({
stringifyUrl: (...args) => ({ stringifyUrl: args }),
@@ -10,7 +10,7 @@ describe('recommendedCourses api', () => {
describe('fetchRecommendedCourses', () => {
it('calls get with the correct recommendation courses URL and user', () => {
expect(api.fetchRecommendedCourses()).toEqual(
get(stringifyUrl(getFetchUrl())),
get(stringifyUrl(fetchUrl)),
);
});
});