From 8edd4570b43e61f175d28d28b116fc8fe87a44e2 Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Thu, 21 Jul 2022 14:36:07 -0400 Subject: [PATCH] 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]]); - }); - }); - }); -};