From be44f04a245f874120dbaef4fefff1d908acdbe3 Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Tue, 19 Jul 2022 15:08:51 -0400 Subject: [PATCH 1/5] chore: course banner logic and tests --- .../components/Banners/CourseBanner.jsx | 29 +++--- .../components/Banners/CourseBanner.test.jsx | 89 +++++++++++++++++++ .../__snapshots__/CourseBanner.test.jsx.snap | 33 +++++++ .../CourseCard/components/Banners/hooks.js | 20 +++++ .../CourseCard/components/Banners/messages.js | 31 +++++++ 5 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 src/containers/CourseCard/components/Banners/CourseBanner.test.jsx create mode 100644 src/containers/CourseCard/components/Banners/__snapshots__/CourseBanner.test.jsx.snap create mode 100644 src/containers/CourseCard/components/Banners/hooks.js create mode 100644 src/containers/CourseCard/components/Banners/messages.js diff --git a/src/containers/CourseCard/components/Banners/CourseBanner.jsx b/src/containers/CourseCard/components/Banners/CourseBanner.jsx index b713a5c..8e54804 100644 --- a/src/containers/CourseCard/components/Banners/CourseBanner.jsx +++ b/src/containers/CourseCard/components/Banners/CourseBanner.jsx @@ -3,21 +3,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Hyperlink } from '@edx/paragon'; -import { useCardValues } from 'hooks'; -import { selectors } from 'data/redux'; - import Banner from 'components/Banner'; - -const { cardData } = selectors; +import { useCourseBannerData } from './hooks'; +import messages from './messages'; export const CourseBanner = ({ courseNumber }) => { - const courseData = useCardValues(courseNumber, { - isVerified: cardData.isVerified, - isCourseRunActive: cardData.isCourseRunActive, - canUpgrade: cardData.canUpgrade, - isAuditAccessExpired: cardData.isAuditAccessExpired, - courseWebsite: cardData.courseWebsite, - }); + const { courseData, formatMessage } = useCourseBannerData({ courseNumber }); if (courseData.isVerified) { return null; } @@ -25,22 +16,28 @@ export const CourseBanner = ({ courseNumber }) => { if (courseData.canUpgrade) { return ( - Your audit access to this course has expired. Upgrade now to access your course again. + {formatMessage(messages.auditAccessExpired)} + {' '} + {formatMessage(messages.upgradeToAccess)} ); } return ( - Your audit access to this course has expired. Find another course + {formatMessage(messages.auditAccessExpired)} + {' '} + {formatMessage(messages.findAnotherCourse)} ); } if (courseData.isCourseRunActive && !courseData.canUpgrade) { return ( - Your upgrade deadline for this course has passed. To upgrade, enroll in a session that is farther in the future. + {formatMessage(messages.upgradeDeadlinePassed)} {' '} - Explore course details. + + {formatMessage(messages.exploreCourseDetails)} + ); } diff --git a/src/containers/CourseCard/components/Banners/CourseBanner.test.jsx b/src/containers/CourseCard/components/Banners/CourseBanner.test.jsx new file mode 100644 index 0000000..f35e7da --- /dev/null +++ b/src/containers/CourseCard/components/Banners/CourseBanner.test.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Hyperlink } from '@edx/paragon'; + +import { formatMessage } from 'testUtils'; +import { CourseBanner } from './CourseBanner'; + +import * as hooks from './hooks'; +import messages from './messages'; + +jest.mock('components/Banner', () => 'Banner'); + +jest.mock('./hooks', () => ({ + useCourseBannerData: jest.fn(), +})); + +const courseNumber = 'my-test-course-number'; + +const courseData = { + isVerified: false, + isCourseRunActive: false, + canUpgrade: false, + isAuditAccessExpired: false, + courseWebsite: 'test-course-website', +}; + +let el; + +const render = (overrides) => { + hooks.useCourseBannerData.mockReturnValueOnce({ + courseData: { + ...courseData, + ...overrides, + }, + formatMessage, + }); + el = shallow(); +}; + +describe('CourseBanner', () => { + test('no display if learner is verified', () => { + render({ isVerified: true }); + expect(el.isEmptyRender()).toEqual(true); + }); + describe('audit access expired, can upgrade', () => { + beforeEach(() => { + render({ isAuditAccessExpired: true, canUpgrade: true }); + }); + test('snapshot: (auditAccessExpired, upgradeToAccess)', () => { + expect(el).toMatchSnapshot(); + }); + test('messages: (auditAccessExpired, upgradeToAccess)', () => { + expect(el.text()).toContain(messages.auditAccessExpired.defaultMessage); + expect(el.text()).toContain(messages.upgradeToAccess.defaultMessage); + }); + }); + describe('audit access expired, cannot upgrade', () => { + beforeEach(() => { + render({ isAuditAccessExpired: true }); + }); + test('snapshot: (auditAccessExpired, findAnotherCourse hyperlink)', () => { + expect(el).toMatchSnapshot(); + }); + test('messages: (auditAccessExpired, upgradeToAccess)', () => { + expect(el.text()).toContain(messages.auditAccessExpired.defaultMessage); + expect(el.find(Hyperlink).text()).toEqual(messages.findAnotherCourse.defaultMessage); + }); + }); + describe('course run active and cannot upgrade', () => { + beforeEach(() => { + render({ isCourseRunActive: true }); + }); + test('snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => { + expect(el).toMatchSnapshot(); + }); + test('messages: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => { + expect(el.text()).toContain(messages.upgradeDeadlinePassed.defaultMessage); + const link = el.find(Hyperlink); + expect(link.text()).toEqual(messages.exploreCourseDetails.defaultMessage); + expect(link.props().destination).toEqual(courseData.courseWebsite); + }); + }); + test('no display if audit access not expired and (course is not active or can upgrade)', () => { + render(); + expect(el.isEmptyRender()).toEqual(true); + render({ isCourseRunActive: true, canUpgrade: true }); + expect(el.isEmptyRender()).toEqual(true); + }); +}); diff --git a/src/containers/CourseCard/components/Banners/__snapshots__/CourseBanner.test.jsx.snap b/src/containers/CourseCard/components/Banners/__snapshots__/CourseBanner.test.jsx.snap new file mode 100644 index 0000000..5c3160a --- /dev/null +++ b/src/containers/CourseCard/components/Banners/__snapshots__/CourseBanner.test.jsx.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CourseBanner audit access expired, can upgrade snapshot: (auditAccessExpired, upgradeToAccess) 1`] = ` + + Your audit access to this course has expired. + + Upgrade now to access your course again. + +`; + +exports[`CourseBanner audit access expired, cannot upgrade snapshot: (auditAccessExpired, findAnotherCourse hyperlink) 1`] = ` + + Your audit access to this course has expired. + + + Find another course + + +`; + +exports[`CourseBanner course run active and cannot upgrade snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink) 1`] = ` + + Your upgrade deadline for this course has passed. To upgrade, enroll in a session that is farther in the future. + + + Explore course details. + + +`; diff --git a/src/containers/CourseCard/components/Banners/hooks.js b/src/containers/CourseCard/components/Banners/hooks.js new file mode 100644 index 0000000..413bc76 --- /dev/null +++ b/src/containers/CourseCard/components/Banners/hooks.js @@ -0,0 +1,20 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useCardValues } from 'hooks'; +import { selectors } from 'data/redux'; + +const { cardData } = selectors; + +export const useCourseBannerData = ({ courseNumber }) => ({ + courseData: useCardValues(courseNumber, { + isVerified: cardData.isVerified, + isCourseRunActive: cardData.isCourseRunActive, + canUpgrade: cardData.canUpgrade, + isAuditAccessExpired: cardData.isAuditAccessExpired, + courseWebsite: cardData.courseWebsite, + }), + formatMessage: useIntl().formatMessage, +}); + +export default { + useCourseBannerData, +}; diff --git a/src/containers/CourseCard/components/Banners/messages.js b/src/containers/CourseCard/components/Banners/messages.js new file mode 100644 index 0000000..ac7a1d3 --- /dev/null +++ b/src/containers/CourseCard/components/Banners/messages.js @@ -0,0 +1,31 @@ +import { StrictDict } from 'utils'; + +export const messages = StrictDict({ + auditAccessExpired: { + id: 'learner-dash.courseCard.banners.auditAccessExpired', + description: 'Audit access expiration banner message', + defaultMessage: 'Your audit access to this course has expired.', + }, + upgradeToAccess: { + id: 'learner-dash.courseCard.banners.upgradeToAccess', + description: 'Upgrade prompt for audit-expired learners that can still upgrade', + defaultMessage: 'Upgrade now to access your course again.', + }, + findAnotherCourse: { + id: 'learner-dash.courseCard.banners.findAnotherCourse', + description: 'Action prompt taking learners to course exploration', + defaultMessage: 'Find another course', + }, + upgradeDeadlinePassed: { + id: 'learner-dash.courseCard.banners.upgradeDeadlinePassed', + description: 'Audit upgrade deadline passed banner message', + defaultMessage: 'Your upgrade deadline for this course has passed. To upgrade, enroll in a session that is farther in the future.', + }, + exploreCourseDetails: { + id: 'learner-dash.courseCard.banners.exploreCourseDetails', + description: 'Action prompt taking learners to course details page', + defaultMessage: 'Explore course details.', + }, +}); + +export default messages; From 25fb5cc41cb631d7e124abf8eea2688b9ca44b4a Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Tue, 19 Jul 2022 15:21:29 -0400 Subject: [PATCH 2/5] chore: course banner hook tests --- .../components/Banners/hooks.test.js | 39 +++++++++++++++++++ src/setupTest.jsx | 16 ++++---- 2 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 src/containers/CourseCard/components/Banners/hooks.test.js diff --git a/src/containers/CourseCard/components/Banners/hooks.test.js b/src/containers/CourseCard/components/Banners/hooks.test.js new file mode 100644 index 0000000..60c3ea1 --- /dev/null +++ b/src/containers/CourseCard/components/Banners/hooks.test.js @@ -0,0 +1,39 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { selectors } from 'data/redux'; +import { testCardValues } from 'testUtils'; +import * as appHooks from 'hooks'; + +import * as hooks from './hooks'; + +const { fieldKeys } = selectors.cardData; + +const courseNumber = 'my-test-course-number'; + +describe('CourseCard banner hooks', () => { + let out; + const { formatMessage } = useIntl(); + describe('useCourseBannerData', () => { + const courseData = { + isVerified: false, + isCourseRunActive: false, + canUpgrade: false, + isAuditAcessExpired: false, + courseWebsite: 'test-course-website', + }; + beforeEach(() => { + appHooks.useCardValues.mockReturnValueOnce(courseData); + out = hooks.useCourseBannerData({ courseNumber }); + }); + testCardValues(courseNumber, { + isVerified: fieldKeys.isVerified, + isCourseRunActive: fieldKeys.isCourseRunActive, + canUpgrade: fieldKeys.canUpgrade, + isAuditAccessExpired: fieldKeys.isAuditAccessExpired, + courseWebsite: fieldKeys.courseWebsite, + }); + it('forwards formatMessage from useIntl', () => { + expect(out.formatMessage).toEqual(formatMessage); + }); + }); +}); diff --git a/src/setupTest.jsx b/src/setupTest.jsx index 68e4c3f..da0b932 100755 --- a/src/setupTest.jsx +++ b/src/setupTest.jsx @@ -18,19 +18,21 @@ jest.mock('react', () => ({ jest.mock('@edx/frontend-platform/i18n', () => { const i18n = jest.requireActual('@edx/frontend-platform/i18n'); const PropTypes = jest.requireActual('prop-types'); + const formatMessage = jest.fn((msg) => ( + + )); + const formatDate = jest.fn().mockName('useIntl.formatDate'); return { ...i18n, intlShape: PropTypes.shape({ formatMessage: PropTypes.func, }), useIntl: () => ({ - formatMessage: (msg) => ( - - ), - formatDate: jest.fn().mockName('useIntl.formatDate'), + formatMessage, + formatDate, }), IntlProvider: () => 'IntlProvider', defineMessages: m => m, From 02e31e25986c3dfe9f3c0356317be64a1154057b Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Tue, 19 Jul 2022 16:00:02 -0400 Subject: [PATCH 3/5] chore: better message tests --- .../components/Banners/CertificateBanner.jsx | 4 ++ .../components/Banners/CourseBanner.jsx | 15 ++++- .../components/Banners/CourseBanner.test.jsx | 32 +++++----- .../CourseCard/components/Banners/hooks.js | 20 ------- .../components/Banners/hooks.test.js | 39 ------------ .../components/CourseCardActions/hooks.js | 3 +- .../CourseCardActions/hooks.test.js | 3 +- src/containers/CourseCard/hooks.js | 3 +- src/containers/CourseCard/hooks.test.js | 4 +- .../__snapshots__/index.test.jsx.snap | 60 +++++-------------- .../__snapshots__/index.test.jsx.snap | 28 ++------- .../__snapshots__/ProgramCard.test.jsx.snap | 20 ++----- .../__snapshots__/ConfirmPane.test.jsx.snap | 16 ++--- .../__snapshots__/FinishedPane.test.jsx.snap | 28 +++------ .../__snapshots__/ReasonPane.test.jsx.snap | 54 ++++------------- src/setupTest.jsx | 7 +-- src/testUtils.js | 6 +- 17 files changed, 98 insertions(+), 244 deletions(-) delete mode 100644 src/containers/CourseCard/components/Banners/hooks.js delete mode 100644 src/containers/CourseCard/components/Banners/hooks.test.js diff --git a/src/containers/CourseCard/components/Banners/CertificateBanner.jsx b/src/containers/CourseCard/components/Banners/CertificateBanner.jsx index 6e19b69..6054512 100644 --- a/src/containers/CourseCard/components/Banners/CertificateBanner.jsx +++ b/src/containers/CourseCard/components/Banners/CertificateBanner.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import { Hyperlink } from '@edx/paragon'; import { CheckCircle } from '@edx/paragon/icons'; +// import { useIntl } from '@edx/frontend-platform/i18n'; import { selectors } from 'data/redux'; import Banner from 'components/Banner'; @@ -27,6 +28,9 @@ export const CertificateBanner = ({ courseNumber }) => { isVerified: cardData.isVerified, minPassingGrade: cardData.minPassingGrade, }); + + // const { formatMessage } = useIntl(); + if (data.isRestricted) { return ( diff --git a/src/containers/CourseCard/components/Banners/CourseBanner.jsx b/src/containers/CourseCard/components/Banners/CourseBanner.jsx index 8e54804..b34f283 100644 --- a/src/containers/CourseCard/components/Banners/CourseBanner.jsx +++ b/src/containers/CourseCard/components/Banners/CourseBanner.jsx @@ -2,13 +2,24 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Hyperlink } from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useCardValues } from 'hooks'; +import { selectors } from 'data/redux'; import Banner from 'components/Banner'; -import { useCourseBannerData } from './hooks'; import messages from './messages'; +const { cardData } = selectors; + export const CourseBanner = ({ courseNumber }) => { - const { courseData, formatMessage } = useCourseBannerData({ courseNumber }); + const courseData = useCardValues(courseNumber, { + isVerified: cardData.isVerified, + isCourseRunActive: cardData.isCourseRunActive, + canUpgrade: cardData.canUpgrade, + isAuditAccessExpired: cardData.isAuditAccessExpired, + courseWebsite: cardData.courseWebsite, + }); + const { formatMessage } = useIntl(); if (courseData.isVerified) { return null; } diff --git a/src/containers/CourseCard/components/Banners/CourseBanner.test.jsx b/src/containers/CourseCard/components/Banners/CourseBanner.test.jsx index f35e7da..3740718 100644 --- a/src/containers/CourseCard/components/Banners/CourseBanner.test.jsx +++ b/src/containers/CourseCard/components/Banners/CourseBanner.test.jsx @@ -2,20 +2,20 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Hyperlink } from '@edx/paragon'; -import { formatMessage } from 'testUtils'; +import * as appHooks from 'hooks'; +import { testCardValues } from 'testUtils'; +import { selectors } from 'data/redux'; import { CourseBanner } from './CourseBanner'; -import * as hooks from './hooks'; import messages from './messages'; jest.mock('components/Banner', () => 'Banner'); -jest.mock('./hooks', () => ({ - useCourseBannerData: jest.fn(), -})); - +const { fieldKeys } = selectors.cardData; const courseNumber = 'my-test-course-number'; +let el; + const courseData = { isVerified: false, isCourseRunActive: false, @@ -24,20 +24,22 @@ const courseData = { courseWebsite: 'test-course-website', }; -let el; - -const render = (overrides) => { - hooks.useCourseBannerData.mockReturnValueOnce({ - courseData: { - ...courseData, - ...overrides, - }, - formatMessage, +const render = (overrides = {}) => { + appHooks.useCardValues.mockReturnValueOnce({ + ...courseData, + ...overrides, }); el = shallow(); }; describe('CourseBanner', () => { + testCardValues(courseNumber, { + isVerified: fieldKeys.isVerified, + isCourseRunActive: fieldKeys.isCourseRunActive, + canUpgrade: fieldKeys.canUpgrade, + isAuditAccessExpired: fieldKeys.isAuditAccessExpired, + courseWebsite: fieldKeys.courseWebsite, + }, render); test('no display if learner is verified', () => { render({ isVerified: true }); expect(el.isEmptyRender()).toEqual(true); diff --git a/src/containers/CourseCard/components/Banners/hooks.js b/src/containers/CourseCard/components/Banners/hooks.js deleted file mode 100644 index 413bc76..0000000 --- a/src/containers/CourseCard/components/Banners/hooks.js +++ /dev/null @@ -1,20 +0,0 @@ -import { useIntl } from '@edx/frontend-platform/i18n'; -import { useCardValues } from 'hooks'; -import { selectors } from 'data/redux'; - -const { cardData } = selectors; - -export const useCourseBannerData = ({ courseNumber }) => ({ - courseData: useCardValues(courseNumber, { - isVerified: cardData.isVerified, - isCourseRunActive: cardData.isCourseRunActive, - canUpgrade: cardData.canUpgrade, - isAuditAccessExpired: cardData.isAuditAccessExpired, - courseWebsite: cardData.courseWebsite, - }), - formatMessage: useIntl().formatMessage, -}); - -export default { - useCourseBannerData, -}; diff --git a/src/containers/CourseCard/components/Banners/hooks.test.js b/src/containers/CourseCard/components/Banners/hooks.test.js deleted file mode 100644 index 60c3ea1..0000000 --- a/src/containers/CourseCard/components/Banners/hooks.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import { useIntl } from '@edx/frontend-platform/i18n'; - -import { selectors } from 'data/redux'; -import { testCardValues } from 'testUtils'; -import * as appHooks from 'hooks'; - -import * as hooks from './hooks'; - -const { fieldKeys } = selectors.cardData; - -const courseNumber = 'my-test-course-number'; - -describe('CourseCard banner hooks', () => { - let out; - const { formatMessage } = useIntl(); - describe('useCourseBannerData', () => { - const courseData = { - isVerified: false, - isCourseRunActive: false, - canUpgrade: false, - isAuditAcessExpired: false, - courseWebsite: 'test-course-website', - }; - beforeEach(() => { - appHooks.useCardValues.mockReturnValueOnce(courseData); - out = hooks.useCourseBannerData({ courseNumber }); - }); - testCardValues(courseNumber, { - isVerified: fieldKeys.isVerified, - isCourseRunActive: fieldKeys.isCourseRunActive, - canUpgrade: fieldKeys.canUpgrade, - isAuditAccessExpired: fieldKeys.isAuditAccessExpired, - courseWebsite: fieldKeys.courseWebsite, - }); - it('forwards formatMessage from useIntl', () => { - expect(out.formatMessage).toEqual(formatMessage); - }); - }); -}); diff --git a/src/containers/CourseCard/components/CourseCardActions/hooks.js b/src/containers/CourseCard/components/CourseCardActions/hooks.js index 2661f5a..df95e77 100644 --- a/src/containers/CourseCard/components/CourseCardActions/hooks.js +++ b/src/containers/CourseCard/components/CourseCardActions/hooks.js @@ -1,7 +1,8 @@ import { Locked } from '@edx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { selectors } from 'data/redux'; -import { useIntl, useCardValues } from 'hooks'; +import { useCardValues } from 'hooks'; import messages from './messages'; const { cardData } = selectors; diff --git a/src/containers/CourseCard/components/CourseCardActions/hooks.test.js b/src/containers/CourseCard/components/CourseCardActions/hooks.test.js index 8b14c0c..17d7f6c 100644 --- a/src/containers/CourseCard/components/CourseCardActions/hooks.test.js +++ b/src/containers/CourseCard/components/CourseCardActions/hooks.test.js @@ -1,4 +1,5 @@ import { Locked } from '@edx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { selectors } from 'data/redux'; @@ -22,7 +23,7 @@ const props = { describe('CourseCardActions hooks', () => { let out; - const { formatMessage } = appHooks.useIntl(); + const { formatMessage } = useIntl(); describe('data connection', () => { beforeEach(() => { out = hooks.useCardActionData({ courseNumber }); diff --git a/src/containers/CourseCard/hooks.js b/src/containers/CourseCard/hooks.js index 94344c5..42d9591 100644 --- a/src/containers/CourseCard/hooks.js +++ b/src/containers/CourseCard/hooks.js @@ -1,6 +1,7 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; import { selectors } from 'data/redux'; -import { useIntl, useCardValues } from 'hooks'; +import { useCardValues } from 'hooks'; import * as module from './hooks'; import messages from './messages'; diff --git a/src/containers/CourseCard/hooks.test.js b/src/containers/CourseCard/hooks.test.js index 86345ad..f367018 100644 --- a/src/containers/CourseCard/hooks.test.js +++ b/src/containers/CourseCard/hooks.test.js @@ -1,3 +1,5 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; + import { keyStore } from 'utils'; import { selectors } from 'data/redux'; import * as appHooks from 'hooks'; @@ -15,7 +17,7 @@ const hookKeys = keyStore(hooks); describe('CourseCard hooks', () => { let out; - const { formatMessage, formatDate } = appHooks.useIntl(); + const { formatMessage, formatDate } = useIntl(); describe('useCardData', () => { beforeEach(() => { jest.spyOn(hooks, hookKeys.useAccessMessage).mockImplementationOnce(mockAccessMessage); diff --git a/src/containers/EmailSettingsModal/__snapshots__/index.test.jsx.snap b/src/containers/EmailSettingsModal/__snapshots__/index.test.jsx.snap index e9b5224..d3ee4d0 100644 --- a/src/containers/EmailSettingsModal/__snapshots__/index.test.jsx.snap +++ b/src/containers/EmailSettingsModal/__snapshots__/index.test.jsx.snap @@ -16,38 +16,28 @@ exports[`EmailSettingsModal render snapshot: emails disabled, show: false 1`] = } >

- + Receive course emails?

- + Course emails are on

- + Course emailsi include important information about your course.

@@ -70,38 +60,28 @@ exports[`EmailSettingsModal render snapshot: emails disabled, show: true 1`] = ` } >

- + Receive course emails?

- + Course emails are on

- + Course emailsi include important information about your course.

@@ -124,38 +104,28 @@ exports[`EmailSettingsModal render snapshot: emails enabled, show: true 1`] = ` } >

- + Receive course emails?

- + Course emails are off

- + Course emailsi include important information about your course.

diff --git a/src/containers/RelatedProgramsModal/__snapshots__/index.test.jsx.snap b/src/containers/RelatedProgramsModal/__snapshots__/index.test.jsx.snap index afaae12..fbe554c 100644 --- a/src/containers/RelatedProgramsModal/__snapshots__/index.test.jsx.snap +++ b/src/containers/RelatedProgramsModal/__snapshots__/index.test.jsx.snap @@ -9,19 +9,13 @@ exports[`RelatedProgramsModal snapshot: closed 1`] = ` isOpen={false} onClose={[MockFunction props.closeModal]} size="lg" - title={ - - } + title="Related Programs" > - + Related Programs

- + Are you looking to expand your knowledge? Enrolling in a Program lets you take a series of courses in the subject that you're interested in

- } + title="Related Programs" > - + Related Programs

- + Are you looking to expand your knowledge? Enrolling in a Program lets you take a series of courses in the subject that you're interested in

- } + logoAlt="Provider logo" logoSrc="props.data.logoUrl" src="props.data.bannerUrl" - srcAlt={ - - } + srcAlt="Programm banner" /> - + 2 Courses • - + 1 Weeks diff --git a/src/containers/UnenrollConfirmModal/components/__snapshots__/ConfirmPane.test.jsx.snap b/src/containers/UnenrollConfirmModal/components/__snapshots__/ConfirmPane.test.jsx.snap index 4706b8d..35cd180 100644 --- a/src/containers/UnenrollConfirmModal/components/__snapshots__/ConfirmPane.test.jsx.snap +++ b/src/containers/UnenrollConfirmModal/components/__snapshots__/ConfirmPane.test.jsx.snap @@ -3,30 +3,22 @@ exports[`UnenrollConfirmModal ConfirmPane snapshot 1`] = `

- + Unenroll from course?

- + Progress that you've made so far will not be saved

diff --git a/src/containers/UnenrollConfirmModal/components/__snapshots__/FinishedPane.test.jsx.snap b/src/containers/UnenrollConfirmModal/components/__snapshots__/FinishedPane.test.jsx.snap index 8015b6a..88a0dd1 100644 --- a/src/containers/UnenrollConfirmModal/components/__snapshots__/FinishedPane.test.jsx.snap +++ b/src/containers/UnenrollConfirmModal/components/__snapshots__/FinishedPane.test.jsx.snap @@ -3,22 +3,16 @@ exports[`UnenrollConfirmModal FinishedPane snapshot: did not give reason 1`] = `

- + You are unenrolled

- + This course will be removed from your dashboard.

@@ -27,25 +21,17 @@ exports[`UnenrollConfirmModal FinishedPane snapshot: did not give reason 1`] = ` exports[`UnenrollConfirmModal FinishedPane snapshot: gave reason 1`] = `

- + You are unenrolled

- - + Thank you for sharing your reason for unenrolling. + This course will be removed from your dashboard.

diff --git a/src/containers/UnenrollConfirmModal/components/__snapshots__/ReasonPane.test.jsx.snap b/src/containers/UnenrollConfirmModal/components/__snapshots__/ReasonPane.test.jsx.snap index 2490718..87a1821 100644 --- a/src/containers/UnenrollConfirmModal/components/__snapshots__/ReasonPane.test.jsx.snap +++ b/src/containers/UnenrollConfirmModal/components/__snapshots__/ReasonPane.test.jsx.snap @@ -3,9 +3,7 @@ exports[`UnenrollConfirmModal ReasonPane snapshot 1`] = `

- + What's your main reason for unenrolling?

- + I don't have the academic or language prerequisites - + The course material was too hard - + This won't help me reach my goals - + Something was broken - + I don't have the time - + I just wanted to browse the material - + I don't have enough support - + I am not happy with the quality of the content - + The course material was too easy - } + placeholder="Other" value="props.reason.customOption.value" /> @@ -103,16 +79,12 @@ exports[`UnenrollConfirmModal ReasonPane snapshot 1`] = ` onClick={[MockFunction props.reason.skip]} variant="tertiary" > - + Skip
diff --git a/src/setupTest.jsx b/src/setupTest.jsx index da0b932..e88ac35 100755 --- a/src/setupTest.jsx +++ b/src/setupTest.jsx @@ -18,12 +18,7 @@ jest.mock('react', () => ({ jest.mock('@edx/frontend-platform/i18n', () => { const i18n = jest.requireActual('@edx/frontend-platform/i18n'); const PropTypes = jest.requireActual('prop-types'); - const formatMessage = jest.fn((msg) => ( - - )); + const { formatMessage } = jest.requireActual('./testUtils'); const formatDate = jest.fn().mockName('useIntl.formatDate'); return { ...i18n, diff --git a/src/testUtils.js b/src/testUtils.js index d2863fc..04a6237 100644 --- a/src/testUtils.js +++ b/src/testUtils.js @@ -200,9 +200,13 @@ export class MockUseState { * Test that useCardValues was called with the given courseNumber and selector mapping. * @param {string} courseNumber - course run identifier * @param {obj} mapping - value mapping { : } + * @param {[func]} beforeEachFn - optional beforeEach method */ -export const testCardValues = (courseNumber, mapping) => { +export const testCardValues = (courseNumber, mapping, beforeEachFn) => { describe('cardData values', () => { + if (beforeEachFn) { + beforeEach(beforeEachFn); + } let mapped; test('passess correct courseNumber', () => { expect(appHooks.useCardValues.mock.calls[0][0]).toEqual(courseNumber); From 8edd4570b43e61f175d28d28b116fc8fe87a44e2 Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Thu, 21 Jul 2022 14:36:07 -0400 Subject: [PATCH 4/5] chore: clean up redux hook usage --- .../components/Banners/CertificateBanner.jsx | 62 ++--- .../components/Banners/CourseBanner.jsx | 29 +- .../components/Banners/CourseBanner.test.jsx | 64 +++-- .../components/Banners/EntitlementBanner.jsx | 23 +- .../CourseCard/components/Banners/messages.js | 5 + .../components/CourseCardActions/hooks.js | 31 +-- .../CourseCardActions/hooks.test.js | 73 ++--- .../components/RelatedProgramsBadge/hooks.jsx | 10 +- .../RelatedProgramsBadge/hooks.test.js | 26 +- src/containers/CourseCard/hooks.js | 43 ++- src/containers/CourseCard/hooks.test.js | 165 ++++++------ src/containers/EmailSettingsModal/hooks.js | 11 +- .../EmailSettingsModal/hooks.test.js | 19 +- .../components/ProgramCard.jsx | 9 +- src/containers/RelatedProgramsModal/hooks.js | 27 +- .../RelatedProgramsModal/hooks.test.js | 67 +---- src/containers/RelatedProgramsModal/index.jsx | 2 +- src/data/redux/app/hooks.js | 23 ++ src/data/redux/app/reducer.js | 11 + src/data/redux/app/selectors.js | 65 ++++- src/data/redux/cardData/index.js | 2 - src/data/redux/cardData/selectors.js | 67 ----- src/data/redux/cardData/selectors.test.js | 255 ------------------ src/data/redux/hooks.js | 23 ++ src/data/redux/index.js | 6 +- src/data/redux/thunkActions/app.js | 6 +- src/data/services/lms/api.js | 1 + src/data/services/lms/fakeData/courses.js | 46 +++- src/hooks.js | 14 - src/setupTest.jsx | 24 +- src/test/app.test.jsx | 4 +- src/testUtils.js | 30 --- 32 files changed, 463 insertions(+), 780 deletions(-) create mode 100644 src/data/redux/app/hooks.js delete mode 100644 src/data/redux/cardData/index.js delete mode 100644 src/data/redux/cardData/selectors.js delete mode 100644 src/data/redux/cardData/selectors.test.js create mode 100644 src/data/redux/hooks.js diff --git a/src/containers/CourseCard/components/Banners/CertificateBanner.jsx b/src/containers/CourseCard/components/Banners/CertificateBanner.jsx index 6054512..3ef40e4 100644 --- a/src/containers/CourseCard/components/Banners/CertificateBanner.jsx +++ b/src/containers/CourseCard/components/Banners/CertificateBanner.jsx @@ -4,38 +4,30 @@ import PropTypes from 'prop-types'; import { Hyperlink } from '@edx/paragon'; import { CheckCircle } from '@edx/paragon/icons'; -// import { useIntl } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; -import { selectors } from 'data/redux'; +import { hooks as appHooks } from 'data/redux'; import Banner from 'components/Banner'; -import { useCardValues } from 'hooks'; -const { cardData } = selectors; - -const restrictedMessage = 'Your Certificate of Achievement is being held pending confirmation that the issuance of your Certificate is in compliance with strict U.S. embargoes on Iran, Cuba, Syria, and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting '; +import messages from './messages'; export const CertificateBanner = ({ courseNumber }) => { - const data = useCardValues(courseNumber, { - certAvailableDate: cardData.certAvailableDate, - certDownloadUrl: cardData.certDownloadUrl, - certPreviewUrl: cardData.certPreviewUrl, - isAudit: cardData.isAudit, - isCertDownloadable: cardData.isCertDownloadable, - isCertEarnedButUnavailable: cardData.isCertEarnedButUnavailable, - isCourseRunFinished: cardData.isCourseRunFinished, - isPassing: cardData.isPassing, - isRestricted: cardData.isRestricted, - isVerified: cardData.isVerified, - minPassingGrade: cardData.minPassingGrade, - }); + const certificate = appHooks.useCardCertificateData(courseNumber); + const { + isAudit, + isVerified, + hasFinished, + } = appHooks.useCardEnrollmentData(courseNumber); + const { isPassing } = appHooks.useCardGradeData(courseNumber); + const { minPassingGrade } = appHooks.useCardCourseRunData(courseNumber); + const { formatMessage } = useIntl(); - // const { formatMessage } = useIntl(); - - if (data.isRestricted) { + if (certificate.isRestricted) { return ( - {restrictedMessage}info@example.com - {data.isVerified && ( + {formatMessage(messages.certRestricted)} + info@example.com + {isVerified && ( <> If you would like a refund on your Certificate of Achievement, please contact our billing address billing@example.com @@ -43,11 +35,11 @@ export const CertificateBanner = ({ courseNumber }) => { ); } - if (!data.isPassing) { - if (data.isAudit) { - return ( Grade required to pass the course: {data.minPassingGrade}% ); + if (!isPassing) { + if (isAudit) { + return ( Grade required to pass the course: {minPassingGrade}% ); } - if (data.isCourseRunFinished) { + if (hasFinished) { return ( You are not eligible for a certificate. View grades. @@ -56,17 +48,17 @@ export const CertificateBanner = ({ courseNumber }) => { } return ( - Grade required for a certificate: {data.minPassingGrade}% + Grade required for a certificate: {minPassingGrade}% ); } - if (data.isCertDownloadable) { - if (data.certPreviewUrl) { + if (certificate.isDownloadable) { + if (certificate.previewUrl) { return ( Congratulations. Your certificate is ready. {' '} - View Certificate. + View Certificate. ); } @@ -74,14 +66,14 @@ export const CertificateBanner = ({ courseNumber }) => { Congratulations. Your certificate is ready. {' '} - Download Certificate. + Download Certificate. ); } - if (data.isCertEarnedButUnavailable) { + if (certificate.isEarnedButUnavailable) { return ( - Your grade and certificate will be ready after {data.certAvailableDate}. + Your grade and certificate will be ready after {certificate.availableDate}. ); } diff --git a/src/containers/CourseCard/components/Banners/CourseBanner.jsx b/src/containers/CourseCard/components/Banners/CourseBanner.jsx index b34f283..3154190 100644 --- a/src/containers/CourseCard/components/Banners/CourseBanner.jsx +++ b/src/containers/CourseCard/components/Banners/CourseBanner.jsx @@ -4,27 +4,24 @@ import PropTypes from 'prop-types'; import { Hyperlink } from '@edx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useCardValues } from 'hooks'; -import { selectors } from 'data/redux'; +import { hooks as appHooks } from 'data/redux'; import Banner from 'components/Banner'; import messages from './messages'; -const { cardData } = selectors; - export const CourseBanner = ({ courseNumber }) => { - const courseData = useCardValues(courseNumber, { - isVerified: cardData.isVerified, - isCourseRunActive: cardData.isCourseRunActive, - canUpgrade: cardData.canUpgrade, - isAuditAccessExpired: cardData.isAuditAccessExpired, - courseWebsite: cardData.courseWebsite, - }); + const { + isVerified, + isAuditAccessExpired, + canUpgrade, + } = appHooks.useCardEnrollmentData(courseNumber); + const courseRun = appHooks.useCardCourseRunData(courseNumber); + const course = appHooks.useCardCourseData(courseNumber); const { formatMessage } = useIntl(); - if (courseData.isVerified) { return null; } + if (isVerified) { return null; } - if (courseData.isAuditAccessExpired) { - if (courseData.canUpgrade) { + if (isAuditAccessExpired) { + if (canUpgrade) { return ( {formatMessage(messages.auditAccessExpired)} @@ -41,12 +38,12 @@ export const CourseBanner = ({ courseNumber }) => { ); } - if (courseData.isCourseRunActive && !courseData.canUpgrade) { + if (courseRun.isActive && !canUpgrade) { return ( {formatMessage(messages.upgradeDeadlinePassed)} {' '} - + {formatMessage(messages.exploreCourseDetails)} diff --git a/src/containers/CourseCard/components/Banners/CourseBanner.test.jsx b/src/containers/CourseCard/components/Banners/CourseBanner.test.jsx index 3740718..24a23fa 100644 --- a/src/containers/CourseCard/components/Banners/CourseBanner.test.jsx +++ b/src/containers/CourseCard/components/Banners/CourseBanner.test.jsx @@ -2,51 +2,71 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Hyperlink } from '@edx/paragon'; -import * as appHooks from 'hooks'; -import { testCardValues } from 'testUtils'; -import { selectors } from 'data/redux'; +import { hooks as appHooks } from 'data/redux'; import { CourseBanner } from './CourseBanner'; import messages from './messages'; jest.mock('components/Banner', () => 'Banner'); +jest.mock('data/redux', () => ({ + hooks: { + useCardCourseData: jest.fn(), + useCardCourseRunData: jest.fn(), + useCardEnrollmentData: jest.fn(), + }, +})); -const { fieldKeys } = selectors.cardData; const courseNumber = 'my-test-course-number'; let el; -const courseData = { +const enrollmentData = { isVerified: false, - isCourseRunActive: false, canUpgrade: false, isAuditAccessExpired: false, - courseWebsite: 'test-course-website', +}; +const courseRunData = { + isActive: false, +}; +const courseData = { + website: 'test-course-website', }; const render = (overrides = {}) => { - appHooks.useCardValues.mockReturnValueOnce({ + const { + course = {}, + courseRun = {}, + enrollment = {}, + } = overrides; + appHooks.useCardCourseData.mockReturnValueOnce({ ...courseData, - ...overrides, + ...course, + }); + appHooks.useCardCourseRunData.mockReturnValueOnce({ + ...courseRunData, + ...courseRun, + }); + appHooks.useCardEnrollmentData.mockReturnValueOnce({ + ...enrollmentData, + ...enrollment, }); el = shallow(); }; describe('CourseBanner', () => { - testCardValues(courseNumber, { - isVerified: fieldKeys.isVerified, - isCourseRunActive: fieldKeys.isCourseRunActive, - canUpgrade: fieldKeys.canUpgrade, - isAuditAccessExpired: fieldKeys.isAuditAccessExpired, - courseWebsite: fieldKeys.courseWebsite, - }, render); + it('initializes data with course number from enrollment, course and course run data', () => { + render(); + expect(appHooks.useCardCourseData).toHaveBeenCalledWith(courseNumber); + expect(appHooks.useCardCourseRunData).toHaveBeenCalledWith(courseNumber); + expect(appHooks.useCardEnrollmentData).toHaveBeenCalledWith(courseNumber); + }); test('no display if learner is verified', () => { - render({ isVerified: true }); + render({ enrollment: { isVerified: true } }); expect(el.isEmptyRender()).toEqual(true); }); describe('audit access expired, can upgrade', () => { beforeEach(() => { - render({ isAuditAccessExpired: true, canUpgrade: true }); + render({ enrollment: { isAuditAccessExpired: true, canUpgrade: true } }); }); test('snapshot: (auditAccessExpired, upgradeToAccess)', () => { expect(el).toMatchSnapshot(); @@ -58,7 +78,7 @@ describe('CourseBanner', () => { }); describe('audit access expired, cannot upgrade', () => { beforeEach(() => { - render({ isAuditAccessExpired: true }); + render({ enrollment: { isAuditAccessExpired: true } }); }); test('snapshot: (auditAccessExpired, findAnotherCourse hyperlink)', () => { expect(el).toMatchSnapshot(); @@ -70,7 +90,7 @@ describe('CourseBanner', () => { }); describe('course run active and cannot upgrade', () => { beforeEach(() => { - render({ isCourseRunActive: true }); + render({ courseRun: { isActive: true } }); }); test('snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => { expect(el).toMatchSnapshot(); @@ -79,13 +99,13 @@ describe('CourseBanner', () => { expect(el.text()).toContain(messages.upgradeDeadlinePassed.defaultMessage); const link = el.find(Hyperlink); expect(link.text()).toEqual(messages.exploreCourseDetails.defaultMessage); - expect(link.props().destination).toEqual(courseData.courseWebsite); + expect(link.props().destination).toEqual(courseData.website); }); }); test('no display if audit access not expired and (course is not active or can upgrade)', () => { render(); expect(el.isEmptyRender()).toEqual(true); - render({ isCourseRunActive: true, canUpgrade: true }); + render({ enrollment: { canUpgrade: true }, courseRun: { isActive: true } }); expect(el.isEmptyRender()).toEqual(true); }); }); diff --git a/src/containers/CourseCard/components/Banners/EntitlementBanner.jsx b/src/containers/CourseCard/components/Banners/EntitlementBanner.jsx index 9e0de18..8c24139 100644 --- a/src/containers/CourseCard/components/Banners/EntitlementBanner.jsx +++ b/src/containers/CourseCard/components/Banners/EntitlementBanner.jsx @@ -1,28 +1,25 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { useCardValues } from 'hooks'; -import { selectors } from 'data/redux'; +import { hooks as appHooks } from 'data/redux'; import Banner from 'components/Banner'; -const { cardData } = selectors; - export const EntitlementBanner = ({ courseNumber }) => { - const data = useCardValues(courseNumber, { - canChange: cardData.canChangeEntitlementSession, - isEntitlement: cardData.isEntitlement, - isExpired: cardData.isEntitlementExpired, - isFulfilled: cardData.isEntitlementFulfilled, - }); + const { + canChange, + isEntitlement, + isExpired, + isFulfilled, + } = appHooks.useCardEntitlementsData(courseNumber); - if (!data.isEntitlement) { + if (!isEntitlement) { return null; } - if (data.isExpired || data.isFulfilled) { + if (isExpired || isFulfilled) { return null; } - return data.canChange + return canChange ? (You must select a session to access the course.) : (The deadline to select a session has passed); }; diff --git a/src/containers/CourseCard/components/Banners/messages.js b/src/containers/CourseCard/components/Banners/messages.js index ac7a1d3..d6889c4 100644 --- a/src/containers/CourseCard/components/Banners/messages.js +++ b/src/containers/CourseCard/components/Banners/messages.js @@ -26,6 +26,11 @@ export const messages = StrictDict({ description: 'Action prompt taking learners to course details page', defaultMessage: 'Explore course details.', }, + certRestricted: { + id: 'learner-dash.courseCard.banners.certificateRestricted', + description: 'Restricted certificate warning message', + defaultMessage: 'Your Certificate of Achievement is being held pending confirmation that the issuance of your Certificate is in compliance with strict U.S. embargoes on Iran, Cuba, Syria, and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting ', + }, }); export default messages; diff --git a/src/containers/CourseCard/components/CourseCardActions/hooks.js b/src/containers/CourseCard/components/CourseCardActions/hooks.js index df95e77..ba168ee 100644 --- a/src/containers/CourseCard/components/CourseCardActions/hooks.js +++ b/src/containers/CourseCard/components/CourseCardActions/hooks.js @@ -1,38 +1,35 @@ import { Locked } from '@edx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { selectors } from 'data/redux'; -import { useCardValues } from 'hooks'; +import { hooks as appHooks } from 'data/redux'; import messages from './messages'; -const { cardData } = selectors; - export const useCardActionData = ({ courseNumber }) => { const { formatMessage } = useIntl(); - const data = useCardValues(courseNumber, { - canUpgrade: cardData.canUpgrade, - isAudit: cardData.isAudit, - isAuditAccessExpired: cardData.isAuditAccessExpired, - isVerified: cardData.isVerified, - isPending: cardData.isCourseRunPending, - isFinished: cardData.isCourseRunFinished, - }); + const { + canUpgrade, + isAudit, + isAuditAccessExpired, + isVerified, + } = appHooks.useCardEnrollmentData(courseNumber); + const { isPending, isArchived } = appHooks.useCardCourseRunData(courseNumber); + let primary; let secondary = null; - if (!data.isVerified) { + if (!isVerified) { secondary = { iconBefore: Locked, variant: 'outline-primary', - disabled: !data.canUpgrade, + disabled: !canUpgrade, children: formatMessage(messages.upgrade), }; } - if (data.isPending) { + if (isPending) { primary = { children: formatMessage(messages.beginCourse) }; - } else if (!data.isFinished) { + } else if (!isArchived) { primary = { children: formatMessage(messages.resume), - disabled: data.isAudit && data.isAuditAccessExpired, + disabled: isAudit && isAuditAccessExpired, }; } else { primary = { children: formatMessage(messages.viewCourse) }; diff --git a/src/containers/CourseCard/components/CourseCardActions/hooks.test.js b/src/containers/CourseCard/components/CourseCardActions/hooks.test.js index 17d7f6c..58c9107 100644 --- a/src/containers/CourseCard/components/CourseCardActions/hooks.test.js +++ b/src/containers/CourseCard/components/CourseCardActions/hooks.test.js @@ -1,55 +1,48 @@ import { Locked } from '@edx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { selectors } from 'data/redux'; +import { hooks as appHooks } from 'data/redux'; -import * as appHooks from 'hooks'; -import { testCardValues } from 'testUtils'; import * as hooks from './hooks'; import messages from './messages'; -const courseNumber = 'my-test-course-number'; -const { fieldKeys } = selectors.cardData; +jest.mock('data/redux', () => ({ + hooks: { + useCardCourseRunData: jest.fn(), + useCardEnrollmentData: jest.fn(), + }, +})); -const props = { +const courseNumber = 'my-test-course-number'; + +const enrollmentData = { canUpgrade: false, isAudit: true, isAuditAccessExpired: false, isVerified: false, +}; +const courseRunData = { isPending: false, - isFinished: false, + isArchived: false, }; describe('CourseCardActions hooks', () => { let out; const { formatMessage } = useIntl(); - describe('data connection', () => { - beforeEach(() => { - out = hooks.useCardActionData({ courseNumber }); - }); - testCardValues(courseNumber, { - canUpgrade: fieldKeys.canUpgrade, - isAudit: fieldKeys.isAudit, - isAuditAccessExpired: fieldKeys.isAuditAccessExpired, - isVerified: fieldKeys.isVerified, - isPending: fieldKeys.isCourseRunPending, - isFinished: fieldKeys.isCourseRunFinished, - }); - }); + const runHook = (overrides = {}) => { + const { enrollment = {}, courseRun = {} } = overrides; + appHooks.useCardCourseRunData.mockReturnValueOnce({ ...courseRunData, ...courseRun }); + appHooks.useCardEnrollmentData.mockReturnValueOnce({ ...enrollmentData, ...enrollment }); + out = hooks.useCardActionData({ courseNumber }); + }; describe('secondary action', () => { it('returns null if verified', () => { - appHooks.useCardValues.mockReturnValueOnce({ - ...props, - isAudit: false, - isVerified: true, - }); - out = hooks.useCardActionData({ courseNumber }); + runHook({ enrollment: { isAudit: false, isVerified: true } }); expect(out.secondary).toEqual(null); }); it('returns disabled upgrade button if audit, but cannot upgrade', () => { - appHooks.useCardValues.mockReturnValueOnce(props); - out = hooks.useCardActionData({ courseNumber }); + runHook(); expect(out.secondary).toEqual({ iconBefore: Locked, variant: 'outline-primary', @@ -58,8 +51,7 @@ describe('CourseCardActions hooks', () => { }); }); it('returns enabled upgrade button if audit and can upgrade', () => { - appHooks.useCardValues.mockReturnValueOnce({ ...props, canUpgrade: true }); - out = hooks.useCardActionData({ courseNumber }); + runHook({ enrollment: { canUpgrade: true } }); expect(out.secondary).toEqual({ iconBefore: Locked, variant: 'outline-primary', @@ -70,37 +62,30 @@ describe('CourseCardActions hooks', () => { }); describe('primary action', () => { it('returns Begin Course button if pending', () => { - appHooks.useCardValues.mockReturnValueOnce({ ...props, isPending: true }); - out = hooks.useCardActionData({ courseNumber }); - expect(out.primary).toEqual({ - children: formatMessage(messages.beginCourse), - }); + runHook({ courseRun: { isPending: true } }); + expect(out.primary).toEqual({ children: formatMessage(messages.beginCourse) }); }); it('returns enabled Resume button if active, and not audit with expired access', () => { - appHooks.useCardValues.mockReturnValueOnce({ ...props, isAuditAccessExpired: true }); - out = hooks.useCardActionData({ courseNumber }); + runHook({ enrollment: { isAuditAccessExpired: true } }); expect(out.primary).toEqual({ children: formatMessage(messages.resume), disabled: true, }); }); it('returns disabled Resume button if active and audit without expired access', () => { - appHooks.useCardValues.mockReturnValueOnce({ ...props }); - out = hooks.useCardActionData({ courseNumber }); + runHook(); expect(out.primary).toEqual({ children: formatMessage(messages.resume), disabled: false, }); - appHooks.useCardValues.mockReturnValueOnce({ ...props, isAudit: false, isVerified: true }); - out = hooks.useCardActionData({ courseNumber }); + runHook({ enrollment: { isAudit: false, isVerified: true } }); expect(out.primary).toEqual({ children: formatMessage(messages.resume), disabled: false, }); }); - it('returns viewCourse button if finished', () => { - appHooks.useCardValues.mockReturnValueOnce({ ...props, isFinished: true }); - out = hooks.useCardActionData({ courseNumber }); + it('returns viewCourse button if archived', () => { + runHook({ courseRun: { isArchived: true } }); expect(out.primary).toEqual({ children: formatMessage(messages.viewCourse), }); diff --git a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx index 875f892..0021cb0 100644 --- a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx +++ b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx @@ -2,14 +2,11 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { StrictDict } from 'utils'; -import { selectors } from 'data/redux'; -import { useCardValues } from 'hooks'; +import { hooks as appHooks } from 'data/redux'; import messages from './messages'; import * as module from './hooks'; -const { cardData } = selectors; - export const state = StrictDict({ isOpen: (val) => React.useState(val), // eslint-disable-line }); @@ -17,10 +14,7 @@ export const state = StrictDict({ export const useRelatedProgramsBadgeData = ({ courseNumber }) => { const [isOpen, setIsOpen] = module.state.isOpen(false); const { formatMessage } = useIntl(); - const { numPrograms } = useCardValues(courseNumber, { - numPrograms: cardData.numRelatedPrograms, - }); - + const numPrograms = appHooks.useCardRelatedProgramsData(courseNumber).length; let programsMessage = ''; if (numPrograms) { programsMessage = formatMessage( diff --git a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js index a57619f..39987fd 100644 --- a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js +++ b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js @@ -1,13 +1,16 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { MockUseState } from 'testUtils'; -import * as appHooks from 'hooks'; -import { selectors } from 'data/redux'; +import { hooks as appHooks } from 'data/redux'; import * as hooks from './hooks'; import messages from './messages'; -const { cardData } = selectors; +jest.mock('data/redux', () => ({ + hooks: { + useCardRelatedProgramsData: jest.fn(), + }, +})); const courseNumber = 'my-test-course-number'; @@ -16,7 +19,7 @@ const numPrograms = 27; const { formatMessage } = useIntl(); -describe('EmailSettingsModal hooks', () => { +describe('RelatedProgramsBadge hooks', () => { let out; describe('state values', () => { state.testGetter(state.keys.isOpen); @@ -27,8 +30,8 @@ describe('EmailSettingsModal hooks', () => { describe('useRelatedProgramsBadgeData', () => { beforeEach(() => { state.mock(); - appHooks.useCardValues.mockReturnValueOnce({ - numPrograms, + appHooks.useCardRelatedProgramsData.mockReturnValueOnce({ + length: numPrograms, }); out = hooks.useRelatedProgramsBadgeData({ courseNumber }); }); @@ -52,21 +55,16 @@ describe('EmailSettingsModal hooks', () => { expect(out.isOpen).toEqual(state.stateVals.isOpen); }); - test('returns numPrograms from useCardValues call on numRelatedPrograms', () => { - expect(appHooks.useCardValues).toHaveBeenCalledWith( - courseNumber, - { numPrograms: cardData.numRelatedPrograms }, - ); + test('forwards numPrograms from relatedPrograms.length for the courseNumber', () => { expect(out.numPrograms).toEqual(numPrograms); }); - test('returns empty programsMessage if no programs', () => { - appHooks.useCardValues.mockReturnValueOnce({ numPrograms: 0 }); + appHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 0 }); out = hooks.useRelatedProgramsBadgeData({ courseNumber }); expect(out.programsMessage).toEqual(''); }); test('returns badgeLabelSingular programsMessage if 1 programs', () => { - appHooks.useCardValues.mockReturnValueOnce({ numPrograms: 1 }); + appHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 1 }); out = hooks.useRelatedProgramsBadgeData({ courseNumber }); expect(out.programsMessage).toEqual(formatMessage( messages.badgeLabelSingular, diff --git a/src/containers/CourseCard/hooks.js b/src/containers/CourseCard/hooks.js index 42d9591..6287d56 100644 --- a/src/containers/CourseCard/hooks.js +++ b/src/containers/CourseCard/hooks.js @@ -1,47 +1,40 @@ import { useIntl } from '@edx/frontend-platform/i18n'; -import { selectors } from 'data/redux'; - -import { useCardValues } from 'hooks'; +import { hooks as appHooks } from 'data/redux'; import * as module from './hooks'; import messages from './messages'; -const { cardData } = selectors; - export const useAccessMessage = ({ courseNumber }) => { const { formatMessage, formatDate } = useIntl(); + const { + accessExpirationDate, + isAudit, + isAuditAccessExpired, + } = appHooks.useCardEnrollmentData(courseNumber); + const { isArchived, endDate } = appHooks.useCardCourseRunData(courseNumber); - const data = useCardValues(courseNumber, { - accessExpirationDate: cardData.courseRunAccessExpirationDate, - isAudit: cardData.isAudit, - isFinished: cardData.isCourseRunFinished, - isAuditAccessExpired: cardData.isAuditAccessExpired, - endDate: cardData.courseRunEndDate, - }); - if (data.isAudit) { + if (isAudit) { return formatMessage( - data.isAuditAccessExpired ? messages.accessExpired : messages.accessExpires, - { accessExpirationDate: formatDate(data.accessExpirationDate) }, + isAuditAccessExpired ? messages.accessExpired : messages.accessExpires, + { accessExpirationDate: formatDate(accessExpirationDate) }, ); } + return formatMessage( - data.isFinished ? messages.courseEnded : messages.courseEnds, - { endDate: formatDate(data.endDate) }, + isArchived ? messages.courseEnded : messages.courseEnds, + { endDate: formatDate(endDate) }, ); }; export const useCardData = ({ courseNumber }) => { const { formatMessage } = useIntl(); - const data = useCardValues(courseNumber, { - title: cardData.courseTitle, - bannerUrl: cardData.courseBannerUrl, - providerName: cardData.providerName, - }); + const { title, bannerUrl } = appHooks.useCardCourseData(courseNumber); + const providerName = appHooks.useCardProviderData(courseNumber).name; return { - title: data.title, - bannerUrl: data.bannerUrl, - providerName: data.providerName || formatMessage(messages.unknownProviderName), + title, + bannerUrl, + providerName: providerName || formatMessage(messages.unknownProviderName), accessMessage: module.useAccessMessage({ courseNumber }), formatMessage, }; diff --git a/src/containers/CourseCard/hooks.test.js b/src/containers/CourseCard/hooks.test.js index f367018..a537bac 100644 --- a/src/containers/CourseCard/hooks.test.js +++ b/src/containers/CourseCard/hooks.test.js @@ -1,14 +1,19 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { keyStore } from 'utils'; -import { selectors } from 'data/redux'; -import * as appHooks from 'hooks'; -import { testCardValues } from 'testUtils'; +import { hooks as appHooks } from 'data/redux'; import * as hooks from './hooks'; import messages from './messages'; -const { fieldKeys } = selectors.cardData; +jest.mock('data/redux', () => ({ + hooks: { + useCardCourseData: jest.fn(), + useCardCourseRunData: jest.fn(), + useCardEnrollmentData: jest.fn(), + useCardProviderData: jest.fn(), + }, +})); const courseNumber = 'my-test-course-number'; const useAccessMessage = 'test-access-message'; @@ -18,120 +23,114 @@ const hookKeys = keyStore(hooks); describe('CourseCard hooks', () => { let out; const { formatMessage, formatDate } = useIntl(); + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('useCardData', () => { + const courseData = { + title: 'fake-title', + bannerUrl: 'my-banner-url', + }; + const providerData = { + name: 'my-provider-name', + }; + const runHook = ({ course = {}, provider = {} }) => { + jest.spyOn(hooks, hookKeys.useAccessMessage) + .mockImplementationOnce(mockAccessMessage); + appHooks.useCardCourseData.mockReturnValueOnce({ + ...courseData, + ...course, + }); + appHooks.useCardProviderData.mockReturnValueOnce({ + ...providerData, + ...provider, + }); + out = hooks.useCardData({ courseNumber }); + }; beforeEach(() => { - jest.spyOn(hooks, hookKeys.useAccessMessage).mockImplementationOnce(mockAccessMessage); - out = hooks.useCardData({ courseNumber }); - }); - - testCardValues(courseNumber, { - title: fieldKeys.courseTitle, - bannerUrl: fieldKeys.courseBannerUrl, - providerName: fieldKeys.providerName, - }); - - test('providerName returns Unknown message if not provided', () => { - appHooks.useCardValues.mockReturnValueOnce({ - title: 'title', - bannerUrl: 'bannerUrl', - providerName: null, - }); - jest.spyOn(hooks, hookKeys.useAccessMessage).mockImplementationOnce(mockAccessMessage); - out = hooks.useCardData({ courseNumber }); - expect(out.providerName).toEqual(formatMessage(messages.unknownProviderName)); - }); - describe('useAccessMessage', () => { - it('returns the output of useAccessMessage hook, passed courseNumber', () => { - expect(out.accessMessage).toEqual(mockAccessMessage({ courseNumber })); - }); + runHook({}); }); it('forwards formatMessage from useIntl', () => { expect(out.formatMessage).toEqual(formatMessage); }); + it('passes course title and banner URL form course data', () => { + expect(appHooks.useCardCourseData).toHaveBeenCalledWith(courseNumber); + expect(out.title).toEqual(courseData.title); + expect(out.bannerUrl).toEqual(courseData.bannerUrl); + }); + it('forwards useAccessMessage output, called with courseNumber', () => { + expect(out.accessMessage).toEqual(mockAccessMessage({ courseNumber })); + }); + it('forwards provider name if it exists, else formatted unknown provider name', () => { + expect(appHooks.useCardCourseData).toHaveBeenCalledWith(courseNumber); + expect(out.providerName).toEqual(providerData.name); + runHook({ provider: { name: '' } }); + expect(out.providerName).toEqual(formatMessage(messages.unknownProviderName)); + }); }); - describe('useAccessMessage', () => { - const accessExpirationDate = 'test-expiration-date'; - const endDate = 'test-end-date'; - - beforeEach(() => { - appHooks.useCardValues.mockClear(); - }); - - describe('loaded data', () => { - beforeEach(() => { - out = hooks.useAccessMessage({ courseNumber }); + const enrollmentData = { + accessExpirationDate: 'test-expiration-date', + isAudit: false, + isAuditAccessExpired: false, + }; + const courseRunData = { + isFinished: false, + endDate: 'test-end-date', + }; + const runHook = ({ enrollment = {}, courseRun = {} }) => { + appHooks.useCardCourseRunData.mockReturnValueOnce({ + ...courseRunData, + ...courseRun, }); - - testCardValues(courseNumber, { - accessExpirationDate: fieldKeys.courseRunAccessExpirationDate, - isAudit: fieldKeys.isAudit, - isFinished: fieldKeys.isCourseRunFinished, - isAuditAccessExpired: fieldKeys.isAuditAccessExpired, - endDate: fieldKeys.courseRunEndDate, + appHooks.useCardEnrollmentData.mockReturnValueOnce({ + ...enrollmentData, + ...enrollment, }); + out = hooks.useAccessMessage({ courseNumber }); + }; + it('loads data from enrollment and course run data based on course number', () => { + runHook({}); + expect(appHooks.useCardCourseRunData).toHaveBeenCalledWith(courseNumber); + expect(appHooks.useCardEnrollmentData).toHaveBeenCalledWith(courseNumber); }); - describe('if audit, and expired', () => { it('returns accessExpired message with accessExpirationDate from cardData', () => { - appHooks.useCardValues.mockReturnValueOnce({ - accessExpirationDate, - endDate, - isAudit: true, - isFinished: false, - isAuditAccessExpired: true, - }); - expect(hooks.useAccessMessage({ courseNumber })).toEqual(formatMessage( + runHook({ enrollment: { isAudit: true, isAuditAccessExpired: true } }); + expect(out).toEqual(formatMessage( messages.accessExpired, - { accessExpirationDate: formatDate(accessExpirationDate) }, + { accessExpirationDate: formatDate(enrollmentData.accessExpirationDate) }, )); }); }); describe('if audit and not expired', () => { it('returns accessExpires message with accessExpirationDate from cardData', () => { - appHooks.useCardValues.mockReturnValueOnce({ - accessExpirationDate, - endDate, - isAudit: true, - isFinished: false, - isAuditAccessExpired: false, - }); - expect(hooks.useAccessMessage({ courseNumber })).toEqual(formatMessage( + runHook({ enrollment: { isAudit: true } }); + expect(out).toEqual(formatMessage( messages.accessExpires, - { accessExpirationDate: formatDate(accessExpirationDate) }, + { accessExpirationDate: formatDate(enrollmentData.accessExpirationDate) }, )); }); }); describe('if verified and not ended', () => { it('returns course ends message with course end date', () => { - appHooks.useCardValues.mockReturnValueOnce({ - accessExpirationDate, - endDate, - isAudit: false, - isFinished: false, - isAuditAccessExpired: false, - }); - expect(hooks.useAccessMessage({ courseNumber })).toEqual(formatMessage( + runHook({}); + expect(out).toEqual(formatMessage( messages.courseEnds, - { endDate: formatDate(endDate) }, + { endDate: formatDate(courseRunData.endDate) }, )); }); }); describe('if verified and ended', () => { it('returns course ended message with course end date', () => { - appHooks.useCardValues.mockReturnValueOnce({ - accessExpirationDate, - endDate, - isAudit: false, - isFinished: true, - isAuditAccessExpired: false, - }); - expect(hooks.useAccessMessage({ courseNumber })).toEqual(formatMessage( + runHook({ courseRun: { isFinished: true } }); + expect(out).toEqual(formatMessage( messages.courseEnded, - { endDate: formatDate(endDate) }, + { endDate: formatDate(courseRunData.endDate) }, )); }); }); diff --git a/src/containers/EmailSettingsModal/hooks.js b/src/containers/EmailSettingsModal/hooks.js index 626464b..24a93cc 100644 --- a/src/containers/EmailSettingsModal/hooks.js +++ b/src/containers/EmailSettingsModal/hooks.js @@ -2,13 +2,10 @@ import React from 'react'; import { StrictDict } from 'utils'; // import { thunkActions } from 'data/redux'; -import { selectors } from 'data/redux'; -import { useCardValues } from 'hooks'; +import { hooks as appHooks } from 'data/redux'; import * as module from './hooks'; -const { cardData } = selectors; - export const state = StrictDict({ toggle: (val) => React.useState(val), // eslint-disable-line }); @@ -18,10 +15,8 @@ export const useEmailData = ({ courseNumber, // dispatch, }) => { - const data = useCardValues(courseNumber, { - isEnabled: cardData.isEmailEnabled, - }); - const [toggleValue, setToggleValue] = module.state.toggle(data.isEnabled); + const { isEmailEnabled } = appHooks.useCardEnrollmentData(courseNumber); + const [toggleValue, setToggleValue] = module.state.toggle(isEmailEnabled); const onToggle = React.useCallback( () => setToggleValue(!toggleValue), [setToggleValue, toggleValue], diff --git a/src/containers/EmailSettingsModal/hooks.test.js b/src/containers/EmailSettingsModal/hooks.test.js index fec8074..accee42 100644 --- a/src/containers/EmailSettingsModal/hooks.test.js +++ b/src/containers/EmailSettingsModal/hooks.test.js @@ -1,10 +1,13 @@ -import { MockUseState, testCardValues } from 'testUtils'; -import * as appHooks from 'hooks'; -import { selectors } from 'data/redux'; +import { MockUseState } from 'testUtils'; +import { hooks as appHooks } from 'data/redux'; import * as hooks from './hooks'; -const { fieldKeys } = selectors.cardData; +jest.mock('data/redux', () => ({ + hooks: { + useCardEnrollmentData: jest.fn(), + }, +})); const courseNumber = 'my-test-course-number'; const closeModal = jest.fn(); @@ -22,18 +25,20 @@ describe('EmailSettingsModal hooks', () => { describe('useEmailData', () => { beforeEach(() => { state.mock(); - appHooks.useCardValues.mockReturnValueOnce({ isEnabled: true }); + appHooks.useCardEnrollmentData.mockReturnValueOnce({ isEmailEnabled: true }); out = hooks.useEmailData({ closeModal, courseNumber }); }); afterEach(state.restore); - testCardValues(courseNumber, { isEnabled: fieldKeys.isEmailEnabled }); + test('loads enrollment data based on course number', () => { + expect(appHooks.useCardEnrollmentData).toHaveBeenCalledWith(courseNumber); + }); test('initializes toggle value to cardData.isEmailEnabled', () => { state.expectInitializedWith(state.keys.toggle, true); expect(out.toggleValue).toEqual(true); - appHooks.useCardValues.mockReturnValueOnce({ isEnabled: false }); + appHooks.useCardEnrollmentData.mockReturnValueOnce({ isEmailEnabled: false }); out = hooks.useEmailData({ closeModal, courseNumber }); state.expectInitializedWith(state.keys.toggle, false); expect(out.toggleValue).toEqual(false); diff --git a/src/containers/RelatedProgramsModal/components/ProgramCard.jsx b/src/containers/RelatedProgramsModal/components/ProgramCard.jsx index d8f14f3..19a5085 100644 --- a/src/containers/RelatedProgramsModal/components/ProgramCard.jsx +++ b/src/containers/RelatedProgramsModal/components/ProgramCard.jsx @@ -54,15 +54,14 @@ export const ProgramCard = ({ data }) => { }; ProgramCard.propTypes = { data: PropTypes.shape({ - estimatedNumberOfWeeks: PropTypes.number, - numberOfCourses: PropTypes.number, bannerUrl: PropTypes.string, + estimatedNumberOfWeeks: PropTypes.number, logoUrl: PropTypes.string, - title: PropTypes.string, - provider: PropTypes.string, + numberOfCourses: PropTypes.number, programType: PropTypes.string, programUrl: PropTypes.string, - programTypeUrl: PropTypes.string, + provider: PropTypes.string, + title: PropTypes.string, }).isRequired, }; diff --git a/src/containers/RelatedProgramsModal/hooks.js b/src/containers/RelatedProgramsModal/hooks.js index af961e3..adcf7ff 100644 --- a/src/containers/RelatedProgramsModal/hooks.js +++ b/src/containers/RelatedProgramsModal/hooks.js @@ -1,27 +1,10 @@ -import { selectors } from 'data/redux'; -import { useCardValues } from 'hooks'; - -const { cardData } = selectors; -const { programs } = cardData; +import { hooks as appHooks } from 'data/redux'; export const useProgramData = ({ courseNumber, -}) => { - const data = useCardValues(courseNumber, { - courseTitle: cardData.courseTitle, - relatedPrograms: cardData.relatedPrograms, - }); - return { - courseTitle: data.courseTitle, - relatedPrograms: data.relatedPrograms.map(program => ({ - estimatedNumberOfWeeks: programs.estimatedNumberOfWeeks(program), - numberOfCourses: programs.numberOfCourses(program), - programType: programs.programType(program), - programTypeUrl: programs.programTypeUrl(program), - provider: programs.provider(program), - title: programs.title(program), - })), - }; -}; +}) => ({ + courseTitle: appHooks.useCardCourseData(courseNumber).title, + relatedPrograms: appHooks.useCardRelatedProgramsData(courseNumber).list, +}); export default useProgramData; diff --git a/src/containers/RelatedProgramsModal/hooks.test.js b/src/containers/RelatedProgramsModal/hooks.test.js index 722e5d4..8ada046 100644 --- a/src/containers/RelatedProgramsModal/hooks.test.js +++ b/src/containers/RelatedProgramsModal/hooks.test.js @@ -1,67 +1,26 @@ -import { testCardValues } from 'testUtils'; -import * as appHooks from 'hooks'; -import { selectors } from 'data/redux'; +import { hooks as appHooks } from 'data/redux'; import * as hooks from './hooks'; -jest.mock('data/redux/cardData/selectors', () => ({ - ...jest.requireActual('data/redux/cardData/selectors'), - programs: { - estimatedNumberOfWeeks: (p) => p.estimatedNumberOfWeeks, - numberOfCourses: (p) => p.numberOfCourses, - programType: (p) => p.programType, - programTypeUrl: (p) => p.programTypeUrl, - provider: (p) => p.provider, - title: (p) => p.title, +jest.mock('data/redux', () => ({ + hooks: { + useCardCourseData: jest.fn(), + useCardRelatedProgramsData: jest.fn(), }, })); -const { fieldKeys } = selectors.cardData; - const courseNumber = 'test-course-number'; const courseTitle = 'test-course-title'; -const relatedPrograms = [ - { - estimatedNumberOfWeeks: 1, - numberOfCourses: 2, - programType: 'test-program-type-1', - programTypeUrl: 'test-program-type-1-url', - provider: 'test-provider-1', - title: 'test-program-title-1', - }, - { - estimatedNumberOfWeeks: 2, - numberOfCourses: 3, - programType: 'test-program-type-2', - programTypeUrl: 'test-program-type-2-url', - provider: 'test-provider-2', - title: 'test-program-title-2', - }, - { - estimatedNumberOfWeeks: 3, - numberOfCourses: 5, - programType: 'test-program-type-3', - programTypeUrl: 'test-program-type-3-url', - provider: 'test-provider-3', - title: 'test-program-title-3', - }, -]; +const relatedPrograms = ['some', 'programs']; describe('RelatedProgramsModal hooks', () => { - let out; - beforeEach(() => { - appHooks.useCardValues.mockReturnValueOnce({ courseTitle, relatedPrograms }); - out = hooks.useProgramData({ courseNumber }); - }); - testCardValues(courseNumber, { - courseTitle: fieldKeys.courseTitle, - relatedPrograms: fieldKeys.relatedPrograms, - }); - test('courseTitle loads course title', () => { - expect(out.courseTitle).toEqual(courseTitle); - }); - test('relatedPrograms loads from course run related programs', () => { - expect(out.relatedPrograms).toEqual(relatedPrograms); + it('forwards course title and related programs list by course number', () => { + appHooks.useCardCourseData.mockReturnValue({ title: courseTitle }); + appHooks.useCardRelatedProgramsData.mockReturnValue({ list: relatedPrograms }); + const out = hooks.useProgramData({ courseNumber }); + expect(appHooks.useCardCourseData).toHaveBeenCalledWith(courseNumber); + expect(appHooks.useCardRelatedProgramsData).toHaveBeenCalledWith(courseNumber); + expect(out).toEqual({ courseTitle, relatedPrograms }); }); }); diff --git a/src/containers/RelatedProgramsModal/index.jsx b/src/containers/RelatedProgramsModal/index.jsx index 06653c2..87bc366 100644 --- a/src/containers/RelatedProgramsModal/index.jsx +++ b/src/containers/RelatedProgramsModal/index.jsx @@ -40,7 +40,7 @@ export const RelatedProgramsModal = ({ columnSizes={{ lg: 6, xlg: 4, xs: 12 }} > {relatedPrograms.map((programData) => ( - + ))}
diff --git a/src/data/redux/app/hooks.js b/src/data/redux/app/hooks.js new file mode 100644 index 0000000..09c9de6 --- /dev/null +++ b/src/data/redux/app/hooks.js @@ -0,0 +1,23 @@ +import { useSelector } from 'react-redux'; + +import selectors from './selectors'; + +const { courseCard } = selectors; + +export const useEmailConfirmationData = () => useSelector(selectors.emailConfirmation); +export const useEnterpriseDashboardData = () => useSelector(selectors.enterpriseDashboards); +export const usePlatformSettingsData = () => useSelector(selectors.platformSettings); + +// eslint-disable-next-line +export const useCourseCardData = (selector) => (courseNumber) => useSelector( + (state) => selector(selectors.courseData(state)[courseNumber]), +); + +export const useCardCertificateData = useCourseCardData(courseCard.certificates); +export const useCardCourseData = useCourseCardData(courseCard.course); +export const useCardCourseRunData = useCourseCardData(courseCard.courseRun); +export const useCardEnrollmentData = useCourseCardData(courseCard.enrollment); +export const useCardEntitlementsData = useCourseCardData(courseCard.entitlements); +export const useCardGradesData = useCourseCardData(courseCard.grades); +export const useCardProviderData = useCourseCardData(courseCard.provider); +export const useCardRelatedProgramsData = useCourseCardData(courseCard.relatedPrograms); diff --git a/src/data/redux/app/reducer.js b/src/data/redux/app/reducer.js index fc8b7e2..dec92e8 100644 --- a/src/data/redux/app/reducer.js +++ b/src/data/redux/app/reducer.js @@ -5,6 +5,10 @@ const initialState = { enrollments: [], courseData: {}, entitlements: [], + emailConfirmation: {}, + enterpriseDashboards: {}, + platformSettings: {}, + suggestedCourses: {}, }; // eslint-disable-next-line no-unused-vars @@ -24,6 +28,13 @@ const app = createSlice({ ), }), loadEntitlements: (state, { payload }) => ({ ...state, entitlements: payload }), + loadGlobalData: (state, { payload }) => ({ + ...state, + emailConfirmation: payload.emailConfirmation, + enterpriseDashboards: payload.enterpriseDashboards, + platformSettings: payload.platformSettings, + suggestedCourses: payload.suggestedCourses, + }), }, }); diff --git a/src/data/redux/app/selectors.js b/src/data/redux/app/selectors.js index a205672..7923f43 100644 --- a/src/data/redux/app/selectors.js +++ b/src/data/redux/app/selectors.js @@ -13,13 +13,76 @@ export const simpleSelectors = { enrollments: mkSimpleSelector(app => app.enrollments), entitlements: mkSimpleSelector(app => app.entitlements), courseData: mkSimpleSelector(app => app.courseData), + platformSettings: mkSimpleSelector(app => app.platformSettings), + emailConfirmation: mkSimpleSelector(app => app.emailConfirmation), + enterpriseDashboards: mkSimpleSelector(app => app.enterpriseDashboards), }; export const courseCardData = (state, courseNumber) => ( module.simpleSelectors.courseData(state)[courseNumber] ); +const mkCardSelector = (sel) => (state, courseNumber) => ( + sel(courseCardData(state, courseNumber)) +); + +export const courseCard = StrictDict({ + certificates: mkCardSelector(({ certificates }) => ({ + availableDate: certificates.availableDate, + downloadUrl: certificates.downloadUrls?.download, + previewUrl: certificates.downloadUrls?.preview, + isDownloadable: certificates.isDownloadable, + isEarnedButUnavailable: certificates.isEarned && !certificates.isAvailable, + isRestricted: certificates.isRestricted, + })), + course: mkCardSelector(({ course }) => ({ + bannerUrl: course.bannerUrl, + title: course.title, + website: course.website, + })), + courseRun: mkCardSelector(({ courseRun }) => ({ + endDate: courseRun?.endDate, + isArchived: courseRun.isArchived, + isStarted: courseRun.isStarted, + isFinished: courseRun.isFinished, + minPassingGrade: courseRun.minPassingGrade, + })), + enrollment: mkCardSelector(({ enrollment }) => ({ + accessExpirationDate: enrollment.accessExpirationDate, + canUpgrade: enrollment.canUpgrade, + hasStarted: enrollment.hasStarted, + isAudit: enrollment.isAudit, + isAuditAccessExpired: enrollment.isAuditAccessExpired, + isEmailEnabled: enrollment.isEmailEnabled, + isVerified: enrollment.isVerified, + lastEnrolled: enrollment.lastEnrollment, + isEnrolled: enrollment.isEnrolled, + })), + entitlements: mkCardSelector(({ entitlements }) => ({ + canChange: entitlements.canChange, + entitlementSessions: entitlements.availableSessions, + isEntitlement: entitlements.isEntitlement, + isExpired: entitlements.isExpired, + isFulfilled: entitlements.isFulfilled, + })), + grades: mkCardSelector(({ grades }) => ({ isPassing: grades.isPassing })), + provider: mkCardSelector(({ provider }) => ({ name: provider?.name })), + relatedPrograms: mkCardSelector(({ relatedPrograms }) => ({ + list: relatedPrograms.map(program => ({ + bannerUrl: program.bannerUrl, + estimatedNumberOfWeeks: program.estimatedNumberOfWeeks, + logoUrl: program.logoUrl, + numberOfCourses: program.numberOfCourses, + programType: program.programType, + programUrl: program.programUrl, + provider: program.provider, + title: program.title, + })), + length: relatedPrograms.length, + })), +}); + export default StrictDict({ ...simpleSelectors, - courseCardData, + courseCard, }); diff --git a/src/data/redux/cardData/index.js b/src/data/redux/cardData/index.js deleted file mode 100644 index 9d6493d..0000000 --- a/src/data/redux/cardData/index.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable import/prefer-default-export */ -export { default as selectors } from './selectors'; diff --git a/src/data/redux/cardData/selectors.js b/src/data/redux/cardData/selectors.js deleted file mode 100644 index 9e64ab3..0000000 --- a/src/data/redux/cardData/selectors.js +++ /dev/null @@ -1,67 +0,0 @@ -import { keyStore, StrictDict } from 'utils'; - -import app from 'data/redux/app/selectors'; -// import * as module from './selectors'; - -const mkCardSelector = (sel) => (state, courseNumber) => ( - sel(app.courseCardData(state, courseNumber)) -); - -export const fieldSelectors = { - canChangeEntitlementSession: data => data.entitlements.canChange, - canUpgrade: data => data.enrollment.canUpgrade, - certAvailableDate: data => data.certificates.availableDate, - certDownloadUrl: data => data.certificates.downloadUrls?.download, - certPreviewUrl: data => data.certificates.downloadUrls?.preview, - courseBannerUrl: data => data.course.bannerUrl, - courseRunAccessExpirationDate: data => data.courseRun.accessExpirationDate, - courseRunEndDate: data => data.courseRun?.endDate, - courseTitle: data => data.course.title, - courseWebsite: data => data.course.website, - entitlementSessions: data => data.entitlements.availableSessions, - isAudit: data => data.enrollment.isAudit, - isAuditAccessExpired: data => data.enrollment.isAuditAccessExpired, - isCertDownloadable: data => data.certificates.isDownloadable, - isCertEarnedButUnavailable: ({ certificates: { isEarned, isAvailable } }) => ( - isEarned && !isAvailable - ), - isCourseRunPending: data => data.courseRun.isPending, - isCourseRunStarted: data => data.courseRun.isStarted, - isCourseRunFinished: data => data.courseRun.isFinished, - isEmailEnabled: data => data.enrollment.isEmailEnabled, - isEntitlement: data => data.entitlements.isEntitlement, - isEntitlementExpired: data => data.entitlements.isExpired, - isEntitlementFulfilled: data => data.entitlements.isFulfilled, - isVerified: data => data.enrollment.isVerified, - isRestricted: data => data.certificates.isRestricted, - isPassing: data => data.grades.isPassing, - minPassingGrade: data => data.courseRun.minPassingGrade, - providerName: data => data.provider?.name, - relatedPrograms: data => data.relatedPrograms, - numRelatedPrograms: data => data.relatedPrograms.length, -}; -fieldSelectors.isCourseRunActive = data => ( - fieldSelectors.isCourseRunStarted(data) && !fieldSelectors.isCourseRunFinished(data) -); - -export const programs = StrictDict({ - estimatedNumberOfWeeks: data => data.estimatedNumberOfWeeks, - numberOfCourses: data => data.numberOfCourses, - programType: data => data.programType, - programTypeUrl: data => data.programTypeUrl, - provider: data => data.provider, - title: data => data.title, -}); - -export const fieldKeys = keyStore(fieldSelectors); - -export const cardSelectors = Object.keys(fieldSelectors).reduce( - (obj, key) => ({ ...obj, [key]: mkCardSelector(fieldSelectors[key]) }), - {}, -); - -export default StrictDict({ - ...cardSelectors, - programs, - fieldKeys, -}); diff --git a/src/data/redux/cardData/selectors.test.js b/src/data/redux/cardData/selectors.test.js deleted file mode 100644 index 1455fbc..0000000 --- a/src/data/redux/cardData/selectors.test.js +++ /dev/null @@ -1,255 +0,0 @@ -import app from 'data/redux/app/selectors'; -import * as selectors from './selectors'; - -jest.mock('data/redux/app/selectors', () => ({ - courseCardData: jest.fn(), -})); - -const { - default: exported, - programs, - fieldKeys, -} = selectors; - -const courseNumber = 'test-course-number'; -const testState = { test: 'state' }; -const testValue = 'my-test-value'; - -/** - * Test a field selector, both in basic definition and exported/connected card field - * @param {string} key - field keys to test - * @param {string} basicMessage - basic usage test message - * @param {obj} basicTest - { data, expected } - passed data and expected output. - * expected defaults to testValue as defined in this file. - * @param {object[]} [conditionTests] - (optional) extra tests for special conditions - * condition: explanation of the condition - * data: data to be passed to the transform - * expected: expected output - * message: test message - */ -const testFieldSelector = (key, basicMessage, basicTest, conditionTests = []) => { - describe(`fieldSelector: ${key}`, () => { - describe('basic usage', () => { - const { data, expected = testValue } = basicTest; - test(basicMessage, () => { - expect(selectors.fieldSelectors[key](data)).toEqual(expected); - }); - it('exports a card selector for the given key, binding to course card data', () => { - app.courseCardData.mockReturnValueOnce(data); - expect(exported[key](testState, courseNumber)).toEqual(expected); - expect(app.courseCardData).toHaveBeenCalledWith(testState, courseNumber); - }); - }); - conditionTests.forEach((conditionTest) => { - const { data, expected } = conditionTest; - describe(conditionTest.condition, () => { - test(conditionTest.message, () => { - expect(selectors.fieldSelectors[key](data)).toEqual(expected); - }); - it('exports a card selector for the given key, binding to course card data', () => { - app.courseCardData.mockReturnValueOnce(data); - expect(exported[key](testState, courseNumber)).toEqual(expected); - expect(app.courseCardData).toHaveBeenCalledWith(testState, courseNumber); - }); - }); - }); - }); -}; - -describe('cardData selectors', () => { - describe('fieldSelectors', () => { - testFieldSelector(fieldKeys.canChangeEntitlementSession, - 'returns the entitlements canChangeEntitlementsSession value', - { data: { entitlements: { canChange: testValue } } }); - - testFieldSelector(fieldKeys.canUpgrade, - 'returns the enrollment canUpgrade value', - { data: { enrollment: { canUpgrade: testValue } } }); - - testFieldSelector(fieldKeys.certAvailableDate, - 'returns the certificates availableDate value', - { data: { certificates: { availableDate: testValue } } }); - - testFieldSelector(fieldKeys.certDownloadUrl, - 'returns the certificates download url value', - { data: { certificates: { downloadUrls: { download: testValue } } } }, - [{ - condition: 'if no download urls are provided for certificates', - data: { certificates: {} }, - expected: undefined, - message: 'returns undefined', - }]); - - testFieldSelector(fieldKeys.certPreviewUrl, - 'returns the certificates preview url value', - { data: { certificates: { downloadUrls: { preview: testValue } } } }, - [{ - condition: 'if no downloadUrls are provided for certificates', - data: { certificates: {} }, - expected: undefined, - message: 'returns undefined', - }]); - - testFieldSelector(fieldKeys.courseBannerUrl, - 'returns the course banner url value', - { data: { course: { bannerUrl: testValue } } }); - - testFieldSelector(fieldKeys.courseRunAccessExpirationDate, - 'returns the course run access expiration date value', - { data: { courseRun: { accessExpirationDate: testValue } } }); - - testFieldSelector(fieldKeys.courseRunEndDate, - 'returns the course banner url value', - { data: { courseRun: { endDate: testValue } } }, - [{ - condition: 'if course run is not defined', - data: {}, - expected: undefined, - message: 'returns undefined if there is no course run data', - }]); - - testFieldSelector(fieldKeys.courseTitle, - 'returns the course title value', - { data: { course: { title: testValue } } }); - - testFieldSelector(fieldKeys.courseWebsite, - 'returns the course website value', - { data: { course: { website: testValue } } }); - - testFieldSelector(fieldKeys.entitlementSessions, - 'returns available entitlement sessions value', - { data: { entitlements: { availableSessions: testValue } } }); - - testFieldSelector(fieldKeys.isAudit, - 'returns enrollment isAudit value', - { data: { enrollment: { isAudit: testValue } } }); - - testFieldSelector(fieldKeys.isAuditAccessExpired, - 'returns enrollment isAudiAccessExpired value', - { data: { enrollment: { isAuditAccessExpired: testValue } } }); - - testFieldSelector(fieldKeys.isCertDownloadable, - 'returns certificates isDownloadable value', - { data: { certificates: { isDownloadable: testValue } } }); - - testFieldSelector(fieldKeys.isCertEarnedButUnavailable, - 'returns true if is certificate is earned but not available', - { - data: { certificates: { isEarned: true, isAvailable: false } }, - expected: true, - }, - [ - { - condition: 'certificate is not earned', - data: { certificates: { isEarned: false, isAvailable: false } }, - expected: false, - message: 'returns false', - }, - { - condition: 'certificate is available', - data: { certificates: { isEarned: true, isAvailable: true } }, - expected: false, - message: 'returns false', - }, - ]); - - testFieldSelector(fieldKeys.isCourseRunPending, - 'returns courseRun isPending value', - { data: { courseRun: { isPending: testValue } } }); - - testFieldSelector(fieldKeys.isCourseRunStarted, - 'returns courseRun isStarted value', - { data: { courseRun: { isStarted: testValue } } }); - - testFieldSelector(fieldKeys.isCourseRunFinished, - 'returns courseRun isFinished value', - { data: { courseRun: { isFinished: testValue } } }); - - testFieldSelector(fieldKeys.isEmailEnabled, - 'returns enrollment isEmailEnabled value', - { data: { enrollment: { isEmailEnabled: testValue } } }); - - testFieldSelector(fieldKeys.isEntitlement, - 'returns entitlements isEntitlement value', - { data: { entitlements: { isEntitlement: testValue } } }); - - testFieldSelector(fieldKeys.isEntitlementExpired, - 'returns entitlements isExpired value', - { data: { entitlements: { isExpired: testValue } } }); - - testFieldSelector(fieldKeys.isEntitlementFulfilled, - 'returns entitlements isFulfilled value', - { data: { entitlements: { isFulfilled: testValue } } }); - - testFieldSelector(fieldKeys.isVerified, - 'returns enrollments isVerified value', - { data: { enrollment: { isVerified: testValue } } }); - - testFieldSelector(fieldKeys.isRestricted, - 'returns certificates isRestricted value', - { data: { certificates: { isRestricted: testValue } } }); - - testFieldSelector(fieldKeys.isPassing, - 'returns grades isPassing value', - { data: { grades: { isPassing: testValue } } }); - - testFieldSelector(fieldKeys.minPassingGrade, - 'returns course run minPassingGrade value', - { data: { courseRun: { minPassingGrade: testValue } } }); - - testFieldSelector(fieldKeys.providerName, - 'returns provider name value', - { data: { provider: { name: testValue } } }, - [{ - condition: 'provider is not known', - data: {}, - expected: undefined, - message: 'returns undefined', - }]); - - testFieldSelector(fieldKeys.relatedPrograms, - 'returns relatedPrograms value', - { data: { relatedPrograms: testValue } }); - - testFieldSelector(fieldKeys.isCourseRunActive, - 'returns true if course run is started but not finished', - { - data: { courseRun: { isStarted: true, isFinished: false } }, - expected: true, - }, - [ - { - condition: 'is not started', - data: { courseRun: { isStarted: false, isFinished: false } }, - expected: false, - message: 'returns false', - }, { - condition: 'is finished', - data: { courseRun: { isStarted: true, isFinished: true } }, - expected: false, - message: 'returns false', - }]); - }); - describe('programs', () => { - test('estimatedNumberOfWeeks returns value from passed program data', () => { - expect( - programs.estimatedNumberOfWeeks({ estimatedNumberOfWeeks: testValue }), - ).toEqual(testValue); - }); - test('numberOfCourses returns value from passed program data', () => { - expect(programs.numberOfCourses({ numberOfCourses: testValue })).toEqual(testValue); - }); - test('programType returns value from passed program data', () => { - expect(programs.programType({ programType: testValue })).toEqual(testValue); - }); - test('programTypeUrl returns value from passed program data', () => { - expect(programs.programTypeUrl({ programTypeUrl: testValue })).toEqual(testValue); - }); - test('provider returns value from passed program data', () => { - expect(programs.provider({ provider: testValue })).toEqual(testValue); - }); - test('title returns value from passed program data', () => { - expect(programs.title({ title: testValue })).toEqual(testValue); - }); - }); -}); diff --git a/src/data/redux/hooks.js b/src/data/redux/hooks.js new file mode 100644 index 0000000..fa68618 --- /dev/null +++ b/src/data/redux/hooks.js @@ -0,0 +1,23 @@ +import { useSelector } from 'react-redux'; + +import appSelectors from './app/selectors'; + +const { courseCard } = appSelectors; + +export const useEmailConfirmationData = () => useSelector(appSelectors.emailConfirmation); +export const useEnterpriseDashboardData = () => useSelector(appSelectors.enterpriseDashboards); +export const usePlatformSettingsData = () => useSelector(appSelectors.platformSettings); + +// eslint-disable-next-line +export const useCourseCardData = (selector) => (courseNumber) => useSelector( + (state) => selector(state, courseNumber), +); + +export const useCardCertificateData = useCourseCardData(courseCard.certificates); +export const useCardCourseData = useCourseCardData(courseCard.course); +export const useCardCourseRunData = useCourseCardData(courseCard.courseRun); +export const useCardEnrollmentData = useCourseCardData(courseCard.enrollment); +export const useCardEntitlementsData = useCourseCardData(courseCard.entitlements); +export const useCardGradeData = useCourseCardData(courseCard.grades); +export const useCardProviderData = useCourseCardData(courseCard.provider); +export const useCardRelatedProgramsData = useCourseCardData(courseCard.relatedPrograms); diff --git a/src/data/redux/index.js b/src/data/redux/index.js index 09dc5b9..22f41c4 100644 --- a/src/data/redux/index.js +++ b/src/data/redux/index.js @@ -4,14 +4,14 @@ import { StrictDict } from 'utils'; import * as app from './app'; import * as requests from './requests'; -import * as cardData from './cardData'; + +import * as hooks from './hooks'; export { default as thunkActions } from './thunkActions'; const modules = { app, requests, - cardData, }; const moduleProps = (propName) => Object.keys(modules).reduce( @@ -28,6 +28,6 @@ const actions = StrictDict(moduleProps('actions')); const selectors = StrictDict(moduleProps('selectors')); -export { actions, selectors }; +export { actions, selectors, hooks }; export default rootReducer; diff --git a/src/data/redux/thunkActions/app.js b/src/data/redux/thunkActions/app.js index 546ccc4..6bc34dc 100644 --- a/src/data/redux/thunkActions/app.js +++ b/src/data/redux/thunkActions/app.js @@ -14,18 +14,20 @@ import requests from './requests'; */ export const initialize = () => (dispatch) => ( dispatch(requests.initializeList({ - onSuccess: (({ enrollments, entitlements }) => { + onSuccess: (({ enrollments, entitlements, ...globalData }) => { dispatch(actions.app.loadEnrollments(enrollments)); dispatch(actions.app.loadEntitlements(entitlements)); + dispatch(actions.app.loadGlobalData(globalData)); }), })) ); export const refreshList = () => (dispatch) => ( dispatch(requests.initializeList({ - onSuccess: (({ enrollments, entitlements }) => { + onSuccess: (({ enrollments, entitlements, ...globalData }) => { dispatch(actions.app.loadEnrollments(enrollments)); dispatch(actions.app.loadEntitlements(entitlements)); + dispatch(actions.app.loadGlobalData(globalData)); }), })) ); diff --git a/src/data/services/lms/api.js b/src/data/services/lms/api.js index 99c2cac..0556f6f 100644 --- a/src/data/services/lms/api.js +++ b/src/data/services/lms/api.js @@ -18,6 +18,7 @@ import { const initializeList = () => Promise.resolve({ enrollments: fakeData.courseRunData, entitlements: fakeData.entitlementCourses, + ...fakeData.globalData, }); export default { initializeList }; diff --git a/src/data/services/lms/fakeData/courses.js b/src/data/services/lms/fakeData/courses.js index d0d9145..da83787 100644 --- a/src/data/services/lms/fakeData/courses.js +++ b/src/data/services/lms/fakeData/courses.js @@ -21,20 +21,19 @@ export const relatedPrograms = [ title: 'Relativity in Modern Mechanics', programUrl: 'www.edx/my-program', programType: 'MicroBachelors Program', - programTypeUrl: 'www.edx/my-program-type', numberOfCourses: 3, - estimatedDuration: '4 weeks', + estimatedNumberOfWeeks: 4, }, { provider: 'University of Maryland', bannerUrl: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', logoUrl: 'https://prod-discovery.edx-cdn.org/organization/certificate_logos/b9dc96da-b3fc-45a6-b6b7-b8e12eb79335-ac60112330e3.png', title: 'Pandering for Modern Professionals', - programUrl: 'www.edx/my-program', + programUrl: 'www.edx/my-program-2', programType: 'MicroBachelors Program', programTypeUrl: 'www.edx/my-program-type', numberOfCourses: 3, - estimatedDuration: '4 weeks', + estimatedNumberOfWeeks: 4, }, ]; @@ -50,23 +49,48 @@ const logos = { const pastDate = '11/11/2000'; const futureDate = '11/11/3030'; +const globalData = { + emailConfirmation: { + isNeeded: true, + sendEmailUrl: 'sendConfirmation@edx.org', + }, + enterpriseDashboards: { + availableDashboards: [ + { label: 'edX', url: 'edx.org/edx-dashboard' }, + { label: 'harvard', url: 'edx.org/harvard-dashboard' }, + ], + mostRecentDashboard: { label: 'edX', url: 'edx.org/edx-dashboard' }, + }, + platformSettings: { + supportEmail: 'support@example.com', + billingEmail: 'billing@email.com', + courseSearchUrl: 'edx.com/course-search', + }, +}; + export const genCourseRunData = (data = {}) => ({ - isPending: false, isStarted: false, - isFinished: false, isArchived: false, - accessExpirationDate: futureDate, endDate: futureDate, minPassingGrade: 70, + homeUrl: 'edx.com/courses/my-course-url/home', + marketingUrl: 'edx.com/courses/my-course-url/marketing', + progressUrl: 'edx.com/courses/my-course-url/progress', + unenrollUrl: 'edx.com/courses/my-course-url/unenroll', + resumeUrl: 'edx.com/courses/my-course-url/resume', ...data, }); export const genEnrollmentData = (data = {}) => ({ - isAudit: true, - isVerified: false, + accessExpirationDate: futureDate, canUpgrade: data.verified ? null : true, + hasStarted: false, + isAudit: true, isAuditAccessExpired: data.verified ? null : false, isEmailEnabled: false, + isEnrolled: true, + isVerified: false, + lastEnrolled: pastDate, ...data, }); @@ -167,7 +191,7 @@ export const courseRuns = [ { enrollment: genEnrollmentData({ isAudit: false, isVerified: true }), grades: { isPassing: false }, - courseRun: { isFinished: true, endDate: pastDate }, + courseRun: { isArchived: true, endDate: pastDate }, certificates: genCertificateData(), entitlements: { isEntitlement: false }, }, @@ -277,7 +301,6 @@ export const courseRuns = [ grades: { isPassing: true }, courseRun: { isStarted: true, - isFinished: true, isArchived: true, endDate: pastDate, }, @@ -372,4 +395,5 @@ export const courseRunData = courseRuns.map( export default { courseRunData, entitlementCourses, + globalData, }; diff --git a/src/hooks.js b/src/hooks.js index 51464c3..9c28275 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -1,19 +1,6 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; -export const useCardValue = (courseNumber, sel) => ( - useSelector(state => sel(state, courseNumber)) -); - -export const useCardValues = (courseNumber, mapping) => ( - Object.keys(mapping).reduce( - // eslint-disable-next-line - (obj, key) => ({ ...obj, [key]: useCardValue(courseNumber, mapping[key]) }), - {}, - ) -); - export const useValueCallback = (cb, prereqs = []) => ( React.useCallback(e => cb(e.target.value), prereqs) // eslint-disable-line ); @@ -23,7 +10,6 @@ export const nullMethod = () => ({}); export { useIntl }; export default { - useCardValues, useValueCallback, nullMethod, useIntl, diff --git a/src/setupTest.jsx b/src/setupTest.jsx index e88ac35..8b8d4d9 100755 --- a/src/setupTest.jsx +++ b/src/setupTest.jsx @@ -146,23 +146,7 @@ jest.mock('react-redux', () => { }; }); -jest.mock('hooks', () => { - const formatMessage = jest.fn((msg, values) => ({ formatted: { msg, values } })); - return { - ...jest.requireActual('hooks'), - useIntl: () => ({ - formatMessage, - formatDate: jest.fn((date) => ({ formatted: date })), - }), - useCardValues: jest.fn((courseNumber, mapping) => ( - Object.keys(mapping).reduce( - (obj, key) => ({ - ...obj, - [key]: { selector: mapping[key], courseNumber }, - }), - {}, - ) - )), - nullMethod: jest.fn().mockName('hooks.nullMethod'), - }; -}); +jest.mock('hooks', () => ({ + ...jest.requireActual('hooks'), + nullMethod: jest.fn().mockName('hooks.nullMethod'), +})); diff --git a/src/test/app.test.jsx b/src/test/app.test.jsx index 85e459e..7c4f801 100644 --- a/src/test/app.test.jsx +++ b/src/test/app.test.jsx @@ -147,11 +147,13 @@ describe('ESG app integration tests', () => { courseData.course.title, ); cardDetails = inspector.get.card.details(card); + + console.log({ enrollment: courseData.enrollment }); [ courseData.provider.name, courseNumber, appMessages.withValues.CourseCard.accessExpires({ - accessExpirationDate: courseData.courseRun.accessExpirationDate, + accessExpirationDate: courseData.enrollment.accessExpirationDate, }), ].forEach(value => inspector.verifyTextIncludes(cardDetails, value)); diff --git a/src/testUtils.js b/src/testUtils.js index 04a6237..6b79c3d 100644 --- a/src/testUtils.js +++ b/src/testUtils.js @@ -1,13 +1,7 @@ import react from 'react'; -import { selectors } from 'data/redux'; - -import * as appHooks from 'hooks'; - import { StrictDict } from 'utils'; -const { cardData } = selectors; - /** * Mocked formatMessage provided by react-intl */ @@ -195,27 +189,3 @@ export class MockUseState { }); } } - -/** - * Test that useCardValues was called with the given courseNumber and selector mapping. - * @param {string} courseNumber - course run identifier - * @param {obj} mapping - value mapping { : } - * @param {[func]} beforeEachFn - optional beforeEach method - */ -export const testCardValues = (courseNumber, mapping, beforeEachFn) => { - describe('cardData values', () => { - if (beforeEachFn) { - beforeEach(beforeEachFn); - } - let mapped; - test('passess correct courseNumber', () => { - expect(appHooks.useCardValues.mock.calls[0][0]).toEqual(courseNumber); - }); - Object.keys(mapping).forEach(key => { - test(`loads ${key} from card data ${mapping[key]} selector`, () => { - [[, mapped]] = appHooks.useCardValues.mock.calls; - expect(mapped[key]).toEqual(cardData[mapping[key]]); - }); - }); - }); -}; From 7a46b3c2a83085006a5dbdd8b8bf800c5bcfa8e2 Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Fri, 22 Jul 2022 01:38:21 -0400 Subject: [PATCH 5/5] chore: test and api updates --- .../components/Banners/CertificateBanner.jsx | 49 +++++--- .../Banners/CertificateBanner.test.jsx | 111 ++++++++++++++++++ .../components/Banners/EntitlementBanner.jsx | 39 ++++-- .../Banners/EntitlementBanner.test.jsx | 69 +++++++++++ .../CertificateBanner.test.jsx.snap | 33 ++++++ .../EntitlementBanner.test.jsx.snap | 60 ++++++++++ .../CourseCard/components/Banners/messages.js | 62 +++++++++- src/containers/CourseCard/hooks.test.js | 2 +- src/data/redux/app/hooks.js | 23 ---- src/data/redux/app/reducer.js | 26 ++-- src/data/redux/app/reducer.test.js | 61 ++++++---- src/data/redux/app/selectors.js | 29 +++-- src/data/redux/thunkActions/app.js | 6 +- src/data/services/lms/api.js | 2 +- src/data/services/lms/fakeData/courses.js | 69 ++++++++--- src/setupTest.jsx | 3 +- src/test/app.test.jsx | 4 +- 17 files changed, 538 insertions(+), 110 deletions(-) create mode 100644 src/containers/CourseCard/components/Banners/CertificateBanner.test.jsx create mode 100644 src/containers/CourseCard/components/Banners/EntitlementBanner.test.jsx create mode 100644 src/containers/CourseCard/components/Banners/__snapshots__/CertificateBanner.test.jsx.snap create mode 100644 src/containers/CourseCard/components/Banners/__snapshots__/EntitlementBanner.test.jsx.snap delete mode 100644 src/data/redux/app/hooks.js diff --git a/src/containers/CourseCard/components/Banners/CertificateBanner.jsx b/src/containers/CourseCard/components/Banners/CertificateBanner.jsx index 3ef40e4..9ff06f6 100644 --- a/src/containers/CourseCard/components/Banners/CertificateBanner.jsx +++ b/src/containers/CourseCard/components/Banners/CertificateBanner.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Hyperlink } from '@edx/paragon'; +import { MailtoLink, Hyperlink } from '@edx/paragon'; import { CheckCircle } from '@edx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -19,61 +19,76 @@ export const CertificateBanner = ({ courseNumber }) => { hasFinished, } = appHooks.useCardEnrollmentData(courseNumber); const { isPassing } = appHooks.useCardGradeData(courseNumber); - const { minPassingGrade } = appHooks.useCardCourseRunData(courseNumber); + const { minPassingGrade, progressUrl } = appHooks.useCardCourseRunData(courseNumber); + const { supportEmail, billingEmail } = appHooks.usePlatformSettingsData(); const { formatMessage } = useIntl(); + const emailLink = address => address && {address}; + if (certificate.isRestricted) { return ( - {formatMessage(messages.certRestricted)} - info@example.com - {isVerified && ( - <> - If you would like a refund on your Certificate of Achievement, please contact our billing address billing@example.com - + {formatMessage(messages.certRestricted, { supportEmail: emailLink(supportEmail) })} + {isVerified && ' '} + {isVerified && formatMessage( + messages.certRefundContactBilling, + { billingEmail: emailLink(billingEmail) }, )} ); } if (!isPassing) { if (isAudit) { - return ( Grade required to pass the course: {minPassingGrade}% ); + return ( + + {formatMessage(messages.passingGrade, { minPassingGrade })} + + ); } if (hasFinished) { return ( - You are not eligible for a certificate. View grades. + {formatMessage(messages.notEligibleForCert)}. + {' '} + {formatMessage(messages.viewGrades)} ); } return ( - Grade required for a certificate: {minPassingGrade}% + {formatMessage(messages.certMinGrade, { minPassingGrade })} ); } if (certificate.isDownloadable) { - if (certificate.previewUrl) { + if (certificate.certPreviewUrl) { return ( - Congratulations. Your certificate is ready. + {formatMessage(messages.certReady)} {' '} - View Certificate. + + {formatMessage(messages.viewCertificate)} + ); } return ( - Congratulations. Your certificate is ready. + {formatMessage(messages.certReady)} {' '} - Download Certificate. + + {formatMessage(messages.downloadCertificate)} + ); } if (certificate.isEarnedButUnavailable) { return ( - Your grade and certificate will be ready after {certificate.availableDate}. + {formatMessage( + messages.gradeAndCertReadyAfter, + { availableDate: certificate.availableDate }, + )} ); } diff --git a/src/containers/CourseCard/components/Banners/CertificateBanner.test.jsx b/src/containers/CourseCard/components/Banners/CertificateBanner.test.jsx new file mode 100644 index 0000000..24a23fa --- /dev/null +++ b/src/containers/CourseCard/components/Banners/CertificateBanner.test.jsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Hyperlink } from '@edx/paragon'; + +import { hooks as appHooks } from 'data/redux'; +import { CourseBanner } from './CourseBanner'; + +import messages from './messages'; + +jest.mock('components/Banner', () => 'Banner'); +jest.mock('data/redux', () => ({ + hooks: { + useCardCourseData: jest.fn(), + useCardCourseRunData: jest.fn(), + useCardEnrollmentData: jest.fn(), + }, +})); + +const courseNumber = 'my-test-course-number'; + +let el; + +const enrollmentData = { + isVerified: false, + canUpgrade: false, + isAuditAccessExpired: false, +}; +const courseRunData = { + isActive: false, +}; +const courseData = { + website: 'test-course-website', +}; + +const render = (overrides = {}) => { + const { + course = {}, + courseRun = {}, + enrollment = {}, + } = overrides; + appHooks.useCardCourseData.mockReturnValueOnce({ + ...courseData, + ...course, + }); + appHooks.useCardCourseRunData.mockReturnValueOnce({ + ...courseRunData, + ...courseRun, + }); + appHooks.useCardEnrollmentData.mockReturnValueOnce({ + ...enrollmentData, + ...enrollment, + }); + el = shallow(); +}; + +describe('CourseBanner', () => { + it('initializes data with course number from enrollment, course and course run data', () => { + render(); + expect(appHooks.useCardCourseData).toHaveBeenCalledWith(courseNumber); + expect(appHooks.useCardCourseRunData).toHaveBeenCalledWith(courseNumber); + expect(appHooks.useCardEnrollmentData).toHaveBeenCalledWith(courseNumber); + }); + test('no display if learner is verified', () => { + render({ enrollment: { isVerified: true } }); + expect(el.isEmptyRender()).toEqual(true); + }); + describe('audit access expired, can upgrade', () => { + beforeEach(() => { + render({ enrollment: { isAuditAccessExpired: true, canUpgrade: true } }); + }); + test('snapshot: (auditAccessExpired, upgradeToAccess)', () => { + expect(el).toMatchSnapshot(); + }); + test('messages: (auditAccessExpired, upgradeToAccess)', () => { + expect(el.text()).toContain(messages.auditAccessExpired.defaultMessage); + expect(el.text()).toContain(messages.upgradeToAccess.defaultMessage); + }); + }); + describe('audit access expired, cannot upgrade', () => { + beforeEach(() => { + render({ enrollment: { isAuditAccessExpired: true } }); + }); + test('snapshot: (auditAccessExpired, findAnotherCourse hyperlink)', () => { + expect(el).toMatchSnapshot(); + }); + test('messages: (auditAccessExpired, upgradeToAccess)', () => { + expect(el.text()).toContain(messages.auditAccessExpired.defaultMessage); + expect(el.find(Hyperlink).text()).toEqual(messages.findAnotherCourse.defaultMessage); + }); + }); + describe('course run active and cannot upgrade', () => { + beforeEach(() => { + render({ courseRun: { isActive: true } }); + }); + test('snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => { + expect(el).toMatchSnapshot(); + }); + test('messages: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => { + expect(el.text()).toContain(messages.upgradeDeadlinePassed.defaultMessage); + const link = el.find(Hyperlink); + expect(link.text()).toEqual(messages.exploreCourseDetails.defaultMessage); + expect(link.props().destination).toEqual(courseData.website); + }); + }); + test('no display if audit access not expired and (course is not active or can upgrade)', () => { + render(); + expect(el.isEmptyRender()).toEqual(true); + render({ enrollment: { canUpgrade: true }, courseRun: { isActive: true } }); + expect(el.isEmptyRender()).toEqual(true); + }); +}); diff --git a/src/containers/CourseCard/components/Banners/EntitlementBanner.jsx b/src/containers/CourseCard/components/Banners/EntitlementBanner.jsx index 8c24139..771e1c4 100644 --- a/src/containers/CourseCard/components/Banners/EntitlementBanner.jsx +++ b/src/containers/CourseCard/components/Banners/EntitlementBanner.jsx @@ -1,27 +1,52 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, MailtoLink } from '@edx/paragon'; import { hooks as appHooks } from 'data/redux'; import Banner from 'components/Banner'; +import messages from './messages'; export const EntitlementBanner = ({ courseNumber }) => { const { - canChange, isEntitlement, - isExpired, + hasSessions, isFulfilled, + changeDeadline, + showExpirationWarning, } = appHooks.useCardEntitlementsData(courseNumber); + const { supportEmail } = appHooks.usePlatformSettingsData(); + const { formatDate, formatMessage } = useIntl(); if (!isEntitlement) { return null; } - if (isExpired || isFulfilled) { - return null; + + if (!hasSessions && !isFulfilled) { + return ( + + {formatMessage(messages.entitlementsUnavailable, { + emailLink: supportEmail && {supportEmail}, + })} + + ); } - return canChange - ? (You must select a session to access the course.) - : (The deadline to select a session has passed); + if (showExpirationWarning) { + return ( + + {formatMessage(messages.entitlementsExpiringSoon, { + changeDeadline: formatDate(changeDeadline), + selectSessionButton: ( + + ), + })} + + ); + } + return null; }; EntitlementBanner.propTypes = { courseNumber: PropTypes.string.isRequired, diff --git a/src/containers/CourseCard/components/Banners/EntitlementBanner.test.jsx b/src/containers/CourseCard/components/Banners/EntitlementBanner.test.jsx new file mode 100644 index 0000000..b805750 --- /dev/null +++ b/src/containers/CourseCard/components/Banners/EntitlementBanner.test.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { hooks as appHooks } from 'data/redux'; +import EntitlementBanner from './EntitlementBanner'; + +jest.mock('@edx/frontend-platform/i18n', () => ({ + useIntl: jest.fn(), +})); +jest.mock('components/Banner', () => 'Banner'); +jest.mock('data/redux', () => ({ + hooks: { + usePlatformSettingsData: jest.fn(), + useCardEntitlementsData: jest.fn(), + }, +})); + +const courseNumber = 'my-test-course-number'; + +let el; + +const entitlementsData = { + isEntitlement: true, + hasSessions: true, + isFulfilled: false, + changeDeadline: 'test-deadline', + showExpirationWarning: false, +}; +const platformData = { supportEmail: 'test-support-email' }; + +const render = (overrides = {}) => { + const { entitlements = {} } = overrides; + appHooks.useCardEntitlementsData.mockReturnValueOnce({ ...entitlementsData, ...entitlements }); + appHooks.usePlatformSettingsData.mockReturnValueOnce(platformData); + el = shallow(); +}; + +describe('EntitlementBanner', () => { + beforeEach(() => { + useIntl.mockReturnValue({ + formatDate: (date) => date, + formatMessage: (message, values) =>
, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('initializes data with course number from entitlements', () => { + render(); + expect(appHooks.useCardEntitlementsData).toHaveBeenCalledWith(courseNumber); + }); + test('no display if not an entitlement', () => { + render({ entitlements: { isEntitlement: false } }); + expect(el.isEmptyRender()).toEqual(true); + }); + test('snapshot: no sessions available', () => { + render({ entitlements: { isFulfilled: false, hasSessions: false } }); + expect(el).toMatchSnapshot(); + }); + test('snapshot: expiration warning', () => { + render({ entitlements: { showExpirationWarning: true } }); + expect(el).toMatchSnapshot(); + }); + test('no display if sessions available and not displaying warning', () => { + render(); + expect(el.isEmptyRender()).toEqual(true); + }); +}); diff --git a/src/containers/CourseCard/components/Banners/__snapshots__/CertificateBanner.test.jsx.snap b/src/containers/CourseCard/components/Banners/__snapshots__/CertificateBanner.test.jsx.snap new file mode 100644 index 0000000..5c3160a --- /dev/null +++ b/src/containers/CourseCard/components/Banners/__snapshots__/CertificateBanner.test.jsx.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CourseBanner audit access expired, can upgrade snapshot: (auditAccessExpired, upgradeToAccess) 1`] = ` + + Your audit access to this course has expired. + + Upgrade now to access your course again. + +`; + +exports[`CourseBanner audit access expired, cannot upgrade snapshot: (auditAccessExpired, findAnotherCourse hyperlink) 1`] = ` + + Your audit access to this course has expired. + + + Find another course + + +`; + +exports[`CourseBanner course run active and cannot upgrade snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink) 1`] = ` + + Your upgrade deadline for this course has passed. To upgrade, enroll in a session that is farther in the future. + + + Explore course details. + + +`; diff --git a/src/containers/CourseCard/components/Banners/__snapshots__/EntitlementBanner.test.jsx.snap b/src/containers/CourseCard/components/Banners/__snapshots__/EntitlementBanner.test.jsx.snap new file mode 100644 index 0000000..cad1507 --- /dev/null +++ b/src/containers/CourseCard/components/Banners/__snapshots__/EntitlementBanner.test.jsx.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EntitlementBanner snapshot: expiration warning 1`] = ` + +
+
+ , + } + } + /> + +`; + +exports[`EntitlementBanner snapshot: no sessions available 1`] = ` + +
+ test-support-email + , + } + } + /> + +`; diff --git a/src/containers/CourseCard/components/Banners/messages.js b/src/containers/CourseCard/components/Banners/messages.js index d6889c4..052d800 100644 --- a/src/containers/CourseCard/components/Banners/messages.js +++ b/src/containers/CourseCard/components/Banners/messages.js @@ -29,7 +29,67 @@ export const messages = StrictDict({ certRestricted: { id: 'learner-dash.courseCard.banners.certificateRestricted', description: 'Restricted certificate warning message', - defaultMessage: 'Your Certificate of Achievement is being held pending confirmation that the issuance of your Certificate is in compliance with strict U.S. embargoes on Iran, Cuba, Syria, and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting ', + defaultMessage: 'Your Certificate of Achievement is being held pending confirmation that the issuance of your Certificate is in compliance with strict U.S. embargoes on Iran, Cuba, Syria, and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting {supportEmail}.', + }, + certRefundContactBilling: { + id: 'learner-dash.courseCard.banners.certificateRefundContactBilling', + description: 'Message to learners to contact billing for certificate refunds', + defaultMessage: 'If you would like a refund on your Certificate of Achievement, please contact our billing address {billingEmail}', + }, + passingGrade: { + id: 'learner-dash.courseCard.banners.passingGrade', + description: 'Message to learners with minimum passing grade for the course', + defaultMessage: 'Grade required to pass the course: {minPassingGrade}', + }, + notEligibleForCert: { + id: 'learner-dash.courseCard.banners.notEligibleForCert', + description: 'Certificate inelligibility message', + defaultMessage: 'You are not eligible for a certificate.', + }, + viewGrades: { + id: 'learner-dash.courseCard.banners.viewGrades', + description: 'Gradses link text', + defaultMessage: 'View grades.', + }, + certReady: { + id: 'learner-dash.courseCard.banners.certReady', + description: 'Certificate ready message', + defaultMessage: 'Congratulations. Your certificate is ready.', + }, + viewCertificate: { + id: 'learner-dash.courseCard.banners.viewCertificate', + description: 'Certificate link text', + defaultMessage: 'View Certificate.', + }, + certMinGrade: { + id: 'learner-dash.courseCard.banners.certMinGrade', + description: 'Passing grade requirement message', + defaultMessage: 'Grade required for a certificate: {minPassingGrade}', + }, + downloadCertificate: { + id: 'learner-dash.courseCard.banners.downloadCertificate', + description: 'Certificate download link text', + defaultMessage: 'Download Certificate.', + }, + gradeAndCertReadyAfter: { + id: 'learner-dash.courseCard.banners.gradseAndCertReadyAfter', + description: 'Grade and certificate availability date message', + defaultMessage: 'Your grade and certificate will be ready after {availableDate}.', + }, + entitlementsUnavailable: { + id: 'learner-dash.courseCard.banners.entitlementsUnavailable', + description: 'Entitlements course message when no sessions are available', + defaultMessage: 'There are no sessions available at the moment. The course team will create new sessions soon. If no sessions appear, please contact {emailLink} for information.', + }, + entitlementsExpiringSoon: { + id: 'learner-dash.courseCard.banners.entitlementsExpiringSoon', + description: 'Entitlements course message when the entitlement is expiring soon.', + defaultMessage: 'You must {selectSessionButton} by {changeDeadline} to access the course.', + }, + selectSession: { + id: 'learner-dash.courseCard.banners.selectSession', + description: 'Entitlements session selection link text', + defaultMessage: 'select a session', }, }); diff --git a/src/containers/CourseCard/hooks.test.js b/src/containers/CourseCard/hooks.test.js index a537bac..1233d91 100644 --- a/src/containers/CourseCard/hooks.test.js +++ b/src/containers/CourseCard/hooks.test.js @@ -127,7 +127,7 @@ describe('CourseCard hooks', () => { describe('if verified and ended', () => { it('returns course ended message with course end date', () => { - runHook({ courseRun: { isFinished: true } }); + runHook({ courseRun: { isArchived: true } }); expect(out).toEqual(formatMessage( messages.courseEnded, { endDate: formatDate(courseRunData.endDate) }, diff --git a/src/data/redux/app/hooks.js b/src/data/redux/app/hooks.js deleted file mode 100644 index 09c9de6..0000000 --- a/src/data/redux/app/hooks.js +++ /dev/null @@ -1,23 +0,0 @@ -import { useSelector } from 'react-redux'; - -import selectors from './selectors'; - -const { courseCard } = selectors; - -export const useEmailConfirmationData = () => useSelector(selectors.emailConfirmation); -export const useEnterpriseDashboardData = () => useSelector(selectors.enterpriseDashboards); -export const usePlatformSettingsData = () => useSelector(selectors.platformSettings); - -// eslint-disable-next-line -export const useCourseCardData = (selector) => (courseNumber) => useSelector( - (state) => selector(selectors.courseData(state)[courseNumber]), -); - -export const useCardCertificateData = useCourseCardData(courseCard.certificates); -export const useCardCourseData = useCourseCardData(courseCard.course); -export const useCardCourseRunData = useCourseCardData(courseCard.courseRun); -export const useCardEnrollmentData = useCourseCardData(courseCard.enrollment); -export const useCardEntitlementsData = useCourseCardData(courseCard.entitlements); -export const useCardGradesData = useCourseCardData(courseCard.grades); -export const useCardProviderData = useCourseCardData(courseCard.provider); -export const useCardRelatedProgramsData = useCourseCardData(courseCard.relatedPrograms); diff --git a/src/data/redux/app/reducer.js b/src/data/redux/app/reducer.js index dec92e8..0ef8192 100644 --- a/src/data/redux/app/reducer.js +++ b/src/data/redux/app/reducer.js @@ -9,6 +9,7 @@ const initialState = { enterpriseDashboards: {}, platformSettings: {}, suggestedCourses: {}, + filterState: {}, }; // eslint-disable-next-line no-unused-vars @@ -16,18 +17,23 @@ const app = createSlice({ name: 'app', initialState, reducers: { - loadEnrollments: (state, { payload }) => ({ + loadCourses: (state, { payload: { enrollments, entitlements } }) => ({ ...state, - enrollments: payload.map(curr => curr.courseRun.courseNumber), - courseData: payload.reduce( - (obj, curr) => ({ - ...obj, - [curr.courseRun.courseNumber]: curr, - }), - {}, - ), + enrollments: [ + ...enrollments.map(curr => curr.courseRun.courseNumber), + ...entitlements.map(curr => curr.courseRun.courseNumber), + ], + courseData: { + ...entitlements.reduce( + (obj, curr) => ({ ...obj, [curr.courseRun.courseNumber]: curr }), + {}, + ), + ...enrollments.reduce( + (obj, curr) => ({ ...obj, [curr.courseRun.courseNumber]: curr }), + {}, + ), + }, }), - loadEntitlements: (state, { payload }) => ({ ...state, entitlements: payload }), loadGlobalData: (state, { payload }) => ({ ...state, emailConfirmation: payload.emailConfirmation, diff --git a/src/data/redux/app/reducer.test.js b/src/data/redux/app/reducer.test.js index 549ad75..f46b2fb 100644 --- a/src/data/redux/app/reducer.test.js +++ b/src/data/redux/app/reducer.test.js @@ -20,45 +20,66 @@ describe('app reducer', () => { }); }; describe('action handlers', () => { - test('loadEntitlements loads entitlements from payload', () => { - testAction( - actions.loadEntitlements(testValue), - { entitlements: testValue }, - ); - }); - describe('loadEnrollments', () => { - const enrollments = [ + describe('loadCourses', () => { + const courseIds = [ 'course-1', 'course-2', 'course-3', ]; - const courseData = { - [enrollments[0]]: { - courseRun: { courseNumber: enrollments[0] }, + const entitlementIds = [ + 'entitlement-course-1', + 'entitlement-course-2', + ]; + const enrollmentData = [ + { + courseRun: { courseNumber: courseIds[0] }, course: 1, some: 'data', }, - [enrollments[1]]: { - courseRun: { courseNumber: enrollments[1] }, + { + courseRun: { courseNumber: courseIds[1] }, course: 2, some: 'other data', }, - [enrollments[2]]: { - courseRun: { courseNumber: enrollments[2] }, + { + courseRun: { courseNumber: courseIds[2] }, course: 3, some: 'still different data', }, - }; - const enrollmentData = enrollments.map(v => courseData[v]); + ]; + const entitlementData = [ + { + courseRun: { courseNumber: entitlementIds[0] }, + course: 4, + some: 'STILL different data', + }, + { + courseRun: { courseNumber: entitlementIds[1] }, + course: 5, + some: 'still DIFFERENT data', + }, + ]; let out; beforeEach(() => { - out = reducer(testState, actions.loadEnrollments(enrollmentData)); + out = reducer(testState, actions.loadCourses({ + enrollments: enrollmentData, + entitlements: entitlementData, + })); }); it('loads list of courseRun ids into enrollments field', () => { - expect(out.enrollments).toEqual(enrollments); + expect(out.enrollments).toEqual([ + ...courseIds, + ...entitlementIds, + ]); }); it('loads object keyed by courseRun ids into courseData field', () => { - expect(out.courseData).toEqual(courseData); + expect(out.courseData).toEqual({ + [courseIds[0]]: enrollmentData[0], + [courseIds[1]]: enrollmentData[1], + [courseIds[2]]: enrollmentData[2], + [entitlementIds[0]]: entitlementData[0], + [entitlementIds[1]]: entitlementData[1], + }); }); }); }); diff --git a/src/data/redux/app/selectors.js b/src/data/redux/app/selectors.js index 7923f43..3d61ee7 100644 --- a/src/data/redux/app/selectors.js +++ b/src/data/redux/app/selectors.js @@ -26,11 +26,15 @@ const mkCardSelector = (sel) => (state, courseNumber) => ( sel(courseCardData(state, courseNumber)) ); +const dateSixMonthsFromNow = new Date(); +dateSixMonthsFromNow.setDate(dateSixMonthsFromNow.getDate() + 180); + export const courseCard = StrictDict({ certificates: mkCardSelector(({ certificates }) => ({ availableDate: certificates.availableDate, - downloadUrl: certificates.downloadUrls?.download, - previewUrl: certificates.downloadUrls?.preview, + certDownloadUrl: certificates.certDownloadUrl, + honorCertDownloadUrl: certificates.honorCertDownloadUrl, + certPreviewUrl: certificates.certPreviewUrl, isDownloadable: certificates.isDownloadable, isEarnedButUnavailable: certificates.isEarned && !certificates.isAvailable, isRestricted: certificates.isRestricted, @@ -58,13 +62,20 @@ export const courseCard = StrictDict({ lastEnrolled: enrollment.lastEnrollment, isEnrolled: enrollment.isEnrolled, })), - entitlements: mkCardSelector(({ entitlements }) => ({ - canChange: entitlements.canChange, - entitlementSessions: entitlements.availableSessions, - isEntitlement: entitlements.isEntitlement, - isExpired: entitlements.isExpired, - isFulfilled: entitlements.isFulfilled, - })), + entitlements: mkCardSelector(({ entitlements }) => { + const deadline = new Date(entitlements.changeDeadline); + const showExpirationWarning = deadline > new Date() && deadline <= dateSixMonthsFromNow; + return { + canChange: entitlements.canChange, + entitlementSessions: entitlements.availableSessions, + isEntitlement: entitlements.isEntitlement, + isExpired: entitlements.isExpired, + isFulfilled: entitlements.isFulfilled, + hasSessions: entitlements.availableSessions?.length > 0, + changeDeadline: entitlements.changeDeadline, + showExpirationWarning, + }; + }), grades: mkCardSelector(({ grades }) => ({ isPassing: grades.isPassing })), provider: mkCardSelector(({ provider }) => ({ name: provider?.name })), relatedPrograms: mkCardSelector(({ relatedPrograms }) => ({ diff --git a/src/data/redux/thunkActions/app.js b/src/data/redux/thunkActions/app.js index 6bc34dc..196f085 100644 --- a/src/data/redux/thunkActions/app.js +++ b/src/data/redux/thunkActions/app.js @@ -15,8 +15,7 @@ import requests from './requests'; export const initialize = () => (dispatch) => ( dispatch(requests.initializeList({ onSuccess: (({ enrollments, entitlements, ...globalData }) => { - dispatch(actions.app.loadEnrollments(enrollments)); - dispatch(actions.app.loadEntitlements(entitlements)); + dispatch(actions.app.loadCourses({ enrollments, entitlements })); dispatch(actions.app.loadGlobalData(globalData)); }), })) @@ -25,8 +24,7 @@ export const initialize = () => (dispatch) => ( export const refreshList = () => (dispatch) => ( dispatch(requests.initializeList({ onSuccess: (({ enrollments, entitlements, ...globalData }) => { - dispatch(actions.app.loadEnrollments(enrollments)); - dispatch(actions.app.loadEntitlements(entitlements)); + dispatch(actions.app.loadCourses({ enrollments, entitlements })); dispatch(actions.app.loadGlobalData(globalData)); }), })) diff --git a/src/data/services/lms/api.js b/src/data/services/lms/api.js index 0556f6f..e1ba3b8 100644 --- a/src/data/services/lms/api.js +++ b/src/data/services/lms/api.js @@ -17,7 +17,7 @@ import { *********************************************************************************/ const initializeList = () => Promise.resolve({ enrollments: fakeData.courseRunData, - entitlements: fakeData.entitlementCourses, + entitlements: fakeData.entitlementData, ...fakeData.globalData, }); diff --git a/src/data/services/lms/fakeData/courses.js b/src/data/services/lms/fakeData/courses.js index da83787..23d2ad4 100644 --- a/src/data/services/lms/fakeData/courses.js +++ b/src/data/services/lms/fakeData/courses.js @@ -48,6 +48,9 @@ const logos = { const pastDate = '11/11/2000'; const futureDate = '11/11/3030'; +const soonDate = new Date(); +soonDate.setDate(soonDate.getDate() + 60); +const soonDateStr = soonDate.toDateString(); const globalData = { emailConfirmation: { @@ -100,7 +103,9 @@ export const genCertificateData = (data = {}) => ({ isAvailable: false, isEarned: false, isDownloadable: false, - downloadUrls: null, // { preview, download } + certPreviewUrl: 'edx.com/courses/my-course-url/cert-preview', + certDownloadUrl: 'edx.com/courses/my-course-url/cert-download', + honorCertDownloadUrl: 'edx.com/courses/my-course-url/honor-cert-download', ...data, }); @@ -224,10 +229,8 @@ export const courseRuns = [ isAvailable: true, isDownloadable: true, availableDate: pastDate, - downloadUrls: { - preview: logos.edx, - download: logos.social, - }, + certDownloadUrl: logos.social, + certPreviewUrl: logos.edx, }), entitlements: { isEntitlement: false }, }, @@ -241,9 +244,7 @@ export const courseRuns = [ isAvailable: true, isDownloadable: true, availableDate: pastDate, - downloadUrls: { - download: logos.social, - }, + certDownloadUrl: logos.social, }), entitlements: { isEntitlement: false }, }, @@ -319,7 +320,6 @@ export const courseRuns = [ export const entitlementCourses = [ { - course: { title: genCourseTitle(100) }, entitlements: { isEntitlement: true, availableSessions, @@ -331,7 +331,17 @@ export const entitlementCourses = [ isExpired: false, }, }, { - course: { title: genCourseTitle(101) }, + entitlements: { + isEntitlement: true, + availableSessions, + isRefundable: true, + isFulfilled: false, + canViewCourse: false, + changeDeadline: soonDateStr, + canChange: true, + isExpired: false, + }, + }, { entitlements: { isEntitlement: true, availableSessions, @@ -343,10 +353,9 @@ export const entitlementCourses = [ isExpired: false, }, }, { - course: { title: genCourseTitle(102) }, entitlements: { isEntitlement: true, - availableSessions, + availableSessions: [], isRefundable: true, isFulfilled: false, canViewCourse: false, @@ -387,13 +396,45 @@ export const courseRunData = courseRuns.map( ...data, courseRun: genCourseRunData({ ...data.courseRun, courseNumber }), ...iteratedData[providerIndex], - credit: { isPurchased: false, requestStatus: null }, + }; + }, +); + +export const entitlementData = entitlementCourses.map( + (data, index) => { + const title = genCourseTitle(100 + index); + const courseNumber = genCourseID(100 + index); + const providerIndex = index % 3; + const iteratedData = [ + { + provider: providers.edx, + course: { title, bannerUrl: logos.edx }, + relatedPrograms, + }, + { + provider: providers.mit, + course: { title, bannerUrl: logos.science }, + relatedPrograms: [relatedPrograms[0]], + }, + { + provider: null, + course: { title, bannerUrl: logos.social }, + relatedPrograms: [], + }, + ]; + return { + ...data, + enrollment: genEnrollmentData(), + grades: { isPassing: true }, + certificates: genCertificateData(), + courseRun: genCourseRunData({ ...data.courseRun, courseNumber }), + ...iteratedData[providerIndex], }; }, ); export default { courseRunData, - entitlementCourses, + entitlementData, globalData, }; diff --git a/src/setupTest.jsx b/src/setupTest.jsx index 8b8d4d9..c613c94 100755 --- a/src/setupTest.jsx +++ b/src/setupTest.jsx @@ -19,7 +19,7 @@ jest.mock('@edx/frontend-platform/i18n', () => { const i18n = jest.requireActual('@edx/frontend-platform/i18n'); const PropTypes = jest.requireActual('prop-types'); const { formatMessage } = jest.requireActual('./testUtils'); - const formatDate = jest.fn().mockName('useIntl.formatDate'); + const formatDate = jest.fn(date => date).mockName('useIntl.formatDate'); return { ...i18n, intlShape: PropTypes.shape({ @@ -86,6 +86,7 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon Hyperlink: 'Hyperlink', Icon: 'Icon', IconButton: 'IconButton', + MailtoLink: 'MailtoLink', ModalDialog: { Header: 'ModalDialog.Header', Body: 'ModalDialog.Body', diff --git a/src/test/app.test.jsx b/src/test/app.test.jsx index 7c4f801..2d2f78a 100644 --- a/src/test/app.test.jsx +++ b/src/test/app.test.jsx @@ -80,7 +80,8 @@ const mockApi = () => { resolveFns.init = { success: () => resolve({ enrollments: fakeData.courseRunData, - entitlements: fakeData.entitlementCourses, + entitlements: fakeData.entitlementData, + ...fakeData.globalData, }), }; })); @@ -148,7 +149,6 @@ describe('ESG app integration tests', () => { ); cardDetails = inspector.get.card.details(card); - console.log({ enrollment: courseData.enrollment }); [ courseData.provider.name, courseNumber,