diff --git a/src/components/course/CourseContextProvider.jsx b/src/components/course/CourseContextProvider.jsx index ee7bddffb5..bb65a4c485 100644 --- a/src/components/course/CourseContextProvider.jsx +++ b/src/components/course/CourseContextProvider.jsx @@ -1,4 +1,4 @@ -import React, { createContext, useMemo } from 'react'; +import React, { createContext, useState, useMemo } from 'react'; import PropTypes from 'prop-types'; import { COUPON_CODE_SUBSIDY_TYPE, @@ -25,6 +25,8 @@ export const CourseContextProvider = ({ currency, canOnlyViewHighlightSets, }) => { + const [externalCourseFormSubmissionError, setExternalCourseFormSubmissionError] = useState(null); + const value = useMemo(() => ({ state: courseState, userCanRequestSubsidyForCourse, @@ -37,6 +39,8 @@ export const CourseContextProvider = ({ coursePrice, currency, canOnlyViewHighlightSets, + externalCourseFormSubmissionError, + setExternalCourseFormSubmissionError, }), [ courseState, userCanRequestSubsidyForCourse, @@ -49,6 +53,7 @@ export const CourseContextProvider = ({ coursePrice, currency, canOnlyViewHighlightSets, + externalCourseFormSubmissionError, ]); return ( diff --git a/src/components/course/routes/ExternalCourseEnrollment.jsx b/src/components/course/routes/ExternalCourseEnrollment.jsx index c0918d59d8..00d2739ad2 100644 --- a/src/components/course/routes/ExternalCourseEnrollment.jsx +++ b/src/components/course/routes/ExternalCourseEnrollment.jsx @@ -1,9 +1,13 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useRef } from 'react'; import { useHistory } from 'react-router-dom'; import { - Container, Col, Row, + Alert, Button, Container, Col, Hyperlink, Row, } from '@edx/paragon'; +import { CheckCircle } from '@edx/paragon/icons'; +import { getConfig } from '@edx/frontend-platform/config'; +import { AppContext } from '@edx/frontend-platform/react'; +import { isDuplicateExternalCourseOrder } from '../../executive-education-2u/data'; import { CourseContext } from '../CourseContextProvider'; import CourseSummaryCard from '../../executive-education-2u/components/CourseSummaryCard'; import RegistrationSummaryCard from '../../executive-education-2u/components/RegistrationSummaryCard'; @@ -12,6 +16,7 @@ import { useExternalEnrollmentFailureReason, useMinimalCourseMetadata } from '.. import ErrorPageContent from '../../executive-education-2u/components/ErrorPageContent'; const ExternalCourseEnrollment = () => { + const config = getConfig(); const history = useHistory(); const { state: { @@ -20,13 +25,38 @@ const ExternalCourseEnrollment = () => { }, userSubsidyApplicableToCourse, hasSuccessfulRedemption, + externalCourseFormSubmissionError, } = useContext(CourseContext); + const { + enterpriseConfig: { authOrgId }, + } = useContext(AppContext); + const courseMetadata = useMinimalCourseMetadata(); + const externalDashboardQueryParams = new URLSearchParams(); + if (authOrgId) { + externalDashboardQueryParams.set('org_id', authOrgId); + } + + let externalDashboardUrl = config.GETSMARTER_LEARNER_DASHBOARD_URL; + + if (externalDashboardQueryParams.has('org_id')) { + externalDashboardUrl += `?${externalDashboardQueryParams.toString()}`; + } + const { failureReason, failureMessage, } = useExternalEnrollmentFailureReason(); + + const containerRef = useRef(null); + + useEffect(() => { + if (isDuplicateExternalCourseOrder(externalCourseFormSubmissionError) && containerRef?.current) { + containerRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [externalCourseFormSubmissionError, containerRef]); + const handleCheckoutSuccess = () => { history.push('enroll/complete'); }; @@ -52,15 +82,33 @@ const ExternalCourseEnrollment = () => {

Your registration(s)

-

- - This is where you finalize your registration for an edX executive - education course through GetSmarter. - -   Please ensure that the course details below are correct and confirm using Learner - Credit with a "Confirm registration" button. - Your Learner Credit funds will be redeemed at this point. -

+ + Go to dashboard + , + ]} + > + Already Enrolled +

+ You're already enrolled. Go to your GetSmarter dashboard to keep learning. +

+
+ {!isDuplicateExternalCourseOrder(externalCourseFormSubmissionError) && ( +

+ + This is where you finalize your registration for an edX executive + education course through GetSmarter. + +   Please ensure that the course details below are correct and confirm using Learner + Credit with a "Confirm registration" button. + Your Learner Credit funds will be redeemed at this point. +

+ )} jest.fn(()
))); +jest.mock('@edx/frontend-platform/config', () => ({ + ...jest.requireActual('@edx/frontend-platform/config'), + getConfig: jest.fn(() => ({ + GETSMARTER_LEARNER_DASHBOARD_URL: 'https://getsmarter.example.com/account', + })), +})); + const baseCourseContextValue = { state: { courseEntitlementProductSku: 'test-sku', @@ -59,6 +66,7 @@ const baseAppContextValue = { uuid: 'test-uuid', enableDataSharingConsent: true, adminUsers: ['edx@example.com'], + authOrgId: 'test-uuid', }, authenticatedUser: { id: 3 }, }; @@ -80,7 +88,9 @@ describe('ExternalCourseEnrollment', () => { beforeEach(() => { jest.clearAllMocks(); }); - + afterEach(() => { + jest.clearAllMocks(); + }); it('renders and handles checkout success', () => { renderWithRouter(); expect(screen.getByText('Your registration(s)')).toBeInTheDocument(); @@ -153,4 +163,32 @@ describe('ExternalCourseEnrollment', () => { expect(mockHistoryPush).toHaveBeenCalledTimes(1); }); + + it.each([ + { hasDuplicateOrder: true }, + { hasDuplicateOrder: false }, + ])('shows duplicate order alert (%s)', async ({ hasDuplicateOrder }) => { + const mockScrollIntoView = jest.fn(); + global.HTMLElement.prototype.scrollIntoView = mockScrollIntoView; + + const courseContextValue = { + ...baseCourseContextValue, + externalCourseFormSubmissionError: hasDuplicateOrder ? { message: 'duplicate order' } : undefined, + }; + renderWithRouter(); + if (hasDuplicateOrder) { + expect(screen.getByText('Already Enrolled')).toBeInTheDocument(); + const dashboardButton = screen.getByText('Go to dashboard'); + expect(dashboardButton).toBeInTheDocument(); + expect(dashboardButton).toHaveAttribute('href', 'https://getsmarter.example.com/account?org_id=test-uuid'); + expect(mockScrollIntoView).toHaveBeenCalledTimes(1); + expect(mockScrollIntoView).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'smooth' }), + ); + } else { + expect(screen.queryByText('Already Enrolled')).not.toBeInTheDocument(); + expect(screen.queryByText('Go to dashboard')).not.toBeInTheDocument(); + expect(mockScrollIntoView).toHaveBeenCalledTimes(0); + } + }); }); diff --git a/src/components/executive-education-2u/UserEnrollmentForm.jsx b/src/components/executive-education-2u/UserEnrollmentForm.jsx index 33910b52e1..78d7b21dbd 100644 --- a/src/components/executive-education-2u/UserEnrollmentForm.jsx +++ b/src/components/executive-education-2u/UserEnrollmentForm.jsx @@ -17,7 +17,7 @@ import { sendEnterpriseTrackEvent, sendEnterpriseTrackEventWithDelay } from '@ed import moment from 'moment/moment'; import reactStringReplace from 'react-string-replace'; -import { checkoutExecutiveEducation2U, toISOStringWithoutMilliseconds } from './data'; +import { checkoutExecutiveEducation2U, isDuplicateExternalCourseOrder, toISOStringWithoutMilliseconds } from './data'; import { useStatefulEnroll } from '../stateful-enroll/data'; import { LEARNER_CREDIT_SUBSIDY_TYPE } from '../course/data/constants'; import { CourseContext } from '../course/CourseContextProvider'; @@ -50,10 +50,11 @@ const UserEnrollmentForm = ({ state: { userEnrollments, }, + externalCourseFormSubmissionError, + setExternalCourseFormSubmissionError, } = useContext(CourseContext); const [isFormSubmitted, setIsFormSubmitted] = useState(false); - const [formSubmissionError, setFormSubmissionError] = useState(); const [enrollButtonState, setEnrollButtonState] = useState('default'); const handleFormSubmissionSuccess = async (newTransaction) => { @@ -74,7 +75,7 @@ const UserEnrollmentForm = ({ subsidyAccessPolicy: userSubsidyApplicableToCourse, onSuccess: handleFormSubmissionSuccess, onError: (error) => { - setFormSubmissionError(error); + setExternalCourseFormSubmissionError(error); setEnrollButtonState('error'); logError(error); }, @@ -131,7 +132,7 @@ const UserEnrollmentForm = ({ try { await redeem({ metadata: userDetails }); } catch (error) { - setFormSubmissionError(error); + setExternalCourseFormSubmissionError(error); logError(error); } }; @@ -155,7 +156,7 @@ const UserEnrollmentForm = ({ logInfo(`${enterpriseId} user ${userId} has already purchased course ${productSKU}.`); await handleFormSubmissionSuccess(); } else { - setFormSubmissionError(error); + setExternalCourseFormSubmissionError(error); logError(error); } } @@ -201,15 +202,17 @@ const UserEnrollmentForm = ({ setFormSubmissionError(undefined)} + show={ + externalCourseFormSubmissionError + && !isDuplicateExternalCourseOrder(externalCourseFormSubmissionError) + } + onClose={() => setExternalCourseFormSubmissionError(undefined)} dismissible >

An error occurred while sharing your course enrollment information. Please try again.

-
diff --git a/src/components/executive-education-2u/UserEnrollmentForm.test.jsx b/src/components/executive-education-2u/UserEnrollmentForm.test.jsx index f6babc1205..a8072c34dc 100644 --- a/src/components/executive-education-2u/UserEnrollmentForm.test.jsx +++ b/src/components/executive-education-2u/UserEnrollmentForm.test.jsx @@ -78,6 +78,8 @@ const UserEnrollmentFormWrapper = ({ state: { userEnrollments: [], }, + setExternalFormSubmissionError: jest.fn(), + formSubmissionError: {}, }, }) => ( @@ -314,7 +316,17 @@ describe('UserEnrollmentForm', () => { it('handles network error with form submission', async () => { const mockError = new Error('oh noes'); Date.now = jest.fn(() => new Date().valueOf()); - render(); + const mockFormSubmissionValue = { message: 'oh noes' }; + + render(); userEvent.type(screen.getByLabelText('First name *'), mockFirstName); userEvent.type(screen.getByLabelText('Last name *'), mockLastName); userEvent.type(screen.getByLabelText('Date of birth *'), mockDateOfBirth); @@ -340,9 +352,11 @@ describe('UserEnrollmentForm', () => { expect(mockLogError).toHaveBeenCalledTimes(1); expect(mockLogError).toHaveBeenCalledWith(mockError); - // ensure error alert is visible - expect(screen.getByRole('alert')).toBeInTheDocument(); - expect(screen.getByText('An error occurred while sharing your course enrollment information', { exact: false })).toBeInTheDocument(); + await waitFor(() => { + // ensure error alert is visible + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('An error occurred while sharing your course enrollment information', { exact: false })).toBeInTheDocument(); + }); }); it('handle error 422 where course was already enrolled in with legacy enterprise offers', async () => { @@ -397,4 +411,38 @@ describe('UserEnrollmentForm', () => { // disabled after submitting expect(screen.getByText('Registration confirmed').closest('button')).toHaveAttribute('aria-disabled', 'true'); }); + + it('handles duplicate order with form submission', async () => { + const mockError = new Error('duplicate order'); + Date.now = jest.fn(() => new Date().valueOf()); + const mockFormSubmissionValue = { message: 'duplicate order' }; + render(); + userEvent.type(screen.getByLabelText('First name *'), mockFirstName); + userEvent.type(screen.getByLabelText('Last name *'), mockLastName); + userEvent.type(screen.getByLabelText('Date of birth *'), mockDateOfBirth); + userEvent.click(screen.getByLabelText(termsLabelText)); + userEvent.click(screen.getByLabelText(dataSharingConsentLabelText)); + userEvent.click(screen.getByText('Confirm registration')); + + // simulate `useStatefulEnroll` calling `onError` arg + act(() => { + useStatefulEnroll.mock.calls[0][0].onError(mockError); + }); + + expect(mockLogError).toHaveBeenCalledTimes(1); + expect(mockLogError).toHaveBeenCalledWith(mockError); + + await waitFor(() => { + // ensure regular error alert is not visible + expect(screen.queryByText('An error occurred while sharing your course enrollment information')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/executive-education-2u/data/utils.js b/src/components/executive-education-2u/data/utils.js index 6d68b3cb35..d43fef4359 100644 --- a/src/components/executive-education-2u/data/utils.js +++ b/src/components/executive-education-2u/data/utils.js @@ -4,3 +4,5 @@ export const toISOStringWithoutMilliseconds = (isoString) => { } return `${isoString.split('.')[0]}Z`; }; + +export const isDuplicateExternalCourseOrder = (formSubmissionError) => formSubmissionError?.message?.includes('duplicate order') || false;