Skip to content

Commit

Permalink
chore: show custom alert if user is already enrolled pre-emet in exec…
Browse files Browse the repository at this point in the history
… ed course (#765)
  • Loading branch information
emrosarioa authored Jun 22, 2023
1 parent b73a1bf commit 6f9afb1
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 26 deletions.
7 changes: 6 additions & 1 deletion src/components/course/CourseContextProvider.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -25,6 +25,8 @@ export const CourseContextProvider = ({
currency,
canOnlyViewHighlightSets,
}) => {
const [externalCourseFormSubmissionError, setExternalCourseFormSubmissionError] = useState(null);

const value = useMemo(() => ({
state: courseState,
userCanRequestSubsidyForCourse,
Expand All @@ -37,6 +39,8 @@ export const CourseContextProvider = ({
coursePrice,
currency,
canOnlyViewHighlightSets,
externalCourseFormSubmissionError,
setExternalCourseFormSubmissionError,
}), [
courseState,
userCanRequestSubsidyForCourse,
Expand All @@ -49,6 +53,7 @@ export const CourseContextProvider = ({
coursePrice,
currency,
canOnlyViewHighlightSets,
externalCourseFormSubmissionError,
]);

return (
Expand Down
70 changes: 59 additions & 11 deletions src/components/course/routes/ExternalCourseEnrollment.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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: {
Expand All @@ -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');
};
Expand All @@ -52,15 +82,33 @@ const ExternalCourseEnrollment = () => {
<h2 className="mb-3">
Your registration(s)
</h2>
<p className="small bg-light-500 p-3 rounded-lg">
<strong>
This is where you finalize your registration for an edX executive
education course through GetSmarter.
</strong>
&nbsp; Please ensure that the course details below are correct and confirm using Learner
Credit with a &quot;Confirm registration&quot; button.
Your Learner Credit funds will be redeemed at this point.
</p>
<Alert
variant="success"
ref={containerRef}
icon={CheckCircle}
show={isDuplicateExternalCourseOrder(externalCourseFormSubmissionError)}
actions={[
<Button as={Hyperlink} target="_blank" destination={externalDashboardUrl}>
Go to dashboard
</Button>,
]}
>
<Alert.Heading>Already Enrolled</Alert.Heading>
<p>
You&apos;re already enrolled. Go to your GetSmarter dashboard to keep learning.
</p>
</Alert>
{!isDuplicateExternalCourseOrder(externalCourseFormSubmissionError) && (
<p className="small bg-light-500 p-3 rounded-lg">
<strong>
This is where you finalize your registration for an edX executive
education course through GetSmarter.
</strong>
&nbsp; Please ensure that the course details below are correct and confirm using Learner
Credit with a &quot;Confirm registration&quot; button.
Your Learner Credit funds will be redeemed at this point.
</p>
)}
<CourseSummaryCard courseMetadata={courseMetadata} />
<RegistrationSummaryCard priceDetails={courseMetadata.priceDetails} />
<UserEnrollmentForm
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ jest.mock('../../../executive-education-2u/UserEnrollmentForm', () => jest.fn(()
<div data-testid="user-enrollment-form" />
)));

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',
Expand All @@ -59,6 +66,7 @@ const baseAppContextValue = {
uuid: 'test-uuid',
enableDataSharingConsent: true,
adminUsers: ['[email protected]'],
authOrgId: 'test-uuid',
},
authenticatedUser: { id: 3 },
};
Expand All @@ -80,7 +88,9 @@ describe('ExternalCourseEnrollment', () => {
beforeEach(() => {
jest.clearAllMocks();
});

afterEach(() => {
jest.clearAllMocks();
});
it('renders and handles checkout success', () => {
renderWithRouter(<ExternalCourseEnrollmentWrapper />);
expect(screen.getByText('Your registration(s)')).toBeInTheDocument();
Expand Down Expand Up @@ -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(<ExternalCourseEnrollmentWrapper courseContextValue={courseContextValue} />);
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);
}
});
});
28 changes: 19 additions & 9 deletions src/components/executive-education-2u/UserEnrollmentForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand All @@ -74,7 +75,7 @@ const UserEnrollmentForm = ({
subsidyAccessPolicy: userSubsidyApplicableToCourse,
onSuccess: handleFormSubmissionSuccess,
onError: (error) => {
setFormSubmissionError(error);
setExternalCourseFormSubmissionError(error);
setEnrollButtonState('error');
logError(error);
},
Expand Down Expand Up @@ -131,7 +132,7 @@ const UserEnrollmentForm = ({
try {
await redeem({ metadata: userDetails });
} catch (error) {
setFormSubmissionError(error);
setExternalCourseFormSubmissionError(error);
logError(error);
}
};
Expand All @@ -155,7 +156,7 @@ const UserEnrollmentForm = ({
logInfo(`${enterpriseId} user ${userId} has already purchased course ${productSKU}.`);
await handleFormSubmissionSuccess();
} else {
setFormSubmissionError(error);
setExternalCourseFormSubmissionError(error);
logError(error);
}
}
Expand Down Expand Up @@ -201,15 +202,17 @@ const UserEnrollmentForm = ({
<Alert
variant="danger"
className="mb-4.5"
show={!!formSubmissionError}
onClose={() => setFormSubmissionError(undefined)}
show={
externalCourseFormSubmissionError
&& !isDuplicateExternalCourseOrder(externalCourseFormSubmissionError)
}
onClose={() => setExternalCourseFormSubmissionError(undefined)}
dismissible
>
<p>
An error occurred while sharing your course enrollment information. Please try again.
</p>
</Alert>

<Row className="mb-4">
<Col xs={12} lg={6}>
<Form.Group
Expand Down Expand Up @@ -367,9 +370,16 @@ const UserEnrollmentForm = ({
default: 'Confirm registration',
pending: 'Confirming registration...',
complete: 'Registration confirmed',
error: 'Try again',
error: externalCourseFormSubmissionError
&& isDuplicateExternalCourseOrder(externalCourseFormSubmissionError)
? 'Confirm registration'
: 'Try again',
}}
state={enrollButtonState}
disabled={
externalCourseFormSubmissionError
&& isDuplicateExternalCourseOrder(externalCourseFormSubmissionError)
}
/>
</div>
</FormikForm>
Expand Down
56 changes: 52 additions & 4 deletions src/components/executive-education-2u/UserEnrollmentForm.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ const UserEnrollmentFormWrapper = ({
state: {
userEnrollments: [],
},
setExternalFormSubmissionError: jest.fn(),
formSubmissionError: {},
},
}) => (
<IntlProvider locale="en">
Expand Down Expand Up @@ -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(<UserEnrollmentFormWrapper />);
const mockFormSubmissionValue = { message: 'oh noes' };

render(<UserEnrollmentFormWrapper
courseContextValue={{
state: {
userEnrollments: [],
},
setExternalCourseFormSubmissionError: jest.fn(),
externalCourseFormSubmissionError: mockFormSubmissionValue,
}}
/>);
userEvent.type(screen.getByLabelText('First name *'), mockFirstName);
userEvent.type(screen.getByLabelText('Last name *'), mockLastName);
userEvent.type(screen.getByLabelText('Date of birth *'), mockDateOfBirth);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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(<UserEnrollmentFormWrapper
courseContextValue={{
state: {
userEnrollments: [],
},
setExternalCourseFormSubmissionError: jest.fn(),
externalCourseFormSubmissionError: mockFormSubmissionValue,
}}
/>);
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();
});
});
});
2 changes: 2 additions & 0 deletions src/components/executive-education-2u/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export const toISOStringWithoutMilliseconds = (isoString) => {
}
return `${isoString.split('.')[0]}Z`;
};

export const isDuplicateExternalCourseOrder = (formSubmissionError) => formSubmissionError?.message?.includes('duplicate order') || false;

0 comments on commit 6f9afb1

Please sign in to comment.