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;