feat: design change for recommendations (#997)
Description: Design change for recommendations in which covered all the card type VAN-1564
This commit is contained in:
80
src/recommendations/ProductCard/BaseCard/index.jsx
Normal file
80
src/recommendations/ProductCard/BaseCard/index.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Badge, Card, Hyperlink } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { truncateText } from '../../data/utils';
|
||||
|
||||
const BaseCard = ({
|
||||
url,
|
||||
customHeaderImage,
|
||||
schoolLogo,
|
||||
title,
|
||||
uuid,
|
||||
subtitle,
|
||||
variant,
|
||||
productTypeCopy,
|
||||
footer,
|
||||
handleOnClick,
|
||||
isLoading = false,
|
||||
}) => (
|
||||
<div className="mr-4 recommendation-card" key={`container-${uuid}`}>
|
||||
<Hyperlink
|
||||
destination={url}
|
||||
target="_blank"
|
||||
className="card-box"
|
||||
showLaunchIcon={false}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
<Card
|
||||
className={`base-card ${variant}`}
|
||||
variant={variant}
|
||||
onClick={handleOnClick}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<Card.ImageCap
|
||||
className="base-card-image-show optanon-category-C0001"
|
||||
src={customHeaderImage}
|
||||
srcAlt={`header image for ${subtitle}`}
|
||||
logoSrc={schoolLogo}
|
||||
logoAlt={`logo for ${subtitle}`}
|
||||
imageLoadingType="lazy"
|
||||
/>
|
||||
<Card.Header
|
||||
className="mt-2"
|
||||
title={truncateText(title)}
|
||||
subtitle={truncateText(subtitle)}
|
||||
/>
|
||||
<Card.Section className="d-flex">
|
||||
<div className="product-badge">
|
||||
<Badge>
|
||||
{productTypeCopy}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="footer-content mt-2">
|
||||
{footer}
|
||||
</div>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Hyperlink>
|
||||
</div>
|
||||
);
|
||||
|
||||
BaseCard.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
uuid: PropTypes.string.isRequired,
|
||||
footer: PropTypes.element.isRequired,
|
||||
productTypeCopy: PropTypes.string.isRequired,
|
||||
subtitle: PropTypes.string.isRequired,
|
||||
variant: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
customHeaderImage: PropTypes.string.isRequired,
|
||||
schoolLogo: PropTypes.string.isRequired,
|
||||
isLoading: PropTypes.bool,
|
||||
handleOnClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
BaseCard.defaultProps = {
|
||||
isLoading: false,
|
||||
};
|
||||
export default BaseCard;
|
||||
126
src/recommendations/ProductCard/Footer/index.jsx
Normal file
126
src/recommendations/ProductCard/Footer/index.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { Launch } from '@edx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { isEdxProgram } from '../../data/utils';
|
||||
import {
|
||||
cardFooterMessages,
|
||||
externalLinkIconMessages,
|
||||
} from '../../messages';
|
||||
|
||||
const ProductCardFooter = ({
|
||||
factoid,
|
||||
quickFacts,
|
||||
courseLength,
|
||||
footerMessage,
|
||||
cardType,
|
||||
is2UDegreeProgram,
|
||||
isSubscriptionView,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const courseLengthLabel = courseLength > 1 ? 'Courses' : 'Course';
|
||||
|
||||
if (isSubscriptionView) {
|
||||
return (
|
||||
<p className="d-inline-block x-small">
|
||||
{intl.formatMessage(
|
||||
cardFooterMessages[
|
||||
'recommendation.2u-product-card.footer-text.number-of-courses'
|
||||
],
|
||||
{ length: courseLength, label: courseLengthLabel },
|
||||
)}
|
||||
<span className="p-2">•</span>
|
||||
<p className="d-inline-block">
|
||||
{intl.formatMessage(
|
||||
cardFooterMessages[
|
||||
'recommendation.2u-product-card.footer-text.subscription'
|
||||
],
|
||||
)}
|
||||
</p>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (footerMessage) {
|
||||
return (
|
||||
<div className="footer-message d-flex align-items-center">
|
||||
<p className="x-small">{footerMessage}</p>
|
||||
<Icon
|
||||
src={Launch}
|
||||
className="ml-1 footer-icon"
|
||||
screenReaderText={intl.formatMessage(
|
||||
externalLinkIconMessages[
|
||||
'recommendation.2u-product-card.launch-icon.sr-text'
|
||||
],
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (courseLength) {
|
||||
return (
|
||||
<p className="x-small">
|
||||
{intl.formatMessage(
|
||||
cardFooterMessages[
|
||||
'recommendation.2u-product-card.footer-text.number-of-courses'
|
||||
],
|
||||
{ length: courseLength, label: courseLengthLabel },
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEdxProgram({ cardType, is2UDegreeProgram })) {
|
||||
if (quickFacts && quickFacts.length > 0) {
|
||||
const quickFactsCount = quickFacts.length;
|
||||
|
||||
const threeFactsArrangement = [1, 3, 0];
|
||||
const twoFactsArrangement = [0, 2];
|
||||
return (
|
||||
<>
|
||||
{(quickFactsCount > 3 ? threeFactsArrangement : twoFactsArrangement)
|
||||
.map((index) => quickFacts[index])
|
||||
.filter(Boolean)
|
||||
.map((fact, idx) => (
|
||||
<p key={fact.text} className="d-inline-block x-small">
|
||||
{idx > 0 && <span className="p-2">•</span>}
|
||||
{fact && fact.text}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (factoid) {
|
||||
return <p className="x-small">{factoid}</p>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
ProductCardFooter.propTypes = {
|
||||
cardType: PropTypes.string,
|
||||
factoid: PropTypes.string,
|
||||
footerMessage: PropTypes.string,
|
||||
quickFacts: PropTypes.arrayOf(PropTypes.shape({})),
|
||||
courseLength: PropTypes.number,
|
||||
is2UDegreeProgram: PropTypes.bool,
|
||||
isSubscriptionView: PropTypes.bool,
|
||||
};
|
||||
|
||||
ProductCardFooter.defaultProps = {
|
||||
cardType: '',
|
||||
factoid: '',
|
||||
footerMessage: '',
|
||||
quickFacts: [],
|
||||
courseLength: undefined,
|
||||
is2UDegreeProgram: false,
|
||||
isSubscriptionView: false,
|
||||
};
|
||||
|
||||
export default ProductCardFooter;
|
||||
129
src/recommendations/ProductCard/index.jsx
Normal file
129
src/recommendations/ProductCard/index.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import BaseCard from './BaseCard';
|
||||
import Footer from './Footer';
|
||||
import { EXTERNAL_PRODUCT_SOURCES } from '../data/constants';
|
||||
import { createCodeFriendlyProduct, getVariant, useProductType } from '../data/utils';
|
||||
import {
|
||||
cardBadgesMessages,
|
||||
cardFooterMessages,
|
||||
} from '../messages';
|
||||
import { trackRecommendationCardClickOptimizely } from '../optimizelyExperiment';
|
||||
import { trackRecommendationsClicked } from '../track';
|
||||
|
||||
const ProductCard = ({
|
||||
product,
|
||||
userId,
|
||||
position,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const productType = useProductType(product?.courseType, product?.type);
|
||||
|
||||
const variant = getVariant(productType);
|
||||
|
||||
const headerImage = product?.cardImageUrl || product?.image?.src;
|
||||
|
||||
const footerMessagesObj = {
|
||||
[EXTERNAL_PRODUCT_SOURCES.EMERITUS]: formatMessage(
|
||||
cardFooterMessages['recommendation.2u-product-card.footer-text.emeritus'],
|
||||
),
|
||||
[EXTERNAL_PRODUCT_SOURCES.SHORELIGHT]: formatMessage(
|
||||
cardFooterMessages['recommendation.2u-product-card.footer-text.shorelight'],
|
||||
),
|
||||
};
|
||||
|
||||
const schoolName = product?.organizationShortCodeOverride
|
||||
|| product?.owners?.[0]?.name
|
||||
|| product?.authoringOrganizations?.[0]?.name
|
||||
|| product?.partner;
|
||||
const schoolLogo = product?.organizationLogoOverrideUrl
|
||||
|| product?.logoFilename
|
||||
|| product?.authoringOrganizations?.[0]?.logoImageUrl
|
||||
|| product?.owners?.[0]?.logoImageUrl;
|
||||
|
||||
const { owners } = product;
|
||||
const multipleSchoolNames = [];
|
||||
const isMultipleOwner = owners?.length > 1;
|
||||
|
||||
if ((owners?.length > 1)) {
|
||||
owners.forEach((owner, index, arr) => {
|
||||
let school;
|
||||
if (index === arr.length - 1) {
|
||||
school = (
|
||||
<span key={owner.name}>{owner.name}</span>
|
||||
);
|
||||
} else {
|
||||
school = (
|
||||
<>
|
||||
<span key={owner.name}>{owner.name}</span>
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
multipleSchoolNames.push(school);
|
||||
});
|
||||
}
|
||||
|
||||
const productTypeCopy = formatMessage(
|
||||
cardBadgesMessages[
|
||||
`recommendation.2u-product-card.pill-text.${createCodeFriendlyProduct(productType)}`
|
||||
],
|
||||
);
|
||||
const handleCardClick = () => {
|
||||
trackRecommendationCardClickOptimizely(userId?.toString());
|
||||
trackRecommendationsClicked(
|
||||
product.courseKey,
|
||||
false,
|
||||
position + 1,
|
||||
userId,
|
||||
product.marketingUrl,
|
||||
product.recommendationType || 'algolia',
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
url={product.url}
|
||||
customHeaderImage={headerImage}
|
||||
schoolLogo={isMultipleOwner ? '' : schoolLogo}
|
||||
title={product.title}
|
||||
uuid={product.uuid}
|
||||
key={product.uuid}
|
||||
subtitle={isMultipleOwner ? multipleSchoolNames : schoolName}
|
||||
productTypeCopy={productTypeCopy}
|
||||
productType={productType}
|
||||
variant={variant}
|
||||
footer={(
|
||||
<Footer
|
||||
footerMessage={footerMessagesObj?.[product.productSource?.slug]}
|
||||
quickFacts={product.degree?.quickFacts}
|
||||
externalUrl={product.additionalMetadata?.externalUrl
|
||||
|| product.degree?.additionalMetadata?.externalUrl}
|
||||
courseLength={product.courses?.length}
|
||||
isSubscriptionView={!!product.subscriptionEligible}
|
||||
is2UDegreeProgram={product.is2UDegreeProgram}
|
||||
cardType={product.cardType}
|
||||
/>
|
||||
)}
|
||||
handleOnClick={handleCardClick}
|
||||
isSubscriptionView={!!product.subscriptionEligible}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCard.propTypes = {
|
||||
product: PropTypes.shape([
|
||||
PropTypes.shape({}),
|
||||
]).isRequired,
|
||||
userId: PropTypes.number.isRequired,
|
||||
position: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
ProductCard.defaultProps = {
|
||||
};
|
||||
export default ProductCard;
|
||||
@@ -1,87 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Card, Hyperlink } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { trackRecommendationCardClickOptimizely } from './optimizelyExperiment';
|
||||
import { trackRecommendationsClicked } from './track';
|
||||
|
||||
const RecommendationCard = (props) => {
|
||||
const { recommendation, position, userId } = props;
|
||||
const showPartnerLogo = recommendation.owners.length === 1;
|
||||
|
||||
const getOwners = () => {
|
||||
if (recommendation.owners.length === 1) {
|
||||
return recommendation.owners[0].key;
|
||||
}
|
||||
|
||||
let keys = '';
|
||||
recommendation.owners.forEach((owner) => {
|
||||
keys += `${owner.key }, `;
|
||||
});
|
||||
return keys.slice(0, -2);
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
trackRecommendationCardClickOptimizely(userId?.toString());
|
||||
trackRecommendationsClicked(
|
||||
recommendation.courseKey,
|
||||
false,
|
||||
position + 1,
|
||||
userId,
|
||||
recommendation.marketingUrl,
|
||||
recommendation.recommendationType || 'algolia',
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mr-4 recommendation-card">
|
||||
<Hyperlink
|
||||
destination={recommendation.marketingUrl}
|
||||
target="_blank"
|
||||
className="card-box"
|
||||
showLaunchIcon={false}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<Card isClickable>
|
||||
<Card.ImageCap
|
||||
src={recommendation.cardImageUrl}
|
||||
srcAlt="Card image"
|
||||
logoSrc={showPartnerLogo ? recommendation.owners[0].logoImageUrl : ''}
|
||||
logoAlt="Card logo"
|
||||
/>
|
||||
<Card.Header
|
||||
title={recommendation.title}
|
||||
subtitle={getOwners()}
|
||||
/>
|
||||
<Card.Section />
|
||||
<Card.Footer textElement={<small className="pgn__card__footer-text">Course</small>} />
|
||||
</Card>
|
||||
</Hyperlink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RecommendationCard.propTypes = {
|
||||
recommendation: PropTypes.shape({
|
||||
courseKey: PropTypes.string.isRequired,
|
||||
activeRunKey: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
cardImageUrl: PropTypes.string.isRequired,
|
||||
owners: PropTypes.arrayOf(PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
logoImageUrl: PropTypes.string.isRequired,
|
||||
})),
|
||||
marketingUrl: PropTypes.string.isRequired,
|
||||
recommendationType: PropTypes.string,
|
||||
}).isRequired,
|
||||
position: PropTypes.number.isRequired,
|
||||
userId: PropTypes.number,
|
||||
};
|
||||
|
||||
RecommendationCard.defaultProps = {
|
||||
userId: null,
|
||||
};
|
||||
|
||||
export default RecommendationCard;
|
||||
@@ -1,24 +1,50 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Container } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Container, Dropdown, DropdownButton } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import RecommendationCard from './RecommendationCard';
|
||||
import { RECOMMENDATIONS_OPTION_LIST } from './data/constants';
|
||||
import messages from './messages';
|
||||
import ProductCard from './ProductCard';
|
||||
|
||||
const RecommendationsList = (props) => {
|
||||
const { title, recommendations, userId } = props;
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const {
|
||||
title, recommendations, userId, setSelectedRecommendationsType, selectedRecommendationsType,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Container id="course-recommendations" size="lg" className="recommendations-container">
|
||||
<h2 className="text-sm-center mb-4 text-left recommendations-container__heading">
|
||||
{title}
|
||||
</h2>
|
||||
<DropdownButton
|
||||
id="dropdown-basic-button"
|
||||
title={(
|
||||
<>
|
||||
{formatMessage(messages[`recommendation.option.${selectedRecommendationsType.value}`])}
|
||||
</>
|
||||
)}
|
||||
className="bg-white mt-5.5 mb-3"
|
||||
>
|
||||
{RECOMMENDATIONS_OPTION_LIST.map((option) => (
|
||||
<Dropdown.Item
|
||||
onClick={() => setSelectedRecommendationsType(option)}
|
||||
id={`option-${option.value}`}
|
||||
key={`option-${option.value}`}
|
||||
>
|
||||
{formatMessage(messages[`recommendation.option.${option.value}`])}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</DropdownButton>
|
||||
<div className="d-flex recommendations-container__card-list">
|
||||
{
|
||||
recommendations.map((recommendation, idx) => (
|
||||
<RecommendationCard
|
||||
<ProductCard
|
||||
key={recommendation.activeRunKey}
|
||||
recommendation={recommendation}
|
||||
product={recommendation}
|
||||
position={idx}
|
||||
userId={userId}
|
||||
/>
|
||||
@@ -31,6 +57,11 @@ const RecommendationsList = (props) => {
|
||||
|
||||
RecommendationsList.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
setSelectedRecommendationsType: PropTypes.func.isRequired,
|
||||
selectedRecommendationsType: PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
recommendations: PropTypes.arrayOf(PropTypes.shape({
|
||||
courseKey: PropTypes.string.isRequired,
|
||||
activeRunKey: PropTypes.string.isRequired,
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Hyperlink, Image, Spinner, StatefulButton,
|
||||
Hyperlink, Image, StatefulButton,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { EDUCATION_LEVEL_MAPPING, RECOMMENDATIONS_COUNT } from './data/constants';
|
||||
import getPersonalizedRecommendations from './data/service';
|
||||
import { convertCourseRunKeytoCourseKey } from './data/utils';
|
||||
import { RECOMMENDATIONS_COUNT, RECOMMENDATIONS_OPTION_LIST } from './data/constants';
|
||||
import messages from './messages';
|
||||
import RecommendationsList from './RecommendationsList';
|
||||
import { trackRecommendationsViewed } from './track';
|
||||
import { DEFAULT_REDIRECT_URL } from '../data/constants';
|
||||
|
||||
const RecommendationsPage = (props) => {
|
||||
@@ -23,51 +20,12 @@ const RecommendationsPage = (props) => {
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
|
||||
const { formatMessage } = useIntl();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [recommendations, setRecommendations] = useState([]);
|
||||
const [algoliaRecommendations, setAlgoliaRecommendations] = useState([]);
|
||||
const educationLevel = EDUCATION_LEVEL_MAPPING[location.state?.educationLevel];
|
||||
const [selectedRecommendationsType, setSelectedRecommendationsType] = useState(RECOMMENDATIONS_OPTION_LIST[1]);
|
||||
const [recommendations] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registrationResponse) {
|
||||
const generalRecommendations = JSON.parse(getConfig().GENERAL_RECOMMENDATIONS);
|
||||
let coursesWithKeys = [];
|
||||
getPersonalizedRecommendations(educationLevel).then((response) => {
|
||||
coursesWithKeys = response.map(course => ({
|
||||
...course,
|
||||
courseKey: convertCourseRunKeytoCourseKey(course.activeRunKey),
|
||||
}));
|
||||
setAlgoliaRecommendations(coursesWithKeys.slice(0, RECOMMENDATIONS_COUNT));
|
||||
|
||||
if (coursesWithKeys.length >= RECOMMENDATIONS_COUNT) {
|
||||
setRecommendations(coursesWithKeys.slice(0, RECOMMENDATIONS_COUNT));
|
||||
} else {
|
||||
const courseRecommendations = coursesWithKeys.concat(generalRecommendations);
|
||||
// Remove duplicate recommendations
|
||||
const uniqueRecommendations = courseRecommendations.filter(
|
||||
(recommendation, index, self) => index === self.findIndex((existingRecommendation) => (
|
||||
existingRecommendation.courseKey === recommendation.courseKey
|
||||
)),
|
||||
);
|
||||
setRecommendations(uniqueRecommendations.slice(0, RECOMMENDATIONS_COUNT));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setRecommendations(generalRecommendations.slice(0, RECOMMENDATIONS_COUNT));
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [registrationResponse, DASHBOARD_URL, educationLevel, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
// We only want to track the recommendations returned by Algolia
|
||||
const courseKeys = algoliaRecommendations.map(course => course.courseKey);
|
||||
trackRecommendationsViewed(courseKeys, false, userId);
|
||||
}
|
||||
}, [isLoading, algoliaRecommendations, userId]);
|
||||
const handleRecommendationType = (option) => {
|
||||
setSelectedRecommendationsType(option);
|
||||
};
|
||||
|
||||
if (!registrationResponse) {
|
||||
global.location.assign(DASHBOARD_URL);
|
||||
@@ -83,7 +41,7 @@ const RecommendationsPage = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoading && recommendations.length < RECOMMENDATIONS_COUNT) {
|
||||
if (recommendations.length < RECOMMENDATIONS_COUNT) {
|
||||
handleRedirection();
|
||||
}
|
||||
|
||||
@@ -106,12 +64,14 @@ const RecommendationsPage = (props) => {
|
||||
<Image className="logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
|
||||
</Hyperlink>
|
||||
</div>
|
||||
{(!isLoading && recommendations.length === RECOMMENDATIONS_COUNT) ? (
|
||||
{(recommendations) && (
|
||||
<div className="d-flex flex-column align-items-center justify-content-center flex-grow-1 p-1">
|
||||
<RecommendationsList
|
||||
title={formatMessage(messages['recommendation.page.heading'])}
|
||||
recommendations={recommendations}
|
||||
userId={userId}
|
||||
setSelectedRecommendationsType={handleRecommendationType}
|
||||
selectedRecommendationsType={selectedRecommendationsType}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<StatefulButton
|
||||
@@ -125,10 +85,7 @@ const RecommendationsPage = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Spinner animation="border" variant="primary" className="spinner--position-centered" />
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,3 +9,10 @@ export const EDUCATION_LEVEL_MAPPING = {
|
||||
hs: 'Introductory',
|
||||
jhs: 'Introductory',
|
||||
};
|
||||
|
||||
export const EXTERNAL_PRODUCT_SOURCES = {
|
||||
EMERITUS: 'emeritus',
|
||||
SHORELIGHT: 'shorelight',
|
||||
};
|
||||
|
||||
export const RECOMMENDATIONS_OPTION_LIST = [{ title: 'Trending courses', value: 'trending' }, { title: 'Popular courses', value: 'popular' }];
|
||||
|
||||
16
src/recommendations/data/tests/utils.test.jsx
Normal file
16
src/recommendations/data/tests/utils.test.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { convertCourseRunKeytoCourseKey, useProductType } from '../utils';
|
||||
|
||||
describe('UtilsTests', () => {
|
||||
it('should return the courseKey after parsing the activeCourseRun key', async () => {
|
||||
const courseKey = convertCourseRunKeytoCourseKey('course-v1:Demox+Test101+2023');
|
||||
expect(courseKey).toEqual('Demox+Test101');
|
||||
});
|
||||
it('should return courseType and programType', async () => {
|
||||
const programType = useProductType(undefined, 'Professional Certificate');
|
||||
expect(programType).toEqual('Professional Certificate');
|
||||
const courseType = useProductType('verified-audit', undefined);
|
||||
expect(courseType).toEqual('Course');
|
||||
const noCourseType = useProductType(undefined, undefined);
|
||||
expect(noCourseType).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,57 @@ export const convertCourseRunKeytoCourseKey = (courseRunKey) => {
|
||||
return `${splitCourseKey[0]}+${splitCourseKey[1]}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
convertCourseRunKeytoCourseKey,
|
||||
const courseTypeToProductTypeMap = {
|
||||
course: 'Course',
|
||||
'verified-audit': 'Course',
|
||||
verified: 'Course',
|
||||
audit: 'Course',
|
||||
'credit-verified-audit': 'Course',
|
||||
'spoc-verified-audit': 'Course',
|
||||
professional: 'Professional Certificate',
|
||||
'bootcamp-2u': 'Boot Camp',
|
||||
'executive-education-2u': 'Executive Education',
|
||||
'executive-education': 'Executive Education',
|
||||
masters: "Master's",
|
||||
'masters-verified-audit': "Master's",
|
||||
};
|
||||
|
||||
const programTypeToProductTypeMap = {
|
||||
xseries: 'XSeries',
|
||||
micromasters: 'MicroMasters',
|
||||
microbachelors: 'MicroBachelors',
|
||||
'professional certificate': 'Professional Certificate',
|
||||
"bachelor's": "Bachelor's",
|
||||
bachelors: "Bachelor's",
|
||||
"master's": "Master's",
|
||||
masters: "Master's",
|
||||
doctorate: 'Doctorate',
|
||||
license: 'License',
|
||||
certificate: 'Certificate',
|
||||
};
|
||||
|
||||
export const useProductType = (courseType, programType) => {
|
||||
const courseTypeLowerCase = courseType?.toLowerCase();
|
||||
if (courseTypeToProductTypeMap[courseTypeLowerCase]) {
|
||||
return courseTypeToProductTypeMap[courseTypeLowerCase];
|
||||
}
|
||||
|
||||
const programTypeLowerCase = programType?.toLowerCase();
|
||||
if (programTypeToProductTypeMap[programTypeLowerCase]) {
|
||||
return programTypeToProductTypeMap[programTypeLowerCase];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getVariant = (productType) => (
|
||||
['Boot Camp', 'Executive Education', 'Course'].includes(productType) ? 'light' : 'dark'
|
||||
);
|
||||
|
||||
export const createCodeFriendlyProduct = (type) => type?.replace(/\s+/g, '-').replace(/'/g, '').toLowerCase();
|
||||
|
||||
export const isEdxProgram = ({ cardType, is2UDegreeProgram }) => cardType === 'program' && !is2UDegreeProgram;
|
||||
|
||||
export const truncateText = (input) => (input?.length > 50 ? `${input.substring(0, 50)}...` : input);
|
||||
|
||||
export default convertCourseRunKeytoCourseKey;
|
||||
|
||||
@@ -16,6 +16,111 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Skip for now',
|
||||
description: 'Skip button text',
|
||||
},
|
||||
|
||||
'recommendation.option.trending': {
|
||||
id: 'recommendation.option.trending',
|
||||
defaultMessage: 'Trending Courses',
|
||||
description: 'Trending courses option',
|
||||
},
|
||||
'recommendation.option.popular': {
|
||||
id: 'recommendation.option.popular',
|
||||
defaultMessage: 'Popular Courses',
|
||||
description: 'Popular courses option',
|
||||
},
|
||||
});
|
||||
|
||||
export const cardBadgesMessages = defineMessages({
|
||||
'recommendation.2u-product-card.pill-text.course': {
|
||||
id: 'recommendation.2u-product-card.pill-text.course',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Course',
|
||||
},
|
||||
'recommendation.2u-product-card.pill-text.microbachelors': {
|
||||
id: 'recommendation.2u-product-card.pill-text.microbachelors',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'MicroBachelors®',
|
||||
},
|
||||
'recommendation.2u-product-card.pill-text.micromasters': {
|
||||
id: 'recommendation.2u-product-card.pill-text.micromasters',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'MicroMasters®',
|
||||
},
|
||||
'recommendation.2u-product-card.pill-text.xseries': {
|
||||
id: 'recommendation.2u-product-card.pill-text.xseries',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'XSeries',
|
||||
},
|
||||
'recommendation.2u-product-card.pill-text.professional-certificate': {
|
||||
id: 'recommendation.2u-product-card.pill-text.professional-certificate',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Professional Certificate',
|
||||
},
|
||||
// 2U Products
|
||||
'recommendation.2u-product-card.pill-text.executive-education': {
|
||||
id: 'recommendation.2u-product-card.pill-text.executive-education',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Executive Education',
|
||||
},
|
||||
'recommendation.2u-product-card.pill-text.boot-camp': {
|
||||
id: 'recommendation.2u-product-card.pill-text.boot-camp',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Boot Camp',
|
||||
},
|
||||
'recommendation.2u-product-card.pill-text.bachelors': {
|
||||
id: 'recommendation.2u-product-card.pill-text.bachelors',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Bachelor\'s Degree',
|
||||
},
|
||||
'recommendation.2u-product-card.pill-text.masters': {
|
||||
id: 'recommendation.2u-product-card.pill-text.masters',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Master\'s Degree',
|
||||
},
|
||||
'recommendation.2u-product-card.pill-text.doctorate': {
|
||||
id: 'recommendation.2u-product-card.pill-text.doctorate',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Doctoral Program',
|
||||
},
|
||||
'recommendation.2u-product-card.pill-text.certificate': {
|
||||
id: 'recommendation.2u-product-card.pill-text.certificate',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Certificate Program',
|
||||
},
|
||||
'recommendation.2u-product-card.pill-text.license': {
|
||||
id: 'recommendation.2u-product-card.pill-text.license',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Licensure Program',
|
||||
},
|
||||
});
|
||||
|
||||
export const cardFooterMessages = defineMessages({
|
||||
'recommendation.2u-product-card.footer-text.emeritus': {
|
||||
id: 'recommendation.2u-product-card.pill-text.emeritus',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Offered on Emeritus',
|
||||
},
|
||||
'recommendation.2u-product-card.footer-text.shorelight': {
|
||||
id: 'recommendation.2u-product-card.pill-text.shorelight',
|
||||
description: 'Text on a product card that describes which product line this item belongs to',
|
||||
defaultMessage: 'Offered through Shorelight',
|
||||
},
|
||||
'recommendation.2u-product-card.footer-text.number-of-courses': {
|
||||
id: 'recommendation.2u-product-card.footer-text.number-of-courses',
|
||||
description: 'Label in card footer that shows how many courses are in a program',
|
||||
defaultMessage: '{length} {label}',
|
||||
},
|
||||
'recommendation.2u-product-card.footer-text.subscription': {
|
||||
id: 'recommendation.2u-product-card.footer-text.subscription',
|
||||
description: 'Label in card footer that describes that it is a subscription program',
|
||||
defaultMessage: 'Subscription',
|
||||
},
|
||||
});
|
||||
|
||||
export const externalLinkIconMessages = defineMessages({
|
||||
'recommendation.2u-product-card.launch-icon.sr-text': {
|
||||
id: 'recommendation.2u-product-card.launch-icon.sr-text',
|
||||
description: 'Screen reader text for the launch icon on the cards',
|
||||
defaultMessage: 'Opens a link in a new tab',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
140
src/recommendations/tests/RecommendationsList.test.jsx
Normal file
140
src/recommendations/tests/RecommendationsList.test.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { getConfig, mergeConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import mockedCoursesData from './mockedData';
|
||||
import { trackRecommendationCardClickOptimizely } from '../optimizelyExperiment';
|
||||
import RecommendationList from '../RecommendationsList';
|
||||
|
||||
const IntlRecommendationList = injectIntl(RecommendationList);
|
||||
const mockStore = configureStore();
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||
sendTrackEvent: jest.fn(),
|
||||
}));
|
||||
jest.mock('../data/service', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../optimizelyExperiment', () => ({
|
||||
trackRecommendationCardClickOptimizely: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('RecommendationsListTests', () => {
|
||||
mergeConfig({
|
||||
GENERAL_RECOMMENDATIONS: '[]',
|
||||
});
|
||||
|
||||
let defaultProps = {};
|
||||
let store = {};
|
||||
|
||||
const registrationResult = {
|
||||
redirectUrl: getConfig().LMS_BASE_URL.concat('/course-about-page-url'),
|
||||
success: true,
|
||||
};
|
||||
const reduxWrapper = children => (
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const getRecommendationsList = async (props = defaultProps) => {
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationList {...props} />));
|
||||
await act(async () => {
|
||||
await Promise.resolve(recommendationsPage);
|
||||
recommendationsPage.update();
|
||||
});
|
||||
|
||||
return recommendationsPage;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({});
|
||||
defaultProps = {
|
||||
location: {
|
||||
state: {
|
||||
registrationResult,
|
||||
userId: 111,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should render the product card with formatted message in card', async () => {
|
||||
const props = {
|
||||
recommendations: [mockedCoursesData[4]],
|
||||
title: 'We have a few recommendations to get you started.',
|
||||
userId: 1234567,
|
||||
setSelectedRecommendationsType: jest.fn(),
|
||||
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
|
||||
};
|
||||
const recommendationsList = await getRecommendationsList(props);
|
||||
expect(recommendationsList.find('.x-small').at(0).text()).toEqual('Offered on Emeritus');
|
||||
});
|
||||
|
||||
it('should call trackRecommendationCardClickOptimizely when card is clicked', async () => {
|
||||
const props = {
|
||||
recommendations: [mockedCoursesData[1]],
|
||||
title: 'We have a few recommendations to get you started.',
|
||||
userId: 1234567,
|
||||
setSelectedRecommendationsType: jest.fn(),
|
||||
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
|
||||
};
|
||||
const recommendationsList = await getRecommendationsList(props);
|
||||
recommendationsList.find('.card-box').first().simulate('click');
|
||||
expect(trackRecommendationCardClickOptimizely).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should render the recommendations card with with facets', async () => {
|
||||
const props = {
|
||||
recommendations: [mockedCoursesData[1]],
|
||||
title: 'We have a few recommendations to get you started.',
|
||||
userId: 1234567,
|
||||
setSelectedRecommendationsType: jest.fn(),
|
||||
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
|
||||
};
|
||||
const recommendationsList = await getRecommendationsList(props);
|
||||
expect(recommendationsList.find('.d-inline-block').at(0).text()).toEqual('6 Modules');
|
||||
});
|
||||
|
||||
it('should render the recommendations card with footer content when subscription view is enabled', async () => {
|
||||
const props = {
|
||||
recommendations: [mockedCoursesData[3]],
|
||||
title: 'We have a few recommendations to get you started.',
|
||||
userId: 1234567,
|
||||
setSelectedRecommendationsType: jest.fn(),
|
||||
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
|
||||
};
|
||||
const recommendationsList = await getRecommendationsList(props);
|
||||
expect(recommendationsList.find('.d-inline-block').at(1).text()).toEqual('Subscription');
|
||||
});
|
||||
|
||||
it('should render the recommendations card with footer content', async () => {
|
||||
const props = {
|
||||
recommendations: [mockedCoursesData[0]],
|
||||
title: 'We have a few recommendations to get you started.',
|
||||
userId: 1234567,
|
||||
setSelectedRecommendationsType: jest.fn(),
|
||||
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
|
||||
};
|
||||
const recommendationsList = await getRecommendationsList(props);
|
||||
expect(recommendationsList.find('.x-small').text()).toEqual('4 Courses');
|
||||
});
|
||||
|
||||
it('should render the recommendations list', async () => {
|
||||
const props = {
|
||||
recommendations: mockedCoursesData,
|
||||
title: 'We have a few recommendations to get you started.',
|
||||
userId: 1234567,
|
||||
setSelectedRecommendationsType: jest.fn(),
|
||||
selectedRecommendationsType: { title: 'Trending courses', value: 'trending' },
|
||||
};
|
||||
const recommendationsList = await getRecommendationsList(props);
|
||||
expect(recommendationsList.find('.recommendation-card').length).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,13 @@ import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { getConfig, mergeConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import { mockedGeneralRecommendations, mockedResponse } from './mockedData';
|
||||
import { DEFAULT_REDIRECT_URL } from '../../data/constants';
|
||||
import getPersonalizedRecommendations from '../data/service';
|
||||
import { trackRecommendationCardClickOptimizely } from '../optimizelyExperiment';
|
||||
import RecommendationsPage from '../RecommendationsPage';
|
||||
|
||||
const IntlRecommendationsPage = injectIntl(RecommendationsPage);
|
||||
@@ -24,9 +21,6 @@ jest.mock('../data/service', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../optimizelyExperiment', () => ({
|
||||
trackRecommendationCardClickOptimizely: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('RecommendationsPageTests', () => {
|
||||
mergeConfig({
|
||||
@@ -68,19 +62,6 @@ describe('RecommendationsPageTests', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('redirects to dashboard if user tries to access the page directly', async () => {
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
delete window.location;
|
||||
window.location = {
|
||||
href: getConfig().BASE_URL,
|
||||
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
|
||||
};
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([]));
|
||||
await getRecommendationsPage({});
|
||||
|
||||
expect(getPersonalizedRecommendations).toHaveBeenCalledTimes(0);
|
||||
expect(window.location.href).toEqual(DASHBOARD_URL);
|
||||
});
|
||||
it('redirects to dashboard if user click on skip button', async () => {
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
registrationResult = {
|
||||
@@ -95,87 +76,39 @@ describe('RecommendationsPageTests', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
|
||||
const recommendationsPage = await getRecommendationsPage(props);
|
||||
recommendationsPage.find('button').simulate('click');
|
||||
recommendationsPage.find('.pgn__stateful-btn-state-default').first().simulate('click');
|
||||
expect(window.location.href).toEqual(DASHBOARD_URL);
|
||||
});
|
||||
|
||||
it('should call trackRecommendationCardClickOptimizely when card is clicked', async () => {
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
recommendationsPage.find('.card-box').first().simulate('click');
|
||||
expect(trackRecommendationCardClickOptimizely).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show loading state to user', async () => {
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
|
||||
await act(async () => {
|
||||
const recommendationsPage = mount(reduxWrapper(<IntlRecommendationsPage {...defaultProps} />));
|
||||
expect(recommendationsPage.find('.spinner--position-centered').exists()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call getPersonalizedRecommendations', async () => {
|
||||
delete window.location;
|
||||
window.location = { assign: jest.fn() };
|
||||
getPersonalizedRecommendations.mockClear();
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([]));
|
||||
await getRecommendationsPage();
|
||||
|
||||
expect(getPersonalizedRecommendations).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
||||
'edx.bi.user.recommendations.viewed',
|
||||
{
|
||||
page: 'authn_recommendations',
|
||||
course_key_array: [],
|
||||
amplitude_recommendations: false,
|
||||
is_control: false,
|
||||
user_id: 111,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should display recommendations returned by Algolia', async () => {
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
|
||||
it('should change the option when click the dropdown option', async () => {
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
|
||||
expect(recommendationsPage.find('#course-recommendations').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not display recommendations if error comes in while fetching the recommendations', async () => {
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.reject(mockedResponse));
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
|
||||
expect(recommendationsPage.find('#recommendation-card').exists()).toBeFalsy();
|
||||
recommendationsPage.find('#dropdown-basic-button').at(1).simulate('click');
|
||||
recommendationsPage.find('.dropdown-item').at(0).simulate('click',
|
||||
{ target: { title: 'Trending courses', value: 'trending' } });
|
||||
expect(recommendationsPage.find('#dropdown-basic-button').at(1).text()).toEqual('Trending Courses');
|
||||
});
|
||||
|
||||
it('should redirect if recommended courses count is less than RECOMMENDATIONS_COUNT', async () => {
|
||||
delete window.location;
|
||||
window.location = { assign: jest.fn() };
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([mockedResponse[0]]));
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
|
||||
expect(recommendationsPage.find('#course-recommendations').exists()).toBeFalsy();
|
||||
expect(window.location.href).toEqual(registrationResult.redirectUrl);
|
||||
});
|
||||
|
||||
it('should not redirect if fallback recommendations are enabled', async () => {
|
||||
mergeConfig({
|
||||
GENERAL_RECOMMENDATIONS: mockedGeneralRecommendations,
|
||||
});
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve([]));
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
|
||||
expect(recommendationsPage.find('#course-recommendations').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display all owners for a course', async () => {
|
||||
getPersonalizedRecommendations.mockImplementation(() => Promise.resolve(mockedResponse));
|
||||
const recommendationsPage = await getRecommendationsPage();
|
||||
it('redirects to dashboard if user tries to access the page directly', async () => {
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
delete window.location;
|
||||
window.location = {
|
||||
href: getConfig().BASE_URL,
|
||||
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
|
||||
};
|
||||
|
||||
expect(
|
||||
recommendationsPage.find('.pgn__card-header-subtitle-md').getElements()[0].props.children,
|
||||
).toEqual('firstOwnerX, secondOwnerX');
|
||||
await getRecommendationsPage({});
|
||||
|
||||
expect(getPersonalizedRecommendations).toHaveBeenCalledTimes(0);
|
||||
expect(window.location.href).toEqual(DASHBOARD_URL);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,79 +1,241 @@
|
||||
export const mockedResponse = [
|
||||
const mockedCoursesData = [
|
||||
{
|
||||
title: 'How to Learn Online 1',
|
||||
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-1',
|
||||
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-1.png',
|
||||
activeRunKey: 'course-v1:test+testX+2018',
|
||||
owners: [
|
||||
uuid: '1fcffef0-468a-483f-95aa-87e2d4d2c408',
|
||||
title: 'Google Cloud Computing Foundations',
|
||||
subtitle: 'The Google Cloud Computing Foundations courses provide an overview of concepts central to cloud basics, big data, and machine learning, and where and how Google Cloud fits in.',
|
||||
cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/card_images/1fcffef0-468a-483f-95aa-87e2d4d2c408-fc006d5bbc06.png',
|
||||
authoringOrganizations: [
|
||||
{
|
||||
key: 'firstOwnerX',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-1.png',
|
||||
name: 'first owner',
|
||||
},
|
||||
{
|
||||
key: 'secondOwnerX',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-1.png',
|
||||
name: 'second owner',
|
||||
key: 'GoogleCloud',
|
||||
logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/7a30f2c7-0d59-4890-ab19-fc8324e9c7d6-6ff36c37bf48.png',
|
||||
name: 'Google Cloud',
|
||||
},
|
||||
],
|
||||
objectId: 'course-how-to-learn-online-key-1',
|
||||
courses: [
|
||||
{
|
||||
course: {
|
||||
title: 'Google Cloud Computing Foundations: Cloud Computing Fundamentals',
|
||||
topics: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
course: null,
|
||||
},
|
||||
{
|
||||
course: null,
|
||||
},
|
||||
{
|
||||
course: null,
|
||||
},
|
||||
],
|
||||
type: 'Professional Certificate',
|
||||
marketingPath: '/professional-certificate/google-cloud-computing-foundations',
|
||||
organizationLogoOverrideUrl: null,
|
||||
organizationShortCodeOverride: '',
|
||||
productSource: {
|
||||
name: 'edX',
|
||||
slug: 'edx',
|
||||
description: '',
|
||||
},
|
||||
status: 'active',
|
||||
hidden: false,
|
||||
inProspectus: true,
|
||||
is2UDegreeProgram: false,
|
||||
degree: null,
|
||||
locationRestriction: null,
|
||||
subscriptionEligible: null,
|
||||
objectID: 'program-1fcffef0-468a-483f-95aa-87e2d4d2c408',
|
||||
cardType: 'program',
|
||||
cardIndex: 0,
|
||||
},
|
||||
{
|
||||
title: 'How to Learn Online 2',
|
||||
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-2',
|
||||
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-2.png',
|
||||
activeRunKey: 'course-v1:test+testX+2019',
|
||||
owners: [
|
||||
uuid: 'ff89bff4-42c4-4728-ae8b-7e9276ebfcac',
|
||||
title: 'Online Master’s Degree in Public Health',
|
||||
subtitle: 'from Boston University',
|
||||
cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/card_images/ff89bff4-42c4-4728-ae8b-7e9276ebfcac-18ae533b0500.jpeg',
|
||||
authoringOrganizations: [
|
||||
{
|
||||
key: 'testX',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-2.png',
|
||||
name: 'test',
|
||||
key: 'BUx',
|
||||
logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/36cfd0bb-1d18-4355-ae44-cb946573df3c-1e18515c3e4b.png',
|
||||
name: 'Boston University',
|
||||
},
|
||||
],
|
||||
objectId: 'course-how-to-learn-online-key-2',
|
||||
courses: [],
|
||||
type: 'Masters',
|
||||
marketingPath: '/masters/online-masters-in-public-health-boston-university',
|
||||
organizationLogoOverrideUrl: null,
|
||||
organizationShortCodeOverride: '',
|
||||
productSource: {
|
||||
name: 'edX',
|
||||
slug: 'edx',
|
||||
description: '',
|
||||
},
|
||||
status: 'active',
|
||||
hidden: false,
|
||||
inProspectus: true,
|
||||
is2UDegreeProgram: false,
|
||||
degree: {
|
||||
quickFacts: [
|
||||
{
|
||||
text: '$24,000',
|
||||
icon: 'fa-dollar',
|
||||
},
|
||||
{
|
||||
text: '6 Modules',
|
||||
icon: 'fa-book',
|
||||
},
|
||||
{
|
||||
text: 'Fully Online',
|
||||
icon: 'fa-desktop',
|
||||
},
|
||||
{
|
||||
text: '24 Months',
|
||||
icon: 'fa-clock-o',
|
||||
},
|
||||
],
|
||||
additionalMetadata: null,
|
||||
organizationLogoOverrideUrl: null,
|
||||
},
|
||||
locationRestriction: null,
|
||||
subscriptionEligible: null,
|
||||
objectID: 'program-ff89bff4-42c4-4728-ae8b-7e9276ebfcac',
|
||||
cardType: 'program',
|
||||
cardIndex: 1,
|
||||
},
|
||||
{
|
||||
title: 'How to Learn Online 3',
|
||||
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-3',
|
||||
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-3.png',
|
||||
activeRunKey: 'course-v1:test+testX+2020',
|
||||
owners: [
|
||||
uuid: '123f9327-bfef-459f-8bf2-c53277aa58f8',
|
||||
title: 'Bachelor of Science in Data Science and Business Analytics',
|
||||
subtitle: '',
|
||||
cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/card_images/123f9327-bfef-459f-8bf2-c53277aa58f8-60ed48b6667d.png',
|
||||
authoringOrganizations: [
|
||||
{
|
||||
key: 'testX',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-3.png',
|
||||
name: 'test',
|
||||
key: 'UniversityofLondon',
|
||||
logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/5427ee88-6dba-46ff-8ed8-87b71e3234da-0af1465ab67e.png',
|
||||
name: 'University of London',
|
||||
},
|
||||
],
|
||||
objectId: 'course-how-to-learn-online-key-3',
|
||||
courses: [],
|
||||
type: 'Bachelors',
|
||||
marketingPath: '/bachelors/london-bachelor-of-science-in-data-science-and-business-analytics',
|
||||
organizationLogoOverrideUrl: null,
|
||||
organizationShortCodeOverride: 'University of London',
|
||||
productSource: {
|
||||
name: '2u',
|
||||
slug: '2u',
|
||||
description: '2U, Trilogy, Getsmarter -- external source for 2u courses and programs',
|
||||
},
|
||||
status: 'active',
|
||||
hidden: false,
|
||||
inProspectus: true,
|
||||
is2UDegreeProgram: true,
|
||||
degree: {
|
||||
quickFacts: [],
|
||||
additionalMetadata: {
|
||||
externalIdentifier: '2ca2718e-643c-4430-b521-b9ebb91ad226',
|
||||
externalUrl: 'https://programs.edx.org/requestinfo/lse?utm_source=edx&utm_medium=referral',
|
||||
organicUrl: 'https://programs.edx.org/requestinfo/lse?utm_source=edx&utm_medium=referral',
|
||||
},
|
||||
organizationLogoOverrideUrl: null,
|
||||
},
|
||||
locationRestriction: {
|
||||
restrictionType: 'blocklist',
|
||||
countries: [
|
||||
'IN',
|
||||
],
|
||||
states: [],
|
||||
},
|
||||
subscriptionEligible: null,
|
||||
objectID: 'program-123f9327-bfef-459f-8bf2-c53277aa58f8',
|
||||
cardType: 'program',
|
||||
cardIndex: 0,
|
||||
},
|
||||
{
|
||||
title: 'How to Learn Online 4',
|
||||
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-4',
|
||||
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-4.png',
|
||||
activeRunKey: 'course-v1:test+testX+2021',
|
||||
owners: [
|
||||
uuid: '51037067-2c57-4a58-a963-91918ccee5c8',
|
||||
title: 'Leading in a Remote Environment',
|
||||
subtitle: 'Remote work presents unique challenges to maintaining relationships and achieving goals. Leaders need to be adaptive in order to mobilize their teams to meet these new challenges.',
|
||||
cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/card_images/51037067-2c57-4a58-a963-91918ccee5c8-b24ebade984c.jpeg',
|
||||
authoringOrganizations: [
|
||||
{
|
||||
key: 'testX',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-4.png',
|
||||
name: 'test',
|
||||
key: 'HarvardX',
|
||||
logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/44022f13-20df-4666-9111-cede3e5dc5b6-2cc39992c67a.png',
|
||||
name: 'Harvard University',
|
||||
},
|
||||
],
|
||||
objectId: 'course-how-to-learn-online-key-4',
|
||||
courses: [
|
||||
{
|
||||
course: {
|
||||
title: 'Exercising Leadership: Foundational Principles',
|
||||
topics: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
course: {
|
||||
title: 'Remote Work Revolution for Everyone',
|
||||
topics: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'Professional Certificate',
|
||||
marketingPath: '/professional-certificate/harvardx-leading-in-a-remote-environment',
|
||||
organizationLogoOverrideUrl: null,
|
||||
organizationShortCodeOverride: '',
|
||||
productSource: {
|
||||
name: 'edX',
|
||||
slug: 'edx',
|
||||
description: '',
|
||||
},
|
||||
status: 'active',
|
||||
hidden: false,
|
||||
inProspectus: true,
|
||||
is2UDegreeProgram: false,
|
||||
degree: null,
|
||||
locationRestriction: null,
|
||||
subscriptionEligible: true,
|
||||
objectID: 'program-51037067-2c57-4a58-a963-91918ccee5c8',
|
||||
cardType: 'program',
|
||||
cardIndex: 1,
|
||||
},
|
||||
{
|
||||
title: 'How to Learn Online 5',
|
||||
marketingUrl: 'https://test-recommendations.com/course/how-to-learn-online-5',
|
||||
cardImageUrl: 'https://test-recommendations.com/image/how-to-learn-online-5.png',
|
||||
activeRunKey: 'course-v1:test+testX+2022',
|
||||
owners: [
|
||||
uuid: '89f39d1a-bb23-4944-b6e0-b51fe25ce932',
|
||||
title: 'Master of Science in Artificial Intelligence (MSAI)',
|
||||
subtitle: '',
|
||||
cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/programs/card_images/89f39d1a-bb23-4944-b6e0-b51fe25ce932-c0b01712082d.jpg',
|
||||
authoringOrganizations: [
|
||||
{
|
||||
key: 'testX',
|
||||
logoImageUrl: 'https://test-recommendations.com/logos/how-to-learn-online-5.png',
|
||||
name: 'test',
|
||||
key: 'UTAustinX',
|
||||
logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/9d38ad58-87fb-4a89-9f23-c8df318112e3-aec8e9e98a5f.png',
|
||||
name: 'The University of Texas at Austin',
|
||||
},
|
||||
],
|
||||
objectId: 'course-how-to-learn-online-key-5',
|
||||
courses: [],
|
||||
type: 'Masters',
|
||||
marketingPath: '/masters/online-master-artificial-intelligence-utaustinx',
|
||||
organizationLogoOverrideUrl: null,
|
||||
organizationShortCodeOverride: '',
|
||||
productSource: {
|
||||
name: '2u',
|
||||
slug: 'emeritus',
|
||||
description: '2U, Trilogy, Getsmarter -- external source for 2u courses and programs',
|
||||
},
|
||||
status: 'active',
|
||||
hidden: false,
|
||||
inProspectus: true,
|
||||
is2UDegreeProgram: true,
|
||||
degree: {
|
||||
quickFacts: [],
|
||||
additionalMetadata: {
|
||||
externalIdentifier: '89f39d1a-bb23-4944-b6e0-b51fe25ce932',
|
||||
externalUrl: 'https://www.edx.org/masters/online-master-artificial-intelligence-utaustinx',
|
||||
organicUrl: 'https://www.edx.org/masters/online-master-artificial-intelligence-utaustinx',
|
||||
},
|
||||
organizationLogoOverrideUrl: null,
|
||||
},
|
||||
locationRestriction: null,
|
||||
subscriptionEligible: null,
|
||||
objectID: 'program-89f39d1a-bb23-4944-b6e0-b51fe25ce932',
|
||||
cardType: 'program',
|
||||
cardIndex: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockedGeneralRecommendations = '[{"courseKey":"test+text1","activeRunKey":"course-v1:test+test1+2018","cardImageUrl":"https://test-recommendations.com/text-1.jpg","marketingUrl":"https://test-recommendations.com/test-1","objectId":"test-1","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-1.png","name":"General recommendation org 1"}],"title":"General recommendation 1","recommendationType":"general"},{"courseKey":"test+text2","activeRunKey":"course-v1:test+test2+2018","cardImageUrl":"https://test-recommendations.com/text-2.jpg","marketingUrl":"https://test-recommendations.com/test-2","objectId":"test-2","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-2.png","name":"General recommendation org 2"}],"title":"General recommendation 2","recommendationType":"general"},{"courseKey":"test+text3","activeRunKey":"course-v1:test+test3+2018","cardImageUrl":"https://test-recommendations.com/text-3.jpg","marketingUrl":"https://test-recommendations.com/test-3","objectId":"test-3","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-3.png","name":"General recommendation org 3"}],"title":"General recommendation 3","recommendationType":"general"},{"courseKey":"test+text4","activeRunKey":"course-v1:test+test4+2018","cardImageUrl":"https://test-recommendations.com/text-4.jpg","marketingUrl":"https://test-recommendations.com/test-4","objectId":"test-4","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-4.png","name":"General recommendation org 4"}],"title":"General recommendation 4","recommendationType":"general"},{"courseKey":"test+text5","activeRunKey":"course-v1:test+test5+2018","cardImageUrl":"https://test-recommendations.com/text-5.jpg","marketingUrl":"https://test-recommendations.com/test-5","objectId":"test-5","owners":[{"key":"Testx","logoImageUrl":"https://test-recommendations.com/organization/test-5.png","name":"General recommendation org 5"}],"title":"General recommendation 5","recommendationType":"general"}]';
|
||||
export default mockedCoursesData;
|
||||
|
||||
133
src/sass/_recommendations_card_base.scss
Normal file
133
src/sass/_recommendations_card_base.scss
Normal file
@@ -0,0 +1,133 @@
|
||||
$card-height: 332px;
|
||||
$header-height: 104px;
|
||||
|
||||
.base-card-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.base-card-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.base-card {
|
||||
height: $card-height;
|
||||
|
||||
/* stylelint-disable selector-max-type */
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pgn__card-image-cap {
|
||||
height: $header-height;
|
||||
|
||||
object: {
|
||||
fit: cover;
|
||||
position: top center;
|
||||
}
|
||||
}
|
||||
|
||||
.pgn__card-logo-cap {
|
||||
bottom: -1.5rem;
|
||||
|
||||
object: {
|
||||
fit: scale-down;
|
||||
position: center center;
|
||||
}
|
||||
}
|
||||
|
||||
.pgn__card-header-title-md {
|
||||
font: {
|
||||
size: 1.125rem !important; // 18px
|
||||
weight: 700;
|
||||
}
|
||||
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.pgn__card-header-subtitle-md {
|
||||
font: {
|
||||
size: 0.875rem !important; // 14px
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
&.dark {
|
||||
background-color: $primary-500;
|
||||
|
||||
.pgn__card-header-title-md {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.pgn__card-header-subtitle-md {
|
||||
color: $light-200;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: $light-200;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: $dark-200;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
color: $light-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.base-card:hover {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15), 0 0.125rem 0.5rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.base-card-link .base-card {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.base-card-image-show {
|
||||
.pgn__card-image-cap {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pgn__card-logo-cap {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,29 @@
|
||||
@include media-breakpoint-up(xxl) {
|
||||
max-width: $max-width-lg + $grid-gutter-width !important;
|
||||
}
|
||||
.dropdown {
|
||||
background-color: #FBFAF9 !important;
|
||||
|
||||
.dropdown-toggle {
|
||||
background-color: #FBFAF9 !important;
|
||||
border: none;
|
||||
color: #000000;
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
}
|
||||
.dropdown-toggle:hover {
|
||||
background-color: #FBFAF9 !important;
|
||||
border: none;
|
||||
color: #000000;
|
||||
}
|
||||
.dropdown-toggle::after {
|
||||
margin-left: 1.3em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recommendation-card {
|
||||
cursor: pointer;
|
||||
.pgn__hyperlink {
|
||||
display: block;
|
||||
}
|
||||
@@ -65,3 +85,7 @@
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
}
|
||||
.footer-icon{
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@import "_progressive_profiling_page.scss";
|
||||
@import "_login_page.scss";
|
||||
@import "_forgot_password.scss";
|
||||
@import "_recommendations_card_base.scss";
|
||||
//
|
||||
// ----------------------------
|
||||
// #COLORS
|
||||
|
||||
Reference in New Issue
Block a user