From 00a972881f69d0067109a93958e0ef5dec545f4f Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 15 Jul 2024 16:22:10 -0400 Subject: [PATCH] feat: account for learner credit audit upgrade modal UI (#1110) --- .../hooks/useCanUpgradeWithLearnerCredit.js | 44 +- .../useCanUpgradeWithLearnerCredit.test.jsx | 53 +- src/components/app/data/queries/queries.js | 4 +- src/components/app/data/services/course.js | 2 +- .../app/data/services/course.test.js | 2 +- src/components/course/EnrollModal.jsx | 275 +++++++++-- .../course/tests/EnrollModal.test.jsx | 72 ++- .../course-enrollments/CourseSection.jsx | 19 +- .../course-cards/ContinueLearningButton.jsx | 9 +- .../course-cards/InProgressCourseCard.jsx | 50 +- .../course-cards/UpgradeCourseButton.jsx | 3 +- .../tests/InProgressCourseCard.test.jsx | 78 ++- .../course-enrollments/data/hooks.js | 135 +++--- .../data/tests/hooks.test.jsx | 459 +++++++++++------- 14 files changed, 802 insertions(+), 403 deletions(-) diff --git a/src/components/app/data/hooks/useCanUpgradeWithLearnerCredit.js b/src/components/app/data/hooks/useCanUpgradeWithLearnerCredit.js index bfeea496b3..ecddbd3272 100644 --- a/src/components/app/data/hooks/useCanUpgradeWithLearnerCredit.js +++ b/src/components/app/data/hooks/useCanUpgradeWithLearnerCredit.js @@ -3,12 +3,6 @@ import { useQuery } from '@tanstack/react-query'; import { queryCanUpgradeWithLearnerCredit } from '../queries'; import useEnterpriseCustomer from './useEnterpriseCustomer'; -export function isPolicyRedemptionEnabled({ canRedeemData }) { - const hasSuccessfulRedemption = canRedeemData?.hasSuccessfulRedemption; - const redeemableSubsidyAccessPolicy = canRedeemData?.redeemableSubsidyAccessPolicy; - return hasSuccessfulRedemption || !!redeemableSubsidyAccessPolicy; -} - /** * Determines whether the given courseRunKey is redeemable with their learner credit policies * Returns the first redeemableSubsidyAccessPolicy @@ -21,18 +15,34 @@ export default function useCanUpgradeWithLearnerCredit(courseRunKey, queryOption ...queryCanUpgradeWithLearnerCredit(enterpriseCustomer.uuid, courseRunKey), ...queryOptionsRest, select: (data) => { - if (data.length === 0) { - return { - applicableSubsidyAccessPolicy: null, - }; - } - return { - applicableSubsidyAccessPolicy: data.flatMap((canRedeemData) => ({ - ...canRedeemData, - listPrice: canRedeemData?.listPrice?.usd, - isPolicyRedemptionEnabled: isPolicyRedemptionEnabled({ canRedeemData }), - }))[0], + // Base transformed data + const transformedData = { + applicableSubsidyAccessPolicy: null, + listPrice: null, }; + + // Determine whether the course run key is redeemable. If so, update the transformed data with the + // applicable subsidy access policy and list price. + const redeemableCourseRun = data.filter((canRedeemData) => ( + canRedeemData.canRedeem && canRedeemData.redeemableSubsidyAccessPolicy + ))[0]; + if (redeemableCourseRun) { + const applicableSubsidyAccessPolicy = redeemableCourseRun.redeemableSubsidyAccessPolicy; + applicableSubsidyAccessPolicy.isPolicyRedemptionEnabled = true; + transformedData.applicableSubsidyAccessPolicy = applicableSubsidyAccessPolicy; + transformedData.listPrice = redeemableCourseRun.listPrice.usd; + } + + // When custom `select` function is provided in `queryOptions`, call it with original and transformed data. + if (select) { + return select({ + original: data, + transformed: transformedData, + }); + } + + // Otherwise, return the transformed data. + return transformedData; }, }); } diff --git a/src/components/app/data/hooks/useCanUpgradeWithLearnerCredit.test.jsx b/src/components/app/data/hooks/useCanUpgradeWithLearnerCredit.test.jsx index 9caba05ca6..e4aa1e8603 100644 --- a/src/components/app/data/hooks/useCanUpgradeWithLearnerCredit.test.jsx +++ b/src/components/app/data/hooks/useCanUpgradeWithLearnerCredit.test.jsx @@ -48,35 +48,50 @@ const mockCanRedeemData = [{ reasons: [], }]; +const Wrapper = ({ children }) => ( + + {children} + +); + describe('useCanUpgradeWithLearnerCredit', () => { - const Wrapper = ({ children }) => ( - - {children} - - ); beforeEach(() => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); fetchCanRedeem.mockResolvedValue(mockCanRedeemData); }); - it('should handle resolved value correctly', async () => { - const { - result, - waitForNextUpdate, - } = renderHook(() => useCanUpgradeWithLearnerCredit(mockCourseRunKey), { wrapper: Wrapper }); - await waitForNextUpdate(); + it.each([ + { hasCustomSelect: false }, + { hasCustomSelect: true }, + ])('should handle resolved value correctly (%s)', async ({ hasCustomSelect }) => { + const queryOptions = {}; + if (hasCustomSelect) { + // mock the custom select transform function to simply return the same transformed data + queryOptions.select = jest.fn(data => data.transformed); + } + const { result, waitForNextUpdate } = renderHook( + () => useCanUpgradeWithLearnerCredit(mockCourseRunKey, queryOptions), + { wrapper: Wrapper }, + ); + await waitForNextUpdate(); + const expectedTransformedResult = { + applicableSubsidyAccessPolicy: { + ...mockCanRedeemData[0].redeemableSubsidyAccessPolicy, + isPolicyRedemptionEnabled: true, + }, + listPrice: mockCanRedeemData[0].listPrice.usd, + }; + if (hasCustomSelect) { + expect(queryOptions.select).toHaveBeenCalledWith({ + original: mockCanRedeemData, + transformed: expectedTransformedResult, + }); + } expect(result.current).toEqual( expect.objectContaining({ - data: { - applicableSubsidyAccessPolicy: { - ...mockCanRedeemData[0], - listPrice: mockCanRedeemData[0].listPrice.usd, - isPolicyRedemptionEnabled: true, - }, - }, + data: expectedTransformedResult, isLoading: false, - isFetching: false, }), ); }); diff --git a/src/components/app/data/queries/queries.js b/src/components/app/data/queries/queries.js index 2e01769781..89f949cbfc 100644 --- a/src/components/app/data/queries/queries.js +++ b/src/components/app/data/queries/queries.js @@ -282,12 +282,12 @@ export function queryCanRedeem(enterpriseUuid, courseMetadata, isEnrollableBuffe * ._ctx.canRedeem(availableCourseRunKeys) * @returns {Types.QueryOptions} */ -export function queryCanUpgradeWithLearnerCredit(enterpriseUuid, courseRunKeys) { +export function queryCanUpgradeWithLearnerCredit(enterpriseUuid, courseRunKey) { return queries .enterprise .enterpriseCustomer(enterpriseUuid) ._ctx.course(null) - ._ctx.canRedeem(courseRunKeys); + ._ctx.canRedeem([courseRunKey]); } /** diff --git a/src/components/app/data/services/course.js b/src/components/app/data/services/course.js index bef62d6957..72bf5c6ef2 100644 --- a/src/components/app/data/services/course.js +++ b/src/components/app/data/services/course.js @@ -46,7 +46,7 @@ export async function fetchCourseMetadata(courseKey, courseRunKey) { } export async function fetchCourseRunMetadata(courseRunKey) { - const courseRunMetadataUrl = `${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_runs/${courseRunKey}`; + const courseRunMetadataUrl = `${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_runs/${courseRunKey}/`; try { const response = await getAuthenticatedHttpClient().get(courseRunMetadataUrl); return camelCaseObject(response.data); diff --git a/src/components/app/data/services/course.test.js b/src/components/app/data/services/course.test.js index 5aa96b8525..ad70295ca7 100644 --- a/src/components/app/data/services/course.test.js +++ b/src/components/app/data/services/course.test.js @@ -81,7 +81,7 @@ describe('fetchCourseMetadata', () => { }); describe('fetchCourseRunMetadata', () => { - const COURSE_RUN_METADATA = `${APP_CONFIG.DISCOVERY_API_BASE_URL}/api/v1/course_runs/${mockCourseRunKey}`; + const COURSE_RUN_METADATA = `${APP_CONFIG.DISCOVERY_API_BASE_URL}/api/v1/course_runs/${mockCourseRunKey}/`; const courseRunMetadata = { key: mockCourseRunKey, title: 'edX Demonstration Course', diff --git a/src/components/course/EnrollModal.jsx b/src/components/course/EnrollModal.jsx index ecab502224..ae533adc69 100644 --- a/src/components/course/EnrollModal.jsx +++ b/src/components/course/EnrollModal.jsx @@ -1,55 +1,206 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import PropTypes from 'prop-types'; -import { Button, Modal, Spinner } from '@openedx/paragon'; +import { + ActionRow, AlertModal, Button, Icon, Spinner, Stack, +} from '@openedx/paragon'; +import { Check } from '@openedx/paragon/icons'; +import { FormattedMessage, defineMessages, useIntl } from '@edx/frontend-platform/i18n'; +import { v4 as uuidv4 } from 'uuid'; import { ENTERPRISE_OFFER_TYPE } from '../enterprise-user-subsidy/enterprise-offers/data/constants'; -import { COUPON_CODE_SUBSIDY_TYPE, ENTERPRISE_OFFER_SUBSIDY_TYPE } from '../app/data'; +import { COUPON_CODE_SUBSIDY_TYPE, ENTERPRISE_OFFER_SUBSIDY_TYPE, LEARNER_CREDIT_SUBSIDY_TYPE } from '../app/data'; -export const createUseCouponCodeText = couponCodesCount => `You are about to redeem 1 enrollment code from your ${couponCodesCount} remaining codes.`; +export const messages = defineMessages({ + enrollModalConfirmCta: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.buttons.enroll.text', + defaultMessage: 'Enroll', + description: 'Text for the enroll button in the confirmation modal', + }, + upgradeModalConfirmCta: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.buttons.upgrade.text', + defaultMessage: 'Confirm upgrade', + description: 'Text for the upgrade button in the confirmation modal', + }, + modalCancelCta: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.buttons.cancel.text', + defaultMessage: 'Cancel', + description: 'Text for the cancel button in the confirmation modal', + }, + couponCodeModalTitle: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.titles.coupon-code', + defaultMessage: 'Use enrollment code for this course?', + description: 'Title for the confirmation modal when using a coupon code', + }, + enterpriseOfferModalTitle: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.titles.enterprise-offer', + defaultMessage: 'Use learner credit for this course?', // refers to *legacy* learner credit + description: 'Title for the confirmation modal when using an enterprise offer', + }, + learnerCreditModalTitle: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.titles.learner-credit', + defaultMessage: 'Upgrade for free', + description: 'Title for the confirmation modal when using a learner credit', + }, + couponCodesUsage: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.text.coupon-codes-usage', + defaultMessage: 'You are about to redeem an enrollment code from your {couponCodesCount} remaining codes.', + description: 'Text for the confirmation modal when using a coupon code', + }, + enterpriseOfferUsageWithPrice: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.text.enterprise-offer-usage.with-price', + defaultMessage: 'You are about to redeem {courseRunPrice} from your learner credit. This action cannot be reversed.', + description: 'Text for the confirmation modal when using an enterprise offer with a price', + }, + enterpriseOfferUsageWithoutPrice: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.text.enterprise-offer-usage.without-price', + defaultMessage: 'You are about to redeem your learner credit for this course. This action cannot be reversed.', + description: 'Text for the confirmation modal when using an enterprise offer with a limit', + }, + upgradeCoveredByOrg: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.text.upgrade-covered-by-org', + defaultMessage: 'This course is covered by your organization, which allows you to upgrade for free.', + description: 'Text for the confirmation modal when upgrading is covered by the organization', + }, + upgradeBenefitsPrefix: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.list-items.prefix', + defaultMessage: 'By upgrading, you will get:', + description: 'Prefix for the list of benefits of upgrading', + }, + upgradeBenefitsUnlimitedAccess: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.list-items.unlimited-access', + defaultMessage: 'Unlimited access to course materials', + description: 'List item for the benefits of upgrading, including unlimited access.', + }, + upgradeBenefitsFeedbackAndGradedAssignments: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.list-items.feedback-graded-assignments', + defaultMessage: 'Feedback and graded assignments', + description: 'List item for the benefits of upgrading, including feedback.', + }, + upgradeBenefitsShareableCertificate: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.list-items.shareable-certificate', + defaultMessage: 'Shareable certificate upon completion', + description: 'List item for the benefits of upgrading, including a shareable certificate.', + }, + confirmationCtaLoading: { + id: 'enterprise.learner_portal.enroll-upgrade-modal.buttons.loading', + defaultMessage: 'Loading...', + description: 'Text for the confirmation button in the modal when loading', + }, +}); -export const createUseEnterpriseOfferText = (offer, courseRunPrice) => { - if (offer.offerType === ENTERPRISE_OFFER_TYPE.ENROLLMENTS_LIMIT) { - return 'You are about to redeem 1 learner credit. This action cannot be reversed.'; +export const createUseEnterpriseOfferText = (offerType, courseRunPrice) => { + if (offerType !== ENTERPRISE_OFFER_TYPE.ENROLLMENTS_LIMIT && courseRunPrice) { + return messages.enterpriseOfferUsageWithPrice; } - return `You are about to redeem $${courseRunPrice} from your learner credit. This action cannot be reversed.`; + return messages.enterpriseOfferUsageWithoutPrice; +}; + +const UpgradeConfirmationModalListItem = ({ + icon, + children, +}) => ( +
  • + + + {children} + +
  • +); + +UpgradeConfirmationModalListItem.propTypes = { + icon: PropTypes.elementType, + children: PropTypes.node.isRequired, +}; +UpgradeConfirmationModalListItem.defaultProps = { + icon: Check, }; export const MODAL_TEXTS = { HAS_COUPON_CODE: { - body: (couponCodesCount) => createUseCouponCodeText(couponCodesCount), - button: 'Enroll', - title: 'Use 1 enrollment code for this course?', + body: messages.couponCodesUsage, + button: messages.enrollModalConfirmCta, + title: messages.couponCodeModalTitle, }, HAS_ENTERPRISE_OFFER: { - body: (offer, courseRunPrice) => createUseEnterpriseOfferText(offer, courseRunPrice), - button: 'Enroll', - title: 'Use learner credit for this course?', + body: createUseEnterpriseOfferText, + button: messages.enrollModalConfirmCta, + title: messages.enterpriseOfferModalTitle, + }, + HAS_LEARNER_CREDIT: { + Body: () => { + const listItems = [ + , + , + , + ]; + return ( + <> +

    +

    +
      + + {listItems.map((listItem) => ( + + {listItem} + + ))} + +
    + + ); + }, + // TODO: button text should be stateful to account for async loading + button: messages.upgradeModalConfirmCta, + title: messages.learnerCreditModalTitle, }, }; -const getModalTexts = ({ userSubsidyApplicableToCourse, couponCodesCount, courseRunPrice }) => { - const { HAS_COUPON_CODE, HAS_ENTERPRISE_OFFER } = MODAL_TEXTS; +const useModalTexts = ({ userSubsidyApplicableToCourse, couponCodesCount, courseRunPrice }) => { + const intl = useIntl(); + const { + HAS_COUPON_CODE, + HAS_ENTERPRISE_OFFER, + HAS_LEARNER_CREDIT, + } = MODAL_TEXTS; const { subsidyType } = userSubsidyApplicableToCourse || {}; if (subsidyType === COUPON_CODE_SUBSIDY_TYPE) { return { paymentRequiredForCourse: false, - buttonText: HAS_COUPON_CODE.button, - enrollText: HAS_COUPON_CODE.body(couponCodesCount), - titleText: HAS_COUPON_CODE.title, + buttonText: intl.formatMessage(HAS_COUPON_CODE.button), + enrollText: intl.formatMessage(HAS_COUPON_CODE.body, { couponCodesCount }), + titleText: intl.formatMessage(HAS_COUPON_CODE.title), }; } if (subsidyType === ENTERPRISE_OFFER_SUBSIDY_TYPE) { return { paymentRequiredForCourse: false, - buttonText: HAS_ENTERPRISE_OFFER.button, - enrollText: HAS_ENTERPRISE_OFFER.body(userSubsidyApplicableToCourse, courseRunPrice), - titleText: HAS_ENTERPRISE_OFFER.title, + buttonText: intl.formatMessage(HAS_ENTERPRISE_OFFER.button), + enrollText: intl.formatMessage( + HAS_ENTERPRISE_OFFER.body(userSubsidyApplicableToCourse.offerType, courseRunPrice), + { courseRunPrice: `$${courseRunPrice}` }, + ), + titleText: intl.formatMessage(HAS_ENTERPRISE_OFFER.title), }; } - return { paymentRequiredForCourse: true }; + if (subsidyType === LEARNER_CREDIT_SUBSIDY_TYPE) { + return { + paymentRequiredForCourse: false, + buttonText: intl.formatMessage(HAS_LEARNER_CREDIT.button), + enrollText: , + titleText: intl.formatMessage(HAS_LEARNER_CREDIT.title), + }; + } + + // Otherwise, given subsidy type is not supported for the enroll/upgrade modal + return { + paymentRequiredForCourse: true, + buttonText: null, + enrollText: null, + titleText: null, + }; }; const EnrollModal = ({ @@ -61,18 +212,33 @@ const EnrollModal = ({ couponCodesCount, onEnroll, }) => { + const intl = useIntl(); const [isLoading, setIsLoading] = useState(false); - const handleEnroll = (e) => { - setIsLoading(true); - if (onEnroll) { - onEnroll(e); + const handleEnroll = async () => { + if (!onEnroll) { + return; } + setIsLoading(true); + onEnroll(); + setIsLoading(false); + }; + + const dismissModal = () => { + setIsModalOpen(false); + setIsLoading(false); }; const { - paymentRequiredForCourse, titleText, enrollText, buttonText, - } = getModalTexts({ userSubsidyApplicableToCourse, couponCodesCount, courseRunPrice }); + paymentRequiredForCourse, + titleText, + enrollText, + buttonText, + } = useModalTexts({ + userSubsidyApplicableToCourse, + couponCodesCount, + courseRunPrice, + }); // Check whether the modal should be rendered (i.e., do not show modal if user has no applicable subsidy) // as payment would be required for the learner to enroll in the course. @@ -81,22 +247,41 @@ const EnrollModal = ({ } return ( -

    {enrollText}

    } - buttons={[ - , - ]} - onClose={() => setIsModalOpen(false)} - /> + footerNode={( + + + {/* FIXME: the following Button should be using StatefulButton from @openedx/paragon */} + + + )} + onClose={dismissModal} + > + {enrollText} + ); }; @@ -106,7 +291,7 @@ EnrollModal.propTypes = { enrollmentUrl: PropTypes.string.isRequired, userSubsidyApplicableToCourse: PropTypes.shape({ subsidyType: PropTypes.oneOf( - [COUPON_CODE_SUBSIDY_TYPE, ENTERPRISE_OFFER_SUBSIDY_TYPE], + [COUPON_CODE_SUBSIDY_TYPE, ENTERPRISE_OFFER_SUBSIDY_TYPE, LEARNER_CREDIT_SUBSIDY_TYPE], ), offerType: PropTypes.oneOf( Object.values(ENTERPRISE_OFFER_TYPE), diff --git a/src/components/course/tests/EnrollModal.test.jsx b/src/components/course/tests/EnrollModal.test.jsx index 1548ee6b04..258cb574a9 100644 --- a/src/components/course/tests/EnrollModal.test.jsx +++ b/src/components/course/tests/EnrollModal.test.jsx @@ -1,16 +1,24 @@ import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import '@testing-library/jest-dom/extend-expect'; import { screen, render } from '@testing-library/react'; - import userEvent from '@testing-library/user-event'; -import EnrollModal, { MODAL_TEXTS } from '../EnrollModal'; -import { COUPON_CODE_SUBSIDY_TYPE, ENTERPRISE_OFFER_SUBSIDY_TYPE } from '../../app/data'; + +import EnrollModal, { MODAL_TEXTS, messages } from '../EnrollModal'; +import { COUPON_CODE_SUBSIDY_TYPE, ENTERPRISE_OFFER_SUBSIDY_TYPE, LEARNER_CREDIT_SUBSIDY_TYPE } from '../../app/data'; +import { ENTERPRISE_OFFER_TYPE } from '../../enterprise-user-subsidy/enterprise-offers/data/constants'; jest.mock('../data/hooks', () => ({ useTrackSearchConversionClickHandler: jest.fn(), useOptimizelyEnrollmentClickHandler: jest.fn(), })); +const EnrollModalWrapper = (props) => ( + + + +); + describe('', () => { const basicProps = { isModalOpen: true, @@ -22,7 +30,7 @@ describe('', () => { }; it('does not render when user has no applicable subsidy', () => { - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); @@ -34,39 +42,59 @@ describe('', () => { }, couponCodesCount: 5, }; - render( - , - ); - expect(screen.getByText(MODAL_TEXTS.HAS_COUPON_CODE.title)).toBeInTheDocument(); - expect(screen.getByText(MODAL_TEXTS.HAS_COUPON_CODE.body(props.couponCodesCount))).toBeInTheDocument(); - expect(screen.getByText(MODAL_TEXTS.HAS_COUPON_CODE.button)).toBeInTheDocument(); + render(); + expect(screen.getByText(MODAL_TEXTS.HAS_COUPON_CODE.title.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(MODAL_TEXTS.HAS_COUPON_CODE.body.defaultMessage.replace('{couponCodesCount}', props.couponCodesCount))).toBeInTheDocument(); + expect(screen.getByText(MODAL_TEXTS.HAS_COUPON_CODE.button.defaultMessage)).toBeInTheDocument(); }); - it('displays the correct texts when there is an enterprise offer', () => { + it.each([ + { offerType: ENTERPRISE_OFFER_TYPE.ENROLLMENTS_LIMIT }, + { offerType: ENTERPRISE_OFFER_TYPE.NO_LIMIT }, + ])('displays the correct texts when there is an enterprise offer (%s)', ({ offerType }) => { const props = { ...basicProps, userSubsidyApplicableToCourse: { subsidyType: ENTERPRISE_OFFER_SUBSIDY_TYPE, + offerType, }, }; - render( - , - ); - expect(screen.getByText(MODAL_TEXTS.HAS_ENTERPRISE_OFFER.title)).toBeInTheDocument(); + render(); + expect(screen.getByText(MODAL_TEXTS.HAS_ENTERPRISE_OFFER.title.defaultMessage)).toBeInTheDocument(); expect( - screen.getByText(MODAL_TEXTS.HAS_ENTERPRISE_OFFER.body( - props.userSubsidyApplicableToCourse, - props.courseRunPrice, - )), + screen.getByText( + MODAL_TEXTS.HAS_ENTERPRISE_OFFER.body(offerType, props.courseRunPrice) + .defaultMessage + .replace('{courseRunPrice}', `$${props.courseRunPrice}`), + ), ).toBeInTheDocument(); - expect(screen.getByText(MODAL_TEXTS.HAS_ENTERPRISE_OFFER.button)).toBeInTheDocument(); + expect(screen.getByText(MODAL_TEXTS.HAS_ENTERPRISE_OFFER.button.defaultMessage)).toBeInTheDocument(); + }); + + it('displays the correct texts when there is learner credit available', () => { + const props = { + ...basicProps, + userSubsidyApplicableToCourse: { + subsidyType: LEARNER_CREDIT_SUBSIDY_TYPE, + }, + }; + render(); + expect(screen.getByText(MODAL_TEXTS.HAS_LEARNER_CREDIT.title.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.upgradeCoveredByOrg.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.upgradeBenefitsPrefix.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.upgradeBenefitsUnlimitedAccess.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.upgradeBenefitsShareableCertificate.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.upgradeBenefitsFeedbackAndGradedAssignments.defaultMessage)).toBeInTheDocument(); + + // Assert confirm upgrade CTA is present + expect(screen.getByRole('button', { name: messages.upgradeModalConfirmCta.defaultMessage })); }); it('calls onEnroll when enrollmentUrl is clicked', () => { const mockHandleEnroll = jest.fn(); render( - ', () => { couponCodesCount={5} />, ); - const enrollButton = screen.getByText(MODAL_TEXTS.HAS_COUPON_CODE.button); + const enrollButton = screen.getByText(MODAL_TEXTS.HAS_COUPON_CODE.button.defaultMessage); userEvent.click(enrollButton); expect(mockHandleEnroll).toHaveBeenCalled(); diff --git a/src/components/dashboard/main-content/course-enrollments/CourseSection.jsx b/src/components/dashboard/main-content/course-enrollments/CourseSection.jsx index 16656153f5..235cd2f23a 100644 --- a/src/components/dashboard/main-content/course-enrollments/CourseSection.jsx +++ b/src/components/dashboard/main-content/course-enrollments/CourseSection.jsx @@ -106,19 +106,16 @@ const CourseSection = ({ const isAuditOrHonorEnrollment = [COURSE_MODES_MAP.AUDIT, COURSE_MODES_MAP.HONOR].includes(courseRun.mode); if (isAuditOrHonorEnrollment && courseRun.courseRunStatus === COURSE_STATUSES.inProgress) { return ( - - <> +
    Loading...
    - - - - )} + + + )} > - +
    ); } diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/ContinueLearningButton.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/ContinueLearningButton.jsx index 75667a05a4..a7998cb6cf 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/ContinueLearningButton.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/ContinueLearningButton.jsx @@ -16,6 +16,7 @@ import { EXECUTIVE_EDUCATION_COURSE_MODES, useEnterpriseCustomer } from '../../. * @returns {Function} A functional React component for the continue learning button. */ const ContinueLearningButton = ({ + variant, className, linkToCourse, title, @@ -39,7 +40,7 @@ const ContinueLearningButton = ({ const isCourseStarted = () => dayjs(startDate) <= dayjs(); const isExecutiveEducation2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(mode); const disabled = !isCourseStarted() ? 'disabled' : undefined; - const variant = isExecutiveEducation2UCourse ? 'inverse-primary' : 'outline-primary'; + const defaultVariant = isExecutiveEducation2UCourse ? 'inverse-primary' : 'outline-primary'; const renderContent = () => { // resumeCourseRunUrl indicates that learner has made progress, available only if the learner has started learning. @@ -57,7 +58,7 @@ const ContinueLearningButton = ({ destination={linkToCourse} className={classNames('btn-xs-block', disabled, className)} onClick={onClickHandler} - variant={variant} + variant={variant || defaultVariant} > {renderContent()} for {title} @@ -66,7 +67,8 @@ const ContinueLearningButton = ({ }; ContinueLearningButton.defaultProps = { - className: null, + className: undefined, + variant: null, startDate: null, mode: null, resumeCourseRunUrl: null, @@ -74,6 +76,7 @@ ContinueLearningButton.defaultProps = { ContinueLearningButton.propTypes = { className: PropTypes.string, + variant: PropTypes.string, linkToCourse: PropTypes.string.isRequired, title: PropTypes.string.isRequired, courseRunId: PropTypes.string.isRequired, diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx index c38a1a8545..cb15d665df 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx @@ -1,9 +1,10 @@ import { useContext, useState } from 'react'; import PropTypes from 'prop-types'; -import { AppContext } from '@edx/frontend-platform/react'; import { useNavigate } from 'react-router-dom'; +import { AppContext } from '@edx/frontend-platform/react'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { FormattedMessage, defineMessages } from '@edx/frontend-platform/i18n'; +import { Stack } from '@openedx/paragon'; import dayjs from '../../../../../utils/dayjs'; import BaseCourseCard, { getScreenReaderText } from './BaseCourseCard'; @@ -13,7 +14,7 @@ import ContinueLearningButton from './ContinueLearningButton'; import Notification from './Notification'; import UpgradeCourseButton from './UpgradeCourseButton'; -import { useEnterpriseCustomer } from '../../../../app/data'; +import { LICENSE_SUBSIDY_TYPE, useEnterpriseCustomer } from '../../../../app/data'; import { useCourseUpgradeData, useUpdateCourseEnrollmentStatus } from '../data'; import { COURSE_STATUSES } from '../../../../../constants'; @@ -25,6 +26,20 @@ const messages = defineMessages({ }, }); +function useLinkToCourse({ + linkToCourse, + subsidyForCourse, +}) { + let url = linkToCourse; + // For subscription upgrades, there is no upgrade confirmation required by the user + // so we can directly redirect the user to the upgrade path when the `subsidyForCourse` + // is a subscription license. + if (subsidyForCourse?.subsidyType === LICENSE_SUBSIDY_TYPE) { + url = subsidyForCourse.redemptionUrl; + } + return url; +} + export const InProgressCourseCard = ({ linkToCourse, courseRunId, @@ -36,33 +51,40 @@ export const InProgressCourseCard = ({ mode, ...rest }) => { - // TODO: Destructure learnerCreditUpgradeUrl field here + const navigate = useNavigate(); const { - licenseUpgradeUrl, - couponUpgradeUrl, + subsidyForCourse, + hasUpgradeAndConfirm, } = useCourseUpgradeData({ courseRunKey: courseRunId, mode }); - const navigate = useNavigate(); - // The upgrade button is only for upgrading via coupon, upgrades via license are automatic through the course link. - const shouldShowUpgradeButton = !!couponUpgradeUrl; const [isMarkCompleteModalOpen, setIsMarkCompleteModalOpen] = useState(false); const { courseCards } = useContext(AppContext); const { data: enterpriseCustomer } = useEnterpriseCustomer(); const updateCourseEnrollmentStatus = useUpdateCourseEnrollmentStatus({ enterpriseCustomer }); + const coursewareOrUpgradeLink = useLinkToCourse({ + linkToCourse, + subsidyForCourse, + }); const renderButtons = () => ( - <> + - {shouldShowUpgradeButton && } - + {hasUpgradeAndConfirm && ( + + )} + ); const filteredNotifications = notifications.filter((notification) => { @@ -166,7 +188,7 @@ export const InProgressCourseCard = ({ buttons={renderButtons()} dropdownMenuItems={getDropdownMenuItems()} title={title} - linkToCourse={licenseUpgradeUrl ?? linkToCourse} + linkToCourse={coursewareOrUpgradeLink} courseRunId={courseRunId} mode={mode} startDate={startDate} diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/UpgradeCourseButton.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/UpgradeCourseButton.jsx index 807b84d3aa..de79623424 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/UpgradeCourseButton.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/UpgradeCourseButton.jsx @@ -23,7 +23,6 @@ const UpgradeCourseButton = ({ const { data: { couponCodeRedemptionCount } } = useCouponCodes(); const { subsidyForCourse, - couponUpgradeUrl, courseRunPrice, } = useCourseUpgradeData({ courseRunKey, mode }); @@ -56,7 +55,7 @@ const UpgradeCourseButton = ({ ', () => { beforeEach(() => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); - useCouponCodes.mockReturnValue({ - data: { - couponCodeAssignments: [], - }, - }); useCourseUpgradeData.mockReturnValue({ - licenseUpgradeUrl: undefined, - couponUpgradeUrl: undefined, - learnerCreditUpgradeUrl: undefined, - subsidyForCourse: undefined, - courseRunPrice: undefined, + subsidyForCourse: null, + courseRunPrice: null, + hasUpgradeAndConfirm: false, }); }); - it('should not render upgrade course button if there is no couponUpgradeUrl', () => { + it('should not render upgrade course button when hasUpgradeAndConfirm=false (no subsidy returned)', () => { + renderWithRouter(); + expect(screen.queryByTestId('upgrade-course-button')).not.toBeInTheDocument(); + }); + + it('should not render upgrade course button when hasUpgradeAndConfirm=false (subscription license)', () => { + useCourseUpgradeData.mockReturnValue({ + courseRunPrice: null, + subsidyForCourse: { + subsidyType: LICENSE_SUBSIDY_TYPE, + redemptionUrl: 'https://redemption.url', + }, + hasUpgradeAndConfirm: false, + }); renderWithRouter(); expect(screen.queryByTestId('upgrade-course-button')).not.toBeInTheDocument(); }); - it('should render upgrade course button if there is a couponUpgradeUrl', () => { + it('should render upgrade course button when hasUpgradeAndConfirm=true (coupon codes)', () => { + useCouponCodes.mockReturnValue({ + data: { + couponCodeAssignments: [{ + code: 'abc123', + }], + couponCodeRedemptionCount: 1, + }, + }); useCourseUpgradeData.mockReturnValue({ - licenseUpgradeUrl: undefined, - couponUpgradeUrl: 'coupon-upgrade-url', courseRunPrice: 100, - learnerCreditUpgradeUrl: undefined, - subsidyForCourse: undefined, + subsidyForCourse: { + subsidyType: COUPON_CODE_SUBSIDY_TYPE, + redemptionUrl: 'https://redemption.url', + }, + hasUpgradeAndConfirm: true, }); - renderWithRouter(); + renderWithRouter(); + expect(screen.getByTestId('upgrade-course-button')).toBeInTheDocument(); + }); + it('should render upgrade course button when hasUpgradeAndConfirm=true (learner credit)', () => { + useCourseUpgradeData.mockReturnValue({ + courseRunPrice: 100, + subsidyForCourse: { + subsidyType: LEARNER_CREDIT_SUBSIDY_TYPE, + redemptionUrl: 'https://redemption.url', + }, + hasUpgradeAndConfirm: true, + }); + renderWithRouter(); expect(screen.getByTestId('upgrade-course-button')).toBeInTheDocument(); }); }); diff --git a/src/components/dashboard/main-content/course-enrollments/data/hooks.js b/src/components/dashboard/main-content/course-enrollments/data/hooks.js index 18c5374bb8..adb7bbaf0d 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/hooks.js +++ b/src/components/dashboard/main-content/course-enrollments/data/hooks.js @@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AppContext } from '@edx/frontend-platform/react'; import { camelCaseObject } from '@edx/frontend-platform/utils'; import { logError } from '@edx/frontend-platform/logging'; +import { hasFeatureFlagEnabled } from '@edx/frontend-enterprise-utils'; import _camelCase from 'lodash.camelcase'; import _cloneDeep from 'lodash.clonedeep'; @@ -20,7 +21,10 @@ import { import { getExpiringAssignmentsAcknowledgementState, getHasUnacknowledgedAssignments } from '../../../data/utils'; import { ASSIGNMENT_TYPES } from '../../../../enterprise-user-subsidy/enterprise-offers/data/constants'; import { + COUPON_CODE_SUBSIDY_TYPE, COURSE_MODES_MAP, + LEARNER_CREDIT_SUBSIDY_TYPE, + LICENSE_SUBSIDY_TYPE, getSubsidyToApplyForCourse, groupCourseEnrollmentsByStatus, queryEnterpriseCourseEnrollments, @@ -37,6 +41,7 @@ import { } from '../../../../app/data'; import { sortAssignmentsByAssignmentStatus, sortedEnrollmentsByEnrollmentDate } from './utils'; import { ASSIGNMENTS_EXPIRING_WARNING_LOCALSTORAGE_KEY } from '../../../data/constants'; +import { LICENSE_STATUS } from '../../../../enterprise-user-subsidy/data/constants'; export const useCourseEnrollments = ({ enterpriseUUID, @@ -125,114 +130,132 @@ export const useCourseEnrollments = ({ * @param {String} args.mode The mode of the course. Used as a gating mechanism for upgradability * default: false * @returns {Object} { - * licenseUpgradeUrl: undefined, - * couponUpgradeUrl: undefined, - * learnerCreditUpgradeUrl: undefined, * subsidyForCourse: undefined, * courseRunPrice: undefined, - * } + * hasUpgradeAndConfirm: false, + * } */ export const useCourseUpgradeData = ({ courseRunKey, mode, }) => { const location = useLocation(); - // We determine whether the course mode is such that it can be upgraded + // Determine whether the course mode is such that it can be upgraded const canUpgradeToVerifiedEnrollment = [COURSE_MODES_MAP.AUDIT, COURSE_MODES_MAP.HONOR].includes(mode); const { authenticatedUser } = useContext(AppContext); const { data: enterpriseCustomer } = useEnterpriseCustomer(); - const { data: customerContainsContent } = useEnterpriseCustomerContainsContent([courseRunKey]); + const { data: customerContainsContent } = useEnterpriseCustomerContainsContent([courseRunKey], { + enabled: canUpgradeToVerifiedEnrollment, + }); - // TODO: Remove authenticatedUser?.administrator flag when rolling out ENT-9135 + // TODO: Remove `isLearnerCreditUpgradeEnabled` flag when rolling out ENT-9135 // Metadata required to allow upgrade via applicable learner credit - const { data: learnerCreditMetadata } = useCanUpgradeWithLearnerCredit( - [courseRunKey], - { enabled: authenticatedUser?.administrator && canUpgradeToVerifiedEnrollment }, - ); + const isLearnerCreditUpgradeEnabled = authenticatedUser.administrator || hasFeatureFlagEnabled('LEARNER_CREDIT_AUDIT_UPGRADE'); + const { data: learnerCreditMetadata } = useCanUpgradeWithLearnerCredit(courseRunKey, { + enabled: isLearnerCreditUpgradeEnabled && canUpgradeToVerifiedEnrollment, + }); // Metadata required to allow upgrade via applicable subscription license - const { data: { subscriptionLicense: applicableSubscriptionLicense } } = useSubscriptions( - { enabled: customerContainsContent?.containsContentItems && canUpgradeToVerifiedEnrollment }, - ); + const { data: subscriptionLicense } = useSubscriptions({ + select: (data) => { + const license = data?.subscriptionLicense; + const isLicenseActivated = !!(license?.status === LICENSE_STATUS.ACTIVATED); + const isSubscriptionPlanCurrent = !!license?.subscriptionPlan.isCurrent; + if (!isLicenseActivated || !isSubscriptionPlanCurrent) { + return null; + } + return license; + }, + enabled: !!customerContainsContent?.containsContentItems && canUpgradeToVerifiedEnrollment, + }); - // Metadata required to allow upgrade via applicable coupon codes - const { data: couponCodesMetadata } = useCouponCodes({ - select: (data) => ({ - applicableCouponCode: findCouponCodeForCourse(data.couponCodeAssignments, customerContainsContent?.catalogList), - }), - enabled: canUpgradeToVerifiedEnrollment, + // Metadata required to allow upgrade via applicable coupon code + const { data: applicableCouponCode } = useCouponCodes({ + select: (data) => findCouponCodeForCourse(data.couponCodeAssignments, customerContainsContent?.catalogList), + enabled: !!customerContainsContent?.containsContentItems && canUpgradeToVerifiedEnrollment, }); - // If coupon codes are not eligible, there is no need to make this call + + // If coupon codes are eligible, retrieve the course run's product SKU metadata from API const { data: courseRunDetails } = useCourseRunMetadata(courseRunKey, { select: (data) => ({ ...data, sku: findHighestLevelSeatSku(data.seats), - code: data.code, }), - enabled: !couponCodesMetadata.applicableCouponCode && canUpgradeToVerifiedEnrollment, + enabled: !!applicableCouponCode && canUpgradeToVerifiedEnrollment, }); return useMemo(() => { const defaultReturn = { - licenseUpgradeUrl: undefined, - couponUpgradeUrl: undefined, - learnerCreditUpgradeUrl: undefined, - subsidyForCourse: undefined, - courseRunPrice: undefined, + subsidyForCourse: null, + courseRunPrice: null, + hasUpgradeAndConfirm: false, }; - // Exit early if the content to upgrade is not contained in the customers content or - // if they are unable to upgrade due to their course mode - if (!customerContainsContent?.containsContentItems || !canUpgradeToVerifiedEnrollment) { + // Return early if the user is unable to upgrade to their course mode OR the content + // to upgrade is not contained in the customer's content catalog(s). + if (!canUpgradeToVerifiedEnrollment || !customerContainsContent?.containsContentItems) { + return defaultReturn; + } + + // Determine applicable subsidy, if any, based on priority order of subsidy types. + const applicableSubsidy = getSubsidyToApplyForCourse({ + applicableSubscriptionLicense: subscriptionLicense, + applicableCouponCode, + applicableSubsidyAccessPolicy: learnerCreditMetadata?.applicableSubsidyAccessPolicy, + }); + + // No applicable subsidy found, return early. + if (!applicableSubsidy) { return defaultReturn; } // Construct and return subscription based upgrade url - if (applicableSubscriptionLicense) { + if (applicableSubsidy.subsidyType === LICENSE_SUBSIDY_TYPE) { + applicableSubsidy.redemptionUrl = createEnrollWithLicenseUrl({ + courseRunKey, + enterpriseId: enterpriseCustomer.uuid, + licenseUUID: subscriptionLicense.uuid, + location, + }); return { ...defaultReturn, - subsidyForCourse: getSubsidyToApplyForCourse({ applicableSubscriptionLicense }), - licenseUpgradeUrl: createEnrollWithLicenseUrl({ - courseRunKey, - enterpriseId: enterpriseCustomer.uuid, - licenseUUID: applicableSubscriptionLicense.uuid, - location, - }), + subsidyForCourse: applicableSubsidy, }; } // Construct and return coupon code based upgrade url - if (couponCodesMetadata?.applicableCouponCode) { - const { applicableCouponCode } = couponCodesMetadata; + if (applicableSubsidy.subsidyType === COUPON_CODE_SUBSIDY_TYPE) { + applicableSubsidy.redemptionUrl = createEnrollWithCouponCodeUrl({ + courseRunKey, + sku: courseRunDetails?.sku, + code: applicableSubsidy.code, + location, + }); return { ...defaultReturn, - subsidyForCourse: getSubsidyToApplyForCourse({ applicableCouponCode }), - couponUpgradeUrl: createEnrollWithCouponCodeUrl({ - courseRunKey, - sku: courseRunDetails.sku, - code: applicableCouponCode.code, - location, - }), - courseRunPrice: courseRunDetails.firstEnrollablePaidSeatPrice, + subsidyForCourse: applicableSubsidy, + courseRunPrice: courseRunDetails?.firstEnrollablePaidSeatPrice, + hasUpgradeAndConfirm: true, }; } // Construct and return learner credit based upgrade url - if (learnerCreditMetadata?.applicableSubsidyAccessPolicy?.canRedeem) { - const { applicableSubsidyAccessPolicy } = learnerCreditMetadata; + if (applicableSubsidy.subsidyType === LEARNER_CREDIT_SUBSIDY_TYPE) { + applicableSubsidy.redemptionUrl = learnerCreditMetadata.applicableSubsidyAccessPolicy.policyRedemptionUrl; return { ...defaultReturn, - subsidyForCourse: getSubsidyToApplyForCourse({ applicableSubsidyAccessPolicy }), - learnerCreditUpgradeUrl: applicableSubsidyAccessPolicy.redeemableSubsidyAccessPolicy?.policyRedemptionUrl, + subsidyForCourse: applicableSubsidy, + courseRunPrice: learnerCreditMetadata.listPrice, + hasUpgradeAndConfirm: true, }; } - // If none is applicable, return with defaultReturn values + // If no subsidy type is applicable, return with default values return defaultReturn; }, [ - applicableSubscriptionLicense, + subscriptionLicense, canUpgradeToVerifiedEnrollment, - couponCodesMetadata, + applicableCouponCode, courseRunDetails?.firstEnrollablePaidSeatPrice, courseRunDetails?.sku, courseRunKey, diff --git a/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx b/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx index cf6424ec36..cc0fc0d1ee 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx +++ b/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx @@ -29,7 +29,8 @@ import { emptyRedeemableLearnerCreditPolicies, transformCourseEnrollment, transformLearnerContentAssignment, - useCanUpgradeWithLearnerCredit, useCouponCodes, + useCanUpgradeWithLearnerCredit, + useCouponCodes, useEnterpriseCourseEnrollments, useEnterpriseCustomer, useEnterpriseCustomerContainsContent, @@ -39,6 +40,7 @@ import { } from '../../../../../app/data'; import { authenticatedUserFactory, enterpriseCustomerFactory } from '../../../../../app/data/services/data/__factories__'; import { ASSIGNMENTS_EXPIRING_WARNING_LOCALSTORAGE_KEY } from '../../../../data/constants'; +import { LICENSE_STATUS } from '../../../../../enterprise-user-subsidy/data/constants'; jest.mock('../service'); jest.mock('@edx/frontend-platform/logging', () => ({ @@ -174,211 +176,300 @@ describe('useCourseEnrollments', () => { ); }); }); +}); - describe('useCourseUpgradeData', () => { - const courseRunKey = 'course-run-key'; - const enterpriseId = mockEnterpriseCustomer.uuid; - const subscriptionLicense = { uuid: 'license-uuid' }; - const location = { pathname: '/', search: '' }; - const basicArgs = { - courseRunKey, - }; - beforeEach(() => { - jest.clearAllMocks(); - useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); - useSubscriptions.mockReturnValue({ - data: { subscriptionLicense: null }, - }); - useCanUpgradeWithLearnerCredit.mockReturnValue({ - data: { applicableSubsidyAccessPolicy: null }, - }); +describe('useCourseUpgradeData', () => { + const courseRunKey = 'course-run-key'; + const enterpriseId = mockEnterpriseCustomer.uuid; + const subscriptionLicense = { + uuid: 'license-uuid', + status: LICENSE_STATUS.ACTIVATED, + subscriptionPlan: { + uuid: 'subscription-plan-uuid', + startDate: dayjs().subtract(10, 'days').toISOString(), + endDate: dayjs().add(10, 'days').toISOString(), + isCurrent: true, + }, + }; + const location = { pathname: '/', search: '' }; + const basicArgs = { + courseRunKey, + }; + + beforeEach(() => { + jest.clearAllMocks(); + useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); + useSubscriptions.mockReturnValue({ data: null }); + useCanUpgradeWithLearnerCredit.mockReturnValue({ + data: { + applicableSubsidyAccessPolicy: null, + listPrice: null, + }, + }); + useEnterpriseCustomerContainsContent.mockReturnValue({ + data: { + containsContentItems: false, + catalogList: [], + }, + }); + useCouponCodes.mockReturnValue({ data: null }); + useCourseRunMetadata.mockReturnValue({ data: null }); + }); + + it.each([ + true, + false, + ])("should return null for upgrade urls if the course isn't contained by the subsidies' catalogs (%s)", (containsContentItems) => { + useEnterpriseCustomerContainsContent.mockReturnValue({ + data: { + containsContentItems, + catalogList: [], + }, + }); + const { result } = renderHook(() => useCourseUpgradeData(basicArgs), { wrapper }); + expect(result.current).toEqual( + expect.objectContaining({ + courseRunPrice: null, + subsidyForCourse: null, + hasUpgradeAndConfirm: false, + }), + ); + }); + + describe('upgrade via license', () => { + it.each([ + { + subscriptionLicenseStatus: LICENSE_STATUS.ACTIVATED, + isSubscriptionPlanCurrent: true, + }, + { + subscriptionLicenseStatus: LICENSE_STATUS.ACTIVATED, + isSubscriptionPlanCurrent: false, + }, + { + subscriptionLicenseStatus: LICENSE_STATUS.REVOKED, + isSubscriptionPlanCurrent: true, + }, + ])('should return a license upgrade url (%s)', ({ + subscriptionLicenseStatus, + isSubscriptionPlanCurrent, + }) => { + const mockSubscriptionLicense = { + ...subscriptionLicense, + status: subscriptionLicenseStatus, + subscriptionPlan: { + ...subscriptionLicense.subscriptionPlan, + isCurrent: isSubscriptionPlanCurrent, + }, + }; useEnterpriseCustomerContainsContent.mockReturnValue({ data: { - containsContentItems: false, + containsContentItems: true, catalogList: [], }, }); - useCouponCodes.mockReturnValue({ - data: { - applicableCouponCode: null, - }, - }); - useCourseRunMetadata.mockReturnValue({ - data: null, - }); + useSubscriptions.mockReturnValue({ data: mockSubscriptionLicense }); + + const { result } = renderHook(() => useCourseUpgradeData({ + ...basicArgs, + mode: COURSE_MODES_MAP.AUDIT, + }), { wrapper }); + + // Assert the custom `select` transform function was passed and works as expected + expect(useSubscriptions).toHaveBeenCalledWith( + expect.objectContaining({ + select: expect.any(Function), + enabled: true, + }), + ); + const useSubscriptionsSelectFn = useSubscriptions.mock.calls[0][0].select; + const selectTransformResult = useSubscriptionsSelectFn({ subscriptionLicense: mockSubscriptionLicense }); + if (subscriptionLicenseStatus === LICENSE_STATUS.ACTIVATED && isSubscriptionPlanCurrent) { + expect(selectTransformResult).toEqual(mockSubscriptionLicense); + } else { + expect(selectTransformResult).toBeNull(); + } + + // Assert expected output + expect(result.current).toEqual( + expect.objectContaining({ + subsidyForCourse: expect.objectContaining({ + redemptionUrl: createEnrollWithLicenseUrl({ + courseRunKey, + enterpriseId, + licenseUUID: mockSubscriptionLicense.uuid, + location, + }), + }), + hasUpgradeAndConfirm: false, + }), + ); }); + }); - it.each([ - true, - false])('should return undefined for upgrade urls if the course is and isn\'t part of the subsidies but no subsides exist', (containsContentItems) => { + describe('upgrade via coupon', () => { + const mockCouponCode = { + code: 'coupon-code', + catalog: 'catalog-1', + couponStartDate: dayjs().subtract(1, 'w').toISOString(), + couponEndDate: dayjs().add(8, 'w').toISOString(), + }; + + it('should return a coupon upgrade url', async () => { useEnterpriseCustomerContainsContent.mockReturnValue({ data: { - containsContentItems, - catalogList: [], + containsContentItems: true, + catalogList: [mockCouponCode.catalog], }, }); - - const { result } = renderHook(() => useCourseUpgradeData(basicArgs), { wrapper }); - - expect(result.current.licenseUpgradeUrl).toBeUndefined(); - expect(result.current.couponUpgradeUrl).toBeUndefined(); - expect(result.current.courseRunPrice).toBeUndefined(); - expect(result.current.learnerCreditUpgradeUrl).toBeUndefined(); - }); - - describe('upgradeable via license', () => { - it('should return a license upgrade url', () => { - useEnterpriseCustomerContainsContent.mockReturnValue({ - data: { - containsContentItems: true, - catalogList: [], - }, - }); - - useSubscriptions.mockReturnValue({ - data: { - subscriptionLicense: { - uuid: 'license-uuid', - subscriptionPlan: { - startDate: dayjs().subtract(10, 'days').toISOString(), - expirationDate: dayjs().add(10, 'days').toISOString(), - }, - status: 'activated', + useCouponCodes.mockReturnValue({ data: mockCouponCode }); + const sku = 'ABCDEF'; + const coursePrice = '149.00'; + useCourseRunMetadata.mockReturnValue({ + data: { + firstEnrollablePaidSeatPrice: coursePrice, + sku: findHighestLevelSeatSku([ + { + type: COURSE_MODES_MAP.VERIFIED, + price: coursePrice, + sku, }, - }, - }); - - const { result } = renderHook(() => useCourseUpgradeData({ - ...basicArgs, - mode: COURSE_MODES_MAP.AUDIT, - }), { wrapper }); - - expect(result.current.licenseUpgradeUrl).toEqual(createEnrollWithLicenseUrl({ - courseRunKey, - enterpriseId, - licenseUUID: subscriptionLicense.uuid, - location, - })); - expect(result.current.learnerCreditUpgradeUrl).toBeUndefined(); - expect(result.current.couponUpgradeUrl).toBeUndefined(); - expect(result.current.courseRunPrice).toBeUndefined(); + { + type: COURSE_MODES_MAP.AUDIT, + price: '0.00', + sku: 'abcdef', + }, + ]), + }, }); - }); - describe('upgradeable via coupon', () => { - const mockCouponCode = { - code: 'coupon-code', - catalog: 'catalog-1', - couponStartDate: dayjs().subtract(1, 'w').toISOString(), - couponEndDate: dayjs().add(8, 'w').toISOString(), + const { result } = renderHook(() => useCourseUpgradeData({ + ...basicArgs, + mode: COURSE_MODES_MAP.AUDIT, + }), { wrapper }); + + // Assert the custom `select` transform function was passed to useCouponCodes and works as expected + expect(useCouponCodes).toHaveBeenCalledWith( + expect.objectContaining({ + select: expect.any(Function), + enabled: true, + }), + ); + const useCouponCodesSelectFn = useCouponCodes.mock.calls[0][0].select; + const couponCodesSelectTransformResult = useCouponCodesSelectFn({ couponCodeAssignments: [mockCouponCode] }); + expect(couponCodesSelectTransformResult).toEqual(mockCouponCode); + + // Assert the custom `select` transform function was passed to useCourseRunMetadata and works as expected + expect(useCourseRunMetadata).toHaveBeenCalledWith( + courseRunKey, + expect.objectContaining({ + select: expect.any(Function), + enabled: true, + }), + ); + const useCourseRunMetadataSelectFn = useCourseRunMetadata.mock.calls[0][1].select; + const mockSKU = 'ABCDEF'; + const mockCourseRun = { + key: courseRunKey, + seats: [{ + type: COURSE_MODES_MAP.VERIFIED, + sku: mockSKU, + }], }; + const courseRunMetadataSelectTransformResult = useCourseRunMetadataSelectFn(mockCourseRun); + expect(courseRunMetadataSelectTransformResult).toEqual( + expect.objectContaining({ + ...mockCourseRun, + sku: mockSKU, + }), + ); - it('should return a coupon upgrade url', async () => { - useEnterpriseCustomerContainsContent.mockReturnValue({ - data: { - containsContentItems: true, - catalogList: [mockCouponCode.catalog], - }, - }); - useCouponCodes.mockReturnValue({ - data: { applicableCouponCode: mockCouponCode }, - }); - const sku = 'ABCDEF'; - const coursePrice = '149.00'; - useCourseRunMetadata.mockReturnValue({ - data: { - firstEnrollablePaidSeatPrice: coursePrice, - sku: findHighestLevelSeatSku([ - { - type: COURSE_MODES_MAP.VERIFIED, - price: coursePrice, - sku, - }, - { - type: COURSE_MODES_MAP.AUDIT, - price: '0.00', - sku: 'abcdef', - }, - ]), - }, - }); - - const { result } = renderHook(() => useCourseUpgradeData({ - ...basicArgs, - mode: COURSE_MODES_MAP.AUDIT, - }), { wrapper }); - - expect(result.current.licenseUpgradeUrl).toBeUndefined(); - expect(result.current.couponUpgradeUrl).toEqual(createEnrollWithCouponCodeUrl({ - courseRunKey, - sku, - code: mockCouponCode.code, - location, - })); - expect(result.current.learnerCreditUpgradeUrl).toBeUndefined(); - expect(result.current.courseRunPrice).toEqual(coursePrice); - }); + // Assert expected output + expect(result.current).toEqual( + expect.objectContaining({ + subsidyForCourse: expect.objectContaining({ + redemptionUrl: createEnrollWithCouponCodeUrl({ + courseRunKey, + sku, + code: mockCouponCode.code, + location, + }), + }), + courseRunPrice: coursePrice, + hasUpgradeAndConfirm: true, + }), + ); }); + }); - describe('upgrade via learner credit', () => { - const mockCourseRunKey = 'course-v1:edX+DemoX+T2024'; - const mockCanUpgradeWithLearnerCredit = { - contentKey: mockCourseRunKey, - listPrice: 1, - redemptions: [], - hasSuccessfulRedemption: false, - redeemableSubsidyAccessPolicy: { - uuid: 'test-access-policy-uuid', - policyRedemptionUrl: 'https://enterprise-access.stage.edx.org/api/v1/policy-redemption/8c4a92c7-3578-407d-9ba1-9127c4e4cc0b/redeem/', - isLateRedemptionAllowed: false, - policyType: 'PerLearnerSpendCreditAccessPolicy', - enterpriseCustomerUuid: mockEnterpriseCustomer.uuid, - displayName: 'Learner driven plan --- Open Courses', - description: 'Initial Policy Display Name: Learner driven plan --- Open Courses, Initial Policy Value: $10,000, Initial Subsidy Value: $260,000', - active: true, - retired: false, - catalogUuid: 'test-catalog-uuid', - subsidyUuid: 'test-subsidy-uuid', - accessMethod: 'direct', - spendLimit: 1000000, - lateRedemptionAllowedUntil: null, - perLearnerEnrollmentLimit: null, - perLearnerSpendLimit: null, - assignmentConfiguration: null, + describe('upgrade via learner credit', () => { + const mockCourseRunKey = 'course-v1:edX+DemoX+T2024'; + const mockRedemptionUrl = 'https://enterprise-access.stage.edx.org/api/v1/policy-redemption/8c4a92c7-3578-407d-9ba1-9127c4e4cc0b/redeem/'; + const mockCanUpgradeWithLearnerCredit = { + contentKey: mockCourseRunKey, + listPrice: { + usd: 1, + usd_cents: 100, + }, + redemptions: [], + hasSuccessfulRedemption: false, + redeemableSubsidyAccessPolicy: { + uuid: 'test-access-policy-uuid', + policyRedemptionUrl: mockRedemptionUrl, + isLateRedemptionAllowed: false, + policyType: 'PerLearnerSpendCreditAccessPolicy', + enterpriseCustomerUuid: mockEnterpriseCustomer.uuid, + displayName: 'Learner driven plan --- Open Courses', + description: 'Initial Policy Display Name: Learner driven plan --- Open Courses, Initial Policy Value: $10,000, Initial Subsidy Value: $260,000', + active: true, + retired: false, + catalogUuid: 'test-catalog-uuid', + subsidyUuid: 'test-subsidy-uuid', + accessMethod: 'direct', + spendLimit: 1000000, + lateRedemptionAllowedUntil: null, + perLearnerEnrollmentLimit: null, + perLearnerSpendLimit: null, + assignmentConfiguration: null, + }, + canRedeem: true, + reasons: [], + isPolicyRedemptionEnabled: true, + policyRedemptionUrl: mockRedemptionUrl, + }; + beforeEach(() => { + jest.clearAllMocks(); + useCanUpgradeWithLearnerCredit.mockReturnValue({ + data: { + applicableSubsidyAccessPolicy: { + ...mockCanUpgradeWithLearnerCredit.redeemableSubsidyAccessPolicy, + isPolicyRedemptionEnabled: true, + }, + listPrice: mockCanUpgradeWithLearnerCredit.listPrice.usd, }, - canRedeem: true, - reasons: [], - isPolicyRedemptionEnabled: true, - }; - beforeEach(() => { - jest.clearAllMocks(); - useCanUpgradeWithLearnerCredit.mockReturnValue({ - data: { applicableSubsidyAccessPolicy: mockCanUpgradeWithLearnerCredit }, - }); }); - it('should return a learner credit upgrade url', async () => { - useEnterpriseCustomerContainsContent.mockReturnValue({ - data: { - containsContentItems: true, - catalogList: [], - }, - }); - useCouponCodes.mockReturnValue({ - data: { applicableCouponCode: null }, - }); - - const { result } = renderHook(() => useCourseUpgradeData({ - ...basicArgs, - mode: COURSE_MODES_MAP.AUDIT, - }), { wrapper }); - - expect(result.current.licenseUpgradeUrl).toBeUndefined(); - expect(result.current.couponUpgradeUrl).toBeUndefined(); - expect(result.current.learnerCreditUpgradeUrl).toEqual( - mockCanUpgradeWithLearnerCredit.redeemableSubsidyAccessPolicy.policyRedemptionUrl, - ); - expect(result.current.courseRunPrice).toBeUndefined(); + }); + it('should return a learner credit upgrade url', async () => { + useEnterpriseCustomerContainsContent.mockReturnValue({ + data: { + containsContentItems: true, + catalogList: [mockCanUpgradeWithLearnerCredit.redeemableSubsidyAccessPolicy.catalogUuid], + }, }); + const { result } = renderHook(() => useCourseUpgradeData({ + ...basicArgs, + mode: COURSE_MODES_MAP.AUDIT, + }), { wrapper }); + + expect(result.current).toEqual( + expect.objectContaining({ + subsidyForCourse: expect.objectContaining({ + redemptionUrl: mockCanUpgradeWithLearnerCredit.redeemableSubsidyAccessPolicy.policyRedemptionUrl, + }), + courseRunPrice: mockCanUpgradeWithLearnerCredit.listPrice.usd, + hasUpgradeAndConfirm: true, + }), + ); }); }); });