Skip to content

Commit 6f9afb1

Browse files
author
Emily Rosario-Aquin
authored
chore: show custom alert if user is already enrolled pre-emet in exec ed course (#765)
1 parent b73a1bf commit 6f9afb1

File tree

6 files changed

+177
-26
lines changed

6 files changed

+177
-26
lines changed

src/components/course/CourseContextProvider.jsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { createContext, useMemo } from 'react';
1+
import React, { createContext, useState, useMemo } from 'react';
22
import PropTypes from 'prop-types';
33
import {
44
COUPON_CODE_SUBSIDY_TYPE,
@@ -25,6 +25,8 @@ export const CourseContextProvider = ({
2525
currency,
2626
canOnlyViewHighlightSets,
2727
}) => {
28+
const [externalCourseFormSubmissionError, setExternalCourseFormSubmissionError] = useState(null);
29+
2830
const value = useMemo(() => ({
2931
state: courseState,
3032
userCanRequestSubsidyForCourse,
@@ -37,6 +39,8 @@ export const CourseContextProvider = ({
3739
coursePrice,
3840
currency,
3941
canOnlyViewHighlightSets,
42+
externalCourseFormSubmissionError,
43+
setExternalCourseFormSubmissionError,
4044
}), [
4145
courseState,
4246
userCanRequestSubsidyForCourse,
@@ -49,6 +53,7 @@ export const CourseContextProvider = ({
4953
coursePrice,
5054
currency,
5155
canOnlyViewHighlightSets,
56+
externalCourseFormSubmissionError,
5257
]);
5358

5459
return (

src/components/course/routes/ExternalCourseEnrollment.jsx

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
import React, { useContext, useEffect } from 'react';
1+
import React, { useContext, useEffect, useRef } from 'react';
22
import { useHistory } from 'react-router-dom';
33
import {
4-
Container, Col, Row,
4+
Alert, Button, Container, Col, Hyperlink, Row,
55
} from '@edx/paragon';
6+
import { CheckCircle } from '@edx/paragon/icons';
67

8+
import { getConfig } from '@edx/frontend-platform/config';
9+
import { AppContext } from '@edx/frontend-platform/react';
10+
import { isDuplicateExternalCourseOrder } from '../../executive-education-2u/data';
711
import { CourseContext } from '../CourseContextProvider';
812
import CourseSummaryCard from '../../executive-education-2u/components/CourseSummaryCard';
913
import RegistrationSummaryCard from '../../executive-education-2u/components/RegistrationSummaryCard';
@@ -12,6 +16,7 @@ import { useExternalEnrollmentFailureReason, useMinimalCourseMetadata } from '..
1216
import ErrorPageContent from '../../executive-education-2u/components/ErrorPageContent';
1317

1418
const ExternalCourseEnrollment = () => {
19+
const config = getConfig();
1520
const history = useHistory();
1621
const {
1722
state: {
@@ -20,13 +25,38 @@ const ExternalCourseEnrollment = () => {
2025
},
2126
userSubsidyApplicableToCourse,
2227
hasSuccessfulRedemption,
28+
externalCourseFormSubmissionError,
2329
} = useContext(CourseContext);
30+
const {
31+
enterpriseConfig: { authOrgId },
32+
} = useContext(AppContext);
33+
2434
const courseMetadata = useMinimalCourseMetadata();
2535

36+
const externalDashboardQueryParams = new URLSearchParams();
37+
if (authOrgId) {
38+
externalDashboardQueryParams.set('org_id', authOrgId);
39+
}
40+
41+
let externalDashboardUrl = config.GETSMARTER_LEARNER_DASHBOARD_URL;
42+
43+
if (externalDashboardQueryParams.has('org_id')) {
44+
externalDashboardUrl += `?${externalDashboardQueryParams.toString()}`;
45+
}
46+
2647
const {
2748
failureReason,
2849
failureMessage,
2950
} = useExternalEnrollmentFailureReason();
51+
52+
const containerRef = useRef(null);
53+
54+
useEffect(() => {
55+
if (isDuplicateExternalCourseOrder(externalCourseFormSubmissionError) && containerRef?.current) {
56+
containerRef.current.scrollIntoView({ behavior: 'smooth' });
57+
}
58+
}, [externalCourseFormSubmissionError, containerRef]);
59+
3060
const handleCheckoutSuccess = () => {
3161
history.push('enroll/complete');
3262
};
@@ -52,15 +82,33 @@ const ExternalCourseEnrollment = () => {
5282
<h2 className="mb-3">
5383
Your registration(s)
5484
</h2>
55-
<p className="small bg-light-500 p-3 rounded-lg">
56-
<strong>
57-
This is where you finalize your registration for an edX executive
58-
education course through GetSmarter.
59-
</strong>
60-
&nbsp; Please ensure that the course details below are correct and confirm using Learner
61-
Credit with a &quot;Confirm registration&quot; button.
62-
Your Learner Credit funds will be redeemed at this point.
63-
</p>
85+
<Alert
86+
variant="success"
87+
ref={containerRef}
88+
icon={CheckCircle}
89+
show={isDuplicateExternalCourseOrder(externalCourseFormSubmissionError)}
90+
actions={[
91+
<Button as={Hyperlink} target="_blank" destination={externalDashboardUrl}>
92+
Go to dashboard
93+
</Button>,
94+
]}
95+
>
96+
<Alert.Heading>Already Enrolled</Alert.Heading>
97+
<p>
98+
You&apos;re already enrolled. Go to your GetSmarter dashboard to keep learning.
99+
</p>
100+
</Alert>
101+
{!isDuplicateExternalCourseOrder(externalCourseFormSubmissionError) && (
102+
<p className="small bg-light-500 p-3 rounded-lg">
103+
<strong>
104+
This is where you finalize your registration for an edX executive
105+
education course through GetSmarter.
106+
</strong>
107+
&nbsp; Please ensure that the course details below are correct and confirm using Learner
108+
Credit with a &quot;Confirm registration&quot; button.
109+
Your Learner Credit funds will be redeemed at this point.
110+
</p>
111+
)}
64112
<CourseSummaryCard courseMetadata={courseMetadata} />
65113
<RegistrationSummaryCard priceDetails={courseMetadata.priceDetails} />
66114
<UserEnrollmentForm

src/components/course/routes/tests/ExternalCourseEnrollment.test.jsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ jest.mock('../../../executive-education-2u/UserEnrollmentForm', () => jest.fn(()
3939
<div data-testid="user-enrollment-form" />
4040
)));
4141

42+
jest.mock('@edx/frontend-platform/config', () => ({
43+
...jest.requireActual('@edx/frontend-platform/config'),
44+
getConfig: jest.fn(() => ({
45+
GETSMARTER_LEARNER_DASHBOARD_URL: 'https://getsmarter.example.com/account',
46+
})),
47+
}));
48+
4249
const baseCourseContextValue = {
4350
state: {
4451
courseEntitlementProductSku: 'test-sku',
@@ -59,6 +66,7 @@ const baseAppContextValue = {
5966
uuid: 'test-uuid',
6067
enableDataSharingConsent: true,
6168
adminUsers: ['[email protected]'],
69+
authOrgId: 'test-uuid',
6270
},
6371
authenticatedUser: { id: 3 },
6472
};
@@ -80,7 +88,9 @@ describe('ExternalCourseEnrollment', () => {
8088
beforeEach(() => {
8189
jest.clearAllMocks();
8290
});
83-
91+
afterEach(() => {
92+
jest.clearAllMocks();
93+
});
8494
it('renders and handles checkout success', () => {
8595
renderWithRouter(<ExternalCourseEnrollmentWrapper />);
8696
expect(screen.getByText('Your registration(s)')).toBeInTheDocument();
@@ -153,4 +163,32 @@ describe('ExternalCourseEnrollment', () => {
153163

154164
expect(mockHistoryPush).toHaveBeenCalledTimes(1);
155165
});
166+
167+
it.each([
168+
{ hasDuplicateOrder: true },
169+
{ hasDuplicateOrder: false },
170+
])('shows duplicate order alert (%s)', async ({ hasDuplicateOrder }) => {
171+
const mockScrollIntoView = jest.fn();
172+
global.HTMLElement.prototype.scrollIntoView = mockScrollIntoView;
173+
174+
const courseContextValue = {
175+
...baseCourseContextValue,
176+
externalCourseFormSubmissionError: hasDuplicateOrder ? { message: 'duplicate order' } : undefined,
177+
};
178+
renderWithRouter(<ExternalCourseEnrollmentWrapper courseContextValue={courseContextValue} />);
179+
if (hasDuplicateOrder) {
180+
expect(screen.getByText('Already Enrolled')).toBeInTheDocument();
181+
const dashboardButton = screen.getByText('Go to dashboard');
182+
expect(dashboardButton).toBeInTheDocument();
183+
expect(dashboardButton).toHaveAttribute('href', 'https://getsmarter.example.com/account?org_id=test-uuid');
184+
expect(mockScrollIntoView).toHaveBeenCalledTimes(1);
185+
expect(mockScrollIntoView).toHaveBeenCalledWith(
186+
expect.objectContaining({ behavior: 'smooth' }),
187+
);
188+
} else {
189+
expect(screen.queryByText('Already Enrolled')).not.toBeInTheDocument();
190+
expect(screen.queryByText('Go to dashboard')).not.toBeInTheDocument();
191+
expect(mockScrollIntoView).toHaveBeenCalledTimes(0);
192+
}
193+
});
156194
});

src/components/executive-education-2u/UserEnrollmentForm.jsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { sendEnterpriseTrackEvent, sendEnterpriseTrackEventWithDelay } from '@ed
1717
import moment from 'moment/moment';
1818
import reactStringReplace from 'react-string-replace';
1919

20-
import { checkoutExecutiveEducation2U, toISOStringWithoutMilliseconds } from './data';
20+
import { checkoutExecutiveEducation2U, isDuplicateExternalCourseOrder, toISOStringWithoutMilliseconds } from './data';
2121
import { useStatefulEnroll } from '../stateful-enroll/data';
2222
import { LEARNER_CREDIT_SUBSIDY_TYPE } from '../course/data/constants';
2323
import { CourseContext } from '../course/CourseContextProvider';
@@ -50,10 +50,11 @@ const UserEnrollmentForm = ({
5050
state: {
5151
userEnrollments,
5252
},
53+
externalCourseFormSubmissionError,
54+
setExternalCourseFormSubmissionError,
5355
} = useContext(CourseContext);
5456

5557
const [isFormSubmitted, setIsFormSubmitted] = useState(false);
56-
const [formSubmissionError, setFormSubmissionError] = useState();
5758
const [enrollButtonState, setEnrollButtonState] = useState('default');
5859

5960
const handleFormSubmissionSuccess = async (newTransaction) => {
@@ -74,7 +75,7 @@ const UserEnrollmentForm = ({
7475
subsidyAccessPolicy: userSubsidyApplicableToCourse,
7576
onSuccess: handleFormSubmissionSuccess,
7677
onError: (error) => {
77-
setFormSubmissionError(error);
78+
setExternalCourseFormSubmissionError(error);
7879
setEnrollButtonState('error');
7980
logError(error);
8081
},
@@ -131,7 +132,7 @@ const UserEnrollmentForm = ({
131132
try {
132133
await redeem({ metadata: userDetails });
133134
} catch (error) {
134-
setFormSubmissionError(error);
135+
setExternalCourseFormSubmissionError(error);
135136
logError(error);
136137
}
137138
};
@@ -155,7 +156,7 @@ const UserEnrollmentForm = ({
155156
logInfo(`${enterpriseId} user ${userId} has already purchased course ${productSKU}.`);
156157
await handleFormSubmissionSuccess();
157158
} else {
158-
setFormSubmissionError(error);
159+
setExternalCourseFormSubmissionError(error);
159160
logError(error);
160161
}
161162
}
@@ -201,15 +202,17 @@ const UserEnrollmentForm = ({
201202
<Alert
202203
variant="danger"
203204
className="mb-4.5"
204-
show={!!formSubmissionError}
205-
onClose={() => setFormSubmissionError(undefined)}
205+
show={
206+
externalCourseFormSubmissionError
207+
&& !isDuplicateExternalCourseOrder(externalCourseFormSubmissionError)
208+
}
209+
onClose={() => setExternalCourseFormSubmissionError(undefined)}
206210
dismissible
207211
>
208212
<p>
209213
An error occurred while sharing your course enrollment information. Please try again.
210214
</p>
211215
</Alert>
212-
213216
<Row className="mb-4">
214217
<Col xs={12} lg={6}>
215218
<Form.Group
@@ -367,9 +370,16 @@ const UserEnrollmentForm = ({
367370
default: 'Confirm registration',
368371
pending: 'Confirming registration...',
369372
complete: 'Registration confirmed',
370-
error: 'Try again',
373+
error: externalCourseFormSubmissionError
374+
&& isDuplicateExternalCourseOrder(externalCourseFormSubmissionError)
375+
? 'Confirm registration'
376+
: 'Try again',
371377
}}
372378
state={enrollButtonState}
379+
disabled={
380+
externalCourseFormSubmissionError
381+
&& isDuplicateExternalCourseOrder(externalCourseFormSubmissionError)
382+
}
373383
/>
374384
</div>
375385
</FormikForm>

src/components/executive-education-2u/UserEnrollmentForm.test.jsx

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ const UserEnrollmentFormWrapper = ({
7878
state: {
7979
userEnrollments: [],
8080
},
81+
setExternalFormSubmissionError: jest.fn(),
82+
formSubmissionError: {},
8183
},
8284
}) => (
8385
<IntlProvider locale="en">
@@ -314,7 +316,17 @@ describe('UserEnrollmentForm', () => {
314316
it('handles network error with form submission', async () => {
315317
const mockError = new Error('oh noes');
316318
Date.now = jest.fn(() => new Date().valueOf());
317-
render(<UserEnrollmentFormWrapper />);
319+
const mockFormSubmissionValue = { message: 'oh noes' };
320+
321+
render(<UserEnrollmentFormWrapper
322+
courseContextValue={{
323+
state: {
324+
userEnrollments: [],
325+
},
326+
setExternalCourseFormSubmissionError: jest.fn(),
327+
externalCourseFormSubmissionError: mockFormSubmissionValue,
328+
}}
329+
/>);
318330
userEvent.type(screen.getByLabelText('First name *'), mockFirstName);
319331
userEvent.type(screen.getByLabelText('Last name *'), mockLastName);
320332
userEvent.type(screen.getByLabelText('Date of birth *'), mockDateOfBirth);
@@ -340,9 +352,11 @@ describe('UserEnrollmentForm', () => {
340352
expect(mockLogError).toHaveBeenCalledTimes(1);
341353
expect(mockLogError).toHaveBeenCalledWith(mockError);
342354

343-
// ensure error alert is visible
344-
expect(screen.getByRole('alert')).toBeInTheDocument();
345-
expect(screen.getByText('An error occurred while sharing your course enrollment information', { exact: false })).toBeInTheDocument();
355+
await waitFor(() => {
356+
// ensure error alert is visible
357+
expect(screen.getByRole('alert')).toBeInTheDocument();
358+
expect(screen.getByText('An error occurred while sharing your course enrollment information', { exact: false })).toBeInTheDocument();
359+
});
346360
});
347361

348362
it('handle error 422 where course was already enrolled in with legacy enterprise offers', async () => {
@@ -397,4 +411,38 @@ describe('UserEnrollmentForm', () => {
397411
// disabled after submitting
398412
expect(screen.getByText('Registration confirmed').closest('button')).toHaveAttribute('aria-disabled', 'true');
399413
});
414+
415+
it('handles duplicate order with form submission', async () => {
416+
const mockError = new Error('duplicate order');
417+
Date.now = jest.fn(() => new Date().valueOf());
418+
const mockFormSubmissionValue = { message: 'duplicate order' };
419+
render(<UserEnrollmentFormWrapper
420+
courseContextValue={{
421+
state: {
422+
userEnrollments: [],
423+
},
424+
setExternalCourseFormSubmissionError: jest.fn(),
425+
externalCourseFormSubmissionError: mockFormSubmissionValue,
426+
}}
427+
/>);
428+
userEvent.type(screen.getByLabelText('First name *'), mockFirstName);
429+
userEvent.type(screen.getByLabelText('Last name *'), mockLastName);
430+
userEvent.type(screen.getByLabelText('Date of birth *'), mockDateOfBirth);
431+
userEvent.click(screen.getByLabelText(termsLabelText));
432+
userEvent.click(screen.getByLabelText(dataSharingConsentLabelText));
433+
userEvent.click(screen.getByText('Confirm registration'));
434+
435+
// simulate `useStatefulEnroll` calling `onError` arg
436+
act(() => {
437+
useStatefulEnroll.mock.calls[0][0].onError(mockError);
438+
});
439+
440+
expect(mockLogError).toHaveBeenCalledTimes(1);
441+
expect(mockLogError).toHaveBeenCalledWith(mockError);
442+
443+
await waitFor(() => {
444+
// ensure regular error alert is not visible
445+
expect(screen.queryByText('An error occurred while sharing your course enrollment information')).not.toBeInTheDocument();
446+
});
447+
});
400448
});

src/components/executive-education-2u/data/utils.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ export const toISOStringWithoutMilliseconds = (isoString) => {
44
}
55
return `${isoString.split('.')[0]}Z`;
66
};
7+
8+
export const isDuplicateExternalCourseOrder = (formSubmissionError) => formSubmissionError?.message?.includes('duplicate order') || false;

0 commit comments

Comments
 (0)