diff --git a/src/courseware/course/course-exit/CatalogSuggestion.jsx b/src/courseware/course/course-exit/CatalogSuggestion.jsx
index a1fb5c3a..2f91868d 100644
--- a/src/courseware/course/course-exit/CatalogSuggestion.jsx
+++ b/src/courseware/course/course-exit/CatalogSuggestion.jsx
@@ -33,7 +33,7 @@ function CatalogSuggestion({ intl, variant }) {
);
return (
-
+
{ setShowWS1681(window.experiment__courseware_celebration_bShowWS1681); });
-
const { administrator } = getAuthenticatedUser();
const dashboardLink = ;
@@ -330,9 +325,7 @@ function CourseCelebration({ intl }) {
/>
))}
{footnote}
- { showWS1681 && }
- { !showWS1681 && }
-
+
>
diff --git a/src/courseware/course/course-exit/CourseExit.test.jsx b/src/courseware/course/course-exit/CourseExit.test.jsx
index 342fca85..4e992efe 100644
--- a/src/courseware/course/course-exit/CourseExit.test.jsx
+++ b/src/courseware/course/course-exit/CourseExit.test.jsx
@@ -21,7 +21,7 @@ jest.mock('@edx/frontend-platform/analytics');
describe('Course Exit Pages', () => {
let axiosMock;
- const store = initializeStore();
+ let store;
const defaultMetadata = Factory.build('courseMetadata', {
user_has_passing_grade: true,
end: '2014-02-05T05:00:00Z',
@@ -31,7 +31,8 @@ describe('Course Exit Pages', () => {
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
-
+ const discoveryRecommendationsUrl = new RegExp(`${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_recommendations/*`);
+ const enrollmentsUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment*`);
function setMetadata(attributes) {
const courseMetadata = { ...defaultMetadata, ...attributes };
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
@@ -43,10 +44,13 @@ describe('Course Exit Pages', () => {
}
beforeEach(() => {
+ store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, defaultCourseBlocks);
-
+ axiosMock.onGet(discoveryRecommendationsUrl).reply(200,
+ Factory.build('courseRecommendations', {}, { numRecs: 2 }));
+ axiosMock.onGet(enrollmentsUrl).reply(200, []);
logUnhandledRequests(axiosMock);
});
@@ -290,6 +294,61 @@ describe('Course Exit Pages', () => {
expect(screen.queryByTestId('excluded-program-type')).not.toBeInTheDocument();
});
});
+
+ describe('Course recommendations', () => {
+ it('Displays recommendations if at least two are available', async () => {
+ await fetchAndRender(
);
+ const recommendationsTable = await screen.findByTestId('course-recommendations');
+ expect(recommendationsTable).toBeInTheDocument();
+ expect(screen.queryByTestId('catalog-suggestion')).not.toBeInTheDocument();
+ });
+
+ it('Displays the generic catalog suggestion if fewer than two recommendations are available', async () => {
+ axiosMock.onGet(discoveryRecommendationsUrl).reply(200,
+ Factory.build('courseRecommendations', {}, { numRecs: 1 }));
+ await fetchAndRender(
);
+ const catalogSuggestion = await screen.findByTestId('catalog-suggestion');
+ expect(catalogSuggestion).toBeInTheDocument();
+ expect(screen.queryByTestId('course-recommendations')).not.toBeInTheDocument();
+ });
+
+ it('Will not recommend a course in which the user is already enrolled', async () => {
+ const initialRecommendations = Factory.build('courseRecommendations', {}, { numRecs: 2 });
+ initialRecommendations.recommendations.push(
+ Factory.build('courseRecommendation', { key: 'edX+EnrolledX', title: 'Already Enrolled' }),
+ );
+ initialRecommendations.recommendations.push(
+ Factory.build('courseRecommendation', { key: 'edX+NotEnrolledX', title: 'Not Already Enrolled' }),
+ );
+ axiosMock.onGet(discoveryRecommendationsUrl).reply(200, initialRecommendations);
+ axiosMock.onGet(enrollmentsUrl).reply(200, [
+ Factory.build('userEnrollment', '',
+ {
+ runKey: 'edX+EnrolledX+1T2021',
+ }),
+ ]);
+ await fetchAndRender(
);
+ const recommendationsTable = await screen.findByTestId('course-recommendations');
+ expect(recommendationsTable).toBeInTheDocument();
+ expect(screen.queryByText('Already Enrolled')).not.toBeInTheDocument();
+ expect(screen.queryByText('Not Already Enrolled')).toBeInTheDocument();
+ });
+
+ it('Will not recommend the same course that the user just finished', async () => {
+ // the uuid returned from the call to discovery is the uuid of the current course
+ const initialRecommendations = Factory.build('courseRecommendations',
+ { uuid: 'my_uuid' },
+ { numRecs: 2 });
+ initialRecommendations.recommendations.push(
+ Factory.build('courseRecommendation', { uuid: 'my_uuid', title: 'Same Course' }),
+ );
+ axiosMock.onGet(discoveryRecommendationsUrl).reply(200, initialRecommendations);
+ await fetchAndRender(
);
+ const recommendationsTable = await screen.findByTestId('course-recommendations');
+ expect(recommendationsTable).toBeInTheDocument();
+ expect(screen.queryByText('Same Course')).not.toBeInTheDocument();
+ });
+ });
});
describe('Course Non-passing Experience', () => {
diff --git a/src/courseware/course/course-exit/CourseRecommendationsExp/CourseRecommendations.exp.jsx b/src/courseware/course/course-exit/CourseRecommendations.jsx
similarity index 80%
rename from src/courseware/course/course-exit/CourseRecommendationsExp/CourseRecommendations.exp.jsx
rename to src/courseware/course/course-exit/CourseRecommendations.jsx
index ab637a7f..7eb5ca0d 100644
--- a/src/courseware/course/course-exit/CourseRecommendationsExp/CourseRecommendations.exp.jsx
+++ b/src/courseware/course/course-exit/CourseRecommendations.jsx
@@ -6,15 +6,17 @@ import {
FormattedMessage, injectIntl, intlShape, defineMessages,
} from '@edx/frontend-platform/i18n';
import { useSelector, useDispatch } from 'react-redux';
-import { Hyperlink, DataTable, CardView } from '@edx/paragon';
+import {
+ Hyperlink, DataTable, CardView, Card,
+} from '@edx/paragon';
import PropTypes from 'prop-types';
import truncate from 'truncate-html';
-import { useModel } from '../../../../generic/model-store';
-import fetchCourseRecommendations from './data/thunks.exp';
-import { FAILED, LOADED, LOADING } from './data/slice.exp';
-import CatalogSuggestion from '../CatalogSuggestion';
-import PageLoading from '../../../../generic/PageLoading';
-import { logClick } from '../utils';
+import { useModel } from '../../../generic/model-store/hooks';
+import fetchCourseRecommendations from './data/thunks';
+import { FAILED, LOADED, LOADING } from './data/slice';
+import CatalogSuggestion from './CatalogSuggestion';
+import PageLoading from '../../../generic/PageLoading';
+import { logClick } from './utils';
const messages = defineMessages({
recommendationsHeading: {
@@ -48,8 +50,7 @@ const ListStyles = {
conjunction: 'conjunction',
};
-// TODO: replace custom card (copied from Prospectus) with Paragon Card component
-function Card({
+function CourseCard({
original: {
title,
image,
@@ -74,24 +75,23 @@ function Card({
return (
-
-
-

-
-
-
- {truncate(title, 70, { reserveLastWord: -1 })}
-
-
+
+
+
+
+
+ {truncate(title, 70, { reserveLastWord: -1 })}
+
+
+
-
-
-
+
+
+
+
);
}
-Card.propTypes = {
+CourseCard.propTypes = {
original: PropTypes.shape({
marketingUrl: PropTypes.string,
title: PropTypes.string,
@@ -135,7 +134,7 @@ Card.propTypes = {
intl: intlShape.isRequired,
};
-const IntlCard = injectIntl(Card);
+const IntlCard = injectIntl(CourseCard);
function CourseRecommendations({ intl, variant }) {
const { courseId, recommendationsStatus } = useSelector(state => ({ ...state.recommendations, ...state.courseware }));
@@ -178,7 +177,7 @@ function CourseRecommendations({ intl, variant }) {
));
return (
-
+
{intl.formatMessage(messages.recommendationsHeading)}
div[class*="col-"] {
+ justify-content: center;
+ }
+ }
+ }
+
+ .discovery-card {
+ &:hover,
+ &:focus {
+ box-shadow: 0 2px 4px 2px $gray-500
+ }
+ }
+
+ .d-card-hero-top {
+ height: 102px;
+ min-height: 102px;
+ background-color: $gray-200;
+ overflow: hidden;
+ border: {
+ radius: 3px 3px 0 0;
+ bottom: 1px solid $success-100;
+ }
+ }
+
+}
diff --git a/src/courseware/course/course-exit/CourseRecommendationsExp/course_recommendations.exp.scss b/src/courseware/course/course-exit/CourseRecommendationsExp/course_recommendations.exp.scss
deleted file mode 100644
index d6968795..00000000
--- a/src/courseware/course/course-exit/CourseRecommendationsExp/course_recommendations.exp.scss
+++ /dev/null
@@ -1,111 +0,0 @@
-$default-border-color: $info-300;
-.course-recommendations {
- .pgn__data-table-wrapper {
- border: 0;
- .pgn__card-grid {
- .row > div[class*="col-"] {
- justify-content: center;
- }
- }
- }
-
- .discovery-card {
- min-width: 270px;
- max-width: 270px;
- width: 270px;
- height: 270px;
- position: relative;
- border-bottom: 3px solid $default-border-color;
- background-color: $white;
- box-shadow: none;
- padding: 0;
- border: none;
-
- &.custom-link {
- background: none;
- }
-
- .d-card-wrapper {
- height: 270px;
- box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.3);
- border: {
- color: $primary-200;
- width: 1px;
- radius: 3px;
- }
- }
-
- .discovery-card-link {
- text-decoration: none;
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
-
- &:focus,
- &:hover {
- border: 0;
- outline: none;
-
- .d-card-wrapper {
- box-shadow: 0 2px 4px 2px $gray-500;
- }
- }
- }
-
- .d-card-hero {
- height: 102px;
- background-color: $gray-200;
- overflow: hidden;
- border: {
- radius: 3px 3px 0 0;
- bottom: 1px solid $success-100;
- }
- }
-
- .d-card-body {
- padding: 28px 20px 33px;
- }
-
- .d-card-footer {
- padding: 0 20px;
- }
-
- .name-heading {
- height: auto;
- line-height: 1.15;
- color: $gray-700;
- font: {
- family: $font-family-sans-serif;
- size: 1.25rem;
- weight: 500;
- }
- }
-
- .provider {
- line-height: 0.86;
- color: $gray-500;
- margin-bottom: 20px;
- font: {
- family: $font-family-sans-serif;
- size: 0.875rem;
- weight: $font-weight-normal;
- }
- }
-
- .card-type {
- height: 20px;
- line-height: 1.67;
- letter-spacing: 0.2px;
- color: $gray-500;
- position: absolute;
- bottom: 10px;
- font: {
- family: $font-family-sans-serif;
- size: 0.75rem;
- weight: $font-weight-normal;
- }
- }
- }
-}
diff --git a/src/courseware/course/course-exit/CourseRecommendationsExp/data/api.exp.js b/src/courseware/course/course-exit/data/api.js
similarity index 100%
rename from src/courseware/course/course-exit/CourseRecommendationsExp/data/api.exp.js
rename to src/courseware/course/course-exit/data/api.js
diff --git a/src/courseware/course/course-exit/CourseRecommendationsExp/data/slice.exp.js b/src/courseware/course/course-exit/data/slice.js
similarity index 100%
rename from src/courseware/course/course-exit/CourseRecommendationsExp/data/slice.exp.js
rename to src/courseware/course/course-exit/data/slice.js
diff --git a/src/courseware/course/course-exit/CourseRecommendationsExp/data/thunks.exp.js b/src/courseware/course/course-exit/data/thunks.js
similarity index 84%
rename from src/courseware/course/course-exit/CourseRecommendationsExp/data/thunks.exp.js
rename to src/courseware/course/course-exit/data/thunks.js
index 6d1e7e0e..f355afb0 100644
--- a/src/courseware/course/course-exit/CourseRecommendationsExp/data/thunks.exp.js
+++ b/src/courseware/course/course-exit/data/thunks.js
@@ -4,9 +4,9 @@ import {
fetchCourseRecommendationsFailure,
fetchCourseRecommendationsRequest,
fetchCourseRecommendationsSuccess,
-} from './slice.exp';
-import getCourseRecommendations from './api.exp';
-import { updateModel } from '../../../../../generic/model-store';
+} from './slice';
+import getCourseRecommendations from './api';
+import { updateModel } from '../../../../generic/model-store';
export default function fetchCourseRecommendations(courseKey, courseId) {
return async (dispatch) => {
diff --git a/src/courseware/data/__factories__/courseMetadata.factory.js b/src/courseware/data/__factories__/courseMetadata.factory.js
index 2c97f450..2f136870 100644
--- a/src/courseware/data/__factories__/courseMetadata.factory.js
+++ b/src/courseware/data/__factories__/courseMetadata.factory.js
@@ -59,4 +59,5 @@ Factory.define('courseMetadata')
user_needs_integrity_signature: false,
is_mfe_special_exams_enabled: false,
is_mfe_proctored_exams_enabled: false,
+ recommendations: null,
});
diff --git a/src/courseware/data/__factories__/courseRecommendations.factory.js b/src/courseware/data/__factories__/courseRecommendations.factory.js
new file mode 100644
index 00000000..e252b96e
--- /dev/null
+++ b/src/courseware/data/__factories__/courseRecommendations.factory.js
@@ -0,0 +1,38 @@
+import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
+
+Factory.define('courseRecommendations')
+ .option('host', '')
+ .option('numRecs', '', 4)
+ .sequence('uuid', (i) => `a-uuid-${i}`)
+ .attr('recommendations', ['numRecs'], (numRecs) => {
+ const recs = [];
+ for (let i = 0; i < numRecs; i++) {
+ recs.push(Factory.build('courseRecommendation'));
+ }
+ return recs;
+ });
+
+Factory.define('courseRecommendation')
+ .sequence('key', (i) => `edX+DemoX${i}`)
+ .sequence('uuid', (i) => `abcd-${i}`)
+ .attrs({
+ title: 'DemoX',
+ owners: [
+ {
+ uuid: '',
+ key: 'edX',
+ },
+ ],
+ image: {
+ src: '',
+ },
+ })
+ .attr('course_run_keys', ['key'], (key) => (
+ [`${key}+1T2021`]
+ ))
+ .attr('url_slug', ['key'], (key) => key)
+ .attr('marketing_url', ['url_slug'], (urlSlug) => `https://www.edx.org/course/${urlSlug}`);
+
+Factory.define('userEnrollment')
+ .option('runKey')
+ .attr('course_details', ['runKey'], (runKey) => (runKey ? { course_id: runKey } : { course_id: 'edX+EnrolledX' }));
diff --git a/src/courseware/data/__factories__/index.js b/src/courseware/data/__factories__/index.js
index e8f3e04d..d3bb1b2c 100644
--- a/src/courseware/data/__factories__/index.js
+++ b/src/courseware/data/__factories__/index.js
@@ -1,2 +1,3 @@
import './courseMetadata.factory';
import './sequenceMetadata.factory';
+import './courseRecommendations.factory';
diff --git a/src/index.scss b/src/index.scss
index e0c8dbeb..c2cfb04e 100755
--- a/src/index.scss
+++ b/src/index.scss
@@ -384,7 +384,7 @@
@import 'course-home/outline-tab/widgets/ProctoringInfoPanel.scss';
@import 'course-home/progress-tab/course-completion/CompletionDonutChart.scss';
@import 'course-home/progress-tab/grades/course-grade/GradeBar.scss';
-@import 'courseware/course/course-exit/CourseRecommendationsExp/course_recommendations.exp';
+@import 'courseware/course/course-exit/CourseRecommendations';
/** [MM-P2P] Experiment */
@import 'experiments/mm-p2p/index.scss';
diff --git a/src/setupTest.js b/src/setupTest.js
index 2d29312f..2868ea6c 100755
--- a/src/setupTest.js
+++ b/src/setupTest.js
@@ -122,7 +122,7 @@ export function loadUnit(message = messageEvent) {
export function logUnhandledRequests(axiosMock) {
axiosMock.onAny().reply((config) => {
// eslint-disable-next-line no-console
- console.log(config.method, config.url);
+ console.log(config.method, config.url.href);
return [200, {}];
});
}
diff --git a/src/store.js b/src/store.js
index a7d500e8..c7ede6e5 100644
--- a/src/store.js
+++ b/src/store.js
@@ -1,7 +1,7 @@
import { configureStore } from '@reduxjs/toolkit';
import { reducer as courseHomeReducer } from './course-home/data';
import { reducer as coursewareReducer } from './courseware/data/slice';
-import { reducer as recommendationsReducer } from './courseware/course/course-exit/CourseRecommendationsExp/data/slice.exp';
+import { reducer as recommendationsReducer } from './courseware/course/course-exit/data/slice';
import { reducer as modelsReducer } from './generic/model-store';
export default function initializeStore() {