Skip to content

Commit

Permalink
feat: handle specific course runs in Exec Ed course cards (#1017)
Browse files Browse the repository at this point in the history
* feat: handle specific course runs in Exec Ed course cards

The course card component for Exec Ed courses now navigates to the T's &
C's form with a course run key in the URL to indicate exactly which
course run was selected. In turn, that form page has been updated to
read that course run key from the URL and use it in policy redemption
calls.

BEFORE this change, the specific run that was actually selected was
*ignored* and instead the frontend would determine the advertised run
and use that instead. AFTER this change, the learner selection is
respected.

ENT-8523

* fix: redirect hook should be run-specific when checking for existing redemptions

ENT-8523

---------

Co-authored-by: Adam Stankiewicz <agstanki@gmail.com>
  • Loading branch information
pwnage101 and adamstankiewicz authored Apr 5, 2024
1 parent cc692c4 commit 5e73e9c
Show file tree
Hide file tree
Showing 9 changed files with 54 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const useCourseRunCardData = ({
const isUserEnrolled = !!userEnrollment;
const externalCourseEnrollmentUrl = getExternalCourseEnrollmentUrl({
currentRouteUrl: pathname,
selectedCourseRunKey: courseRun.key,
});

// Get and return course run card data for display
Expand Down
1 change: 1 addition & 0 deletions src/components/course/data/hooks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ export const useCourseEnrollmentUrl = ({
if (isExecutiveEducation2UCourse) {
const externalCourseEnrollmentUrl = getExternalCourseEnrollmentUrl({
currentRouteUrl: pathname,
selectedCourseRunKey: courseRunKey,
});
return externalCourseEnrollmentUrl;
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/course/enrollment/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,12 @@ export function determineEnrollmentType({

export function getExternalCourseEnrollmentUrl({
currentRouteUrl,
selectedCourseRunKey,
}) {
// TODO: See if we can make this generic, not linked to Exec Ed
const isExecutiveEducation2UCourse = pathContainsCourseTypeSlug(currentRouteUrl, 'executive-education-2u');
if (!isExecutiveEducation2UCourse) {
return undefined;
}
return `${currentRouteUrl}/enroll`;
return `${currentRouteUrl}/enroll/${selectedCourseRunKey}`;
}
4 changes: 2 additions & 2 deletions src/components/course/routes/CoursePageRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import NotFoundPage from '../../NotFoundPage';
const CoursePageRoutes = () => (
<Routes>
<Route path="/" element={<PageWrap><CourseAbout /></PageWrap>} />
<Route path="enroll" element={<PageWrap><ExternalCourseEnrollment /></PageWrap>} />
<Route path="enroll/complete" element={<PageWrap><ExternalCourseEnrollmentConfirmation /></PageWrap>} />
<Route path="enroll/:courseRunKey" element={<PageWrap><ExternalCourseEnrollment /></PageWrap>} />
<Route path="enroll/:courseRunKey/complete" element={<PageWrap><ExternalCourseEnrollmentConfirmation /></PageWrap>} />
<Route path="*" element={<PageWrap><NotFoundPage /></PageWrap>} />
</Routes>
);
Expand Down
19 changes: 2 additions & 17 deletions src/components/course/routes/ExternalCourseEnrollment.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,13 @@ import {
} from '../data/hooks';
import ErrorPageContent from '../../executive-education-2u/components/ErrorPageContent';
import { features } from '../../../config';
import { useCourseMetadata, useEnterpriseCustomer } from '../../app/data';
import { useEnterpriseCustomer } from '../../app/data';

export { default as makeExternalCourseEnrollmentLoader } from './externalCourseEnrollmentLoader';

const ExternalCourseEnrollment = () => {
const config = getConfig();
const { externalCourseFormSubmissionError } = useContext(CourseContext);
const {
data: {
activeCourseRun,
courseEntitlementProductSku,
},
} = useCourseMetadata({
select: ({ transformed }) => ({
courseMetadata: transformed,
activeCourseRun: transformed.activeCourseRun,
courseEntitlementProductSku: transformed.courseEntitlementProductSku,
}),
});
const { data: enterpriseCustomer } = useEnterpriseCustomer();
const isCourseAssigned = useIsCourseAssigned();
const { data: minimalCourseMetadata } = useMinimalCourseMetadata();
Expand Down Expand Up @@ -142,10 +130,7 @@ const ExternalCourseEnrollment = () => {
)}
<CourseSummaryCard courseMetadata={minimalCourseMetadata} />
<RegistrationSummaryCard priceDetails={minimalCourseMetadata.priceDetails} />
<UserEnrollmentForm
productSKU={courseEntitlementProductSku}
activeCourseRun={activeCourseRun}
/>
<UserEnrollmentForm />
</Col>
</Row>
</Container>
Expand Down
4 changes: 2 additions & 2 deletions src/components/course/routes/tests/CoursePageRoutes.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ describe('CoursePageRoutes', () => {
});

it('renders ExternalCourseEnrollment route', () => {
render(<MemoryRouter initialEntries={['/enroll']}><CoursePageRoutes /></MemoryRouter>);
render(<MemoryRouter initialEntries={['/enroll/course-v1:bin+bar+baz']}><CoursePageRoutes /></MemoryRouter>);
expect(screen.getByTestId('external-course-enrollment')).toBeInTheDocument();
});

it('renders ExternalCourseEnrollmentConfirmation route', () => {
render(<MemoryRouter initialEntries={['/enroll/complete']}><CoursePageRoutes /></MemoryRouter>);
render(<MemoryRouter initialEntries={['/enroll/course-v1:bin+bar+baz/complete']}><CoursePageRoutes /></MemoryRouter>);
expect(screen.getByTestId('external-course-enrollment-confirmation')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,18 @@ import { UserSubsidyContext } from '../../../enterprise-user-subsidy';
import { emptyRedeemableLearnerCreditPolicies, useEnterpriseCustomer } from '../../../app/data';
import { authenticatedUserFactory, enterpriseCustomerFactory } from '../../../app/data/services/data/__factories__';

const testCourseKey = 'bin+bar';
const testCourseRunKey = 'course-v1:bin+bar+baz';

const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
useParams: () => ({
enterpriseSlug: 'test-enterprise-uuid',
courseKey: testCourseKey,
courseRunKey: testCourseRunKey,
}),
}));

jest.mock('../../data/hooks', () => ({
Expand Down Expand Up @@ -65,6 +73,13 @@ const baseCourseContextValue = {
},
userSubsidyApplicableToCourse: { subsidyType: LEARNER_CREDIT_SUBSIDY_TYPE },
missingUserSubsidyReason: undefined,
redeemabilityPerContentKey: [
{
contentKey: testCourseRunKey,
hasSuccessfulRedemption: false,
},
],
hasSuccessfulRedemption: false,
};

const mockEnterpriseCustomer = enterpriseCustomerFactory();
Expand Down Expand Up @@ -113,6 +128,7 @@ describe('ExternalCourseEnrollment', () => {
expect(UserEnrollmentForm.mock.calls[0][0]).toEqual(
expect.objectContaining({
productSKU: 'test-sku',
courseRunKey: testCourseRunKey,
}),
);
});
Expand Down Expand Up @@ -144,6 +160,12 @@ describe('ExternalCourseEnrollment', () => {
const courseContextValue = {
...baseCourseContextValue,
userSubsidyApplicableToCourse: undefined,
redeemabilityPerContentKey: [
{
contentKey: testCourseRunKey,
hasSuccessfulRedemption: true,
},
],
hasSuccessfulRedemption: true,
missingUserSubsidyReason: { reason: DISABLED_ENROLL_REASON_TYPES.NO_SUBSIDY },
};
Expand All @@ -162,6 +184,12 @@ describe('ExternalCourseEnrollment', () => {
it('handles a courserun that has already been enrolled', () => {
const courseContextValue = {
...baseCourseContextValue,
redeemabilityPerContentKey: [
{
contentKey: testCourseRunKey,
hasSuccessfulRedemption: true,
},
],
hasSuccessfulRedemption: true,
};
renderWithRouter(<ExternalCourseEnrollmentWrapper courseContextValue={courseContextValue} />);
Expand Down
24 changes: 7 additions & 17 deletions src/components/executive-education-2u/UserEnrollmentForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,13 @@ import {
queryCanRedeemContextQueryKey,
queryEnterpriseCourseEnrollments,
queryRedeemablePolicies,
useCourseMetadata,
useEnterpriseCourseEnrollments,
useEnterpriseCustomer,
} from '../app/data';
import { useUserSubsidyApplicableToCourse } from '../course/data';

const UserEnrollmentForm = ({
className,
productSKU,
activeCourseRun,
}) => {
const UserEnrollmentForm = ({ className }) => {
const navigate = useNavigate();
const config = getConfig();
const queryClient = useQueryClient();
Expand All @@ -44,11 +41,12 @@ const UserEnrollmentForm = ({
const { data: enterpriseCustomer } = useEnterpriseCustomer();
const { data: enterpriseCourseEnrollments } = useEnterpriseCourseEnrollments();
const { userSubsidyApplicableToCourse } = useUserSubsidyApplicableToCourse();
const { courseKey } = useParams();
const { courseKey, courseRunKey } = useParams();
const {
externalCourseFormSubmissionError,
setExternalCourseFormSubmissionError,
} = useContext(CourseContext);
const { data: { courseEntitlementProductSku } } = useCourseMetadata();

const [isFormSubmitted, setIsFormSubmitted] = useState(false);
const [enrollButtonState, setEnrollButtonState] = useState('default');
Expand Down Expand Up @@ -79,7 +77,7 @@ const UserEnrollmentForm = ({
};

const { redeem } = useStatefulEnroll({
contentKey: activeCourseRun.key,
contentKey: courseRunKey,
subsidyAccessPolicy: userSubsidyApplicableToCourse,
onSuccess: handleFormSubmissionSuccess,
onError: (error) => {
Expand Down Expand Up @@ -175,7 +173,7 @@ const UserEnrollmentForm = ({
const handleLegacyFormSubmit = async (values) => {
try {
await checkoutExecutiveEducation2U({
sku: productSKU,
sku: courseEntitlementProductSku,
userDetails: {
firstName: values.firstName,
lastName: values.lastName,
Expand All @@ -188,7 +186,7 @@ const UserEnrollmentForm = ({
} catch (error) {
const httpErrorStatus = error?.customAttributes?.httpErrorStatus;
if (httpErrorStatus === 422 && error?.message?.includes('User has already purchased the product.')) {
logInfo(`${enterpriseCustomer.uuid} user ${userId} has already purchased course ${productSKU}.`);
logInfo(`${enterpriseCustomer.uuid} user ${userId} has already purchased course ${courseEntitlementProductSku}.`);
await handleFormSubmissionSuccess();
} else {
setExternalCourseFormSubmissionError(error);
Expand Down Expand Up @@ -488,18 +486,10 @@ const UserEnrollmentForm = ({

UserEnrollmentForm.propTypes = {
className: PropTypes.string,
productSKU: PropTypes.string.isRequired,
activeCourseRun: PropTypes.shape({
key: PropTypes.string.isRequired,
}).isRequired,
userSubsidyApplicableToCourse: PropTypes.shape({
subsidyType: PropTypes.string,
}),
};

UserEnrollmentForm.defaultProps = {
className: undefined,
userSubsidyApplicableToCourse: undefined,
};

export default UserEnrollmentForm;
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,7 @@ const initialAppContextValue = {
authenticatedUser: mockAuthenticatedUser,
};

const mockActiveCourseRun = {
key: 'course-v1:edX+DemoX+Demo_Course',
};
const mockCourseRunKey = 'course-v1:edX+DemoX+Demo_Course';
const mockUserSubsidyApplicableToCourse = {
subsidyType: LEARNER_CREDIT_SUBSIDY_TYPE,
};
Expand All @@ -81,7 +79,7 @@ const UserEnrollmentFormWrapper = ({
enterpriseId = mockEnterpriseId,
productSKU = mockProductSKU,
onCheckoutSuccess = mockOnCheckoutSuccess,
activeCourseRun = mockActiveCourseRun,
courseRunKey = mockCourseRunKey,
userSubsidyApplicableToCourse = mockUserSubsidyApplicableToCourse,
courseContextValue = {
state: {
Expand All @@ -99,7 +97,7 @@ const UserEnrollmentFormWrapper = ({
enterpriseId={enterpriseId}
productSKU={productSKU}
onCheckoutSuccess={onCheckoutSuccess}
activeCourseRun={activeCourseRun}
courseRunKey={courseRunKey}
userSubsidyApplicableToCourse={userSubsidyApplicableToCourse}
/>
</CourseContext.Provider>
Expand Down Expand Up @@ -231,6 +229,12 @@ describe('UserEnrollmentForm', () => {
}),
}),
);
// Ensure the contentKey from the URL is passed along to the redeem endpoint via useStatefulEnroll.
expect(useStatefulEnroll.mock.calls[0][0]).toEqual(
expect.objectContaining({
contentKey: mockCourseRunKey,
}),
);

// simulate `useStatefulEnroll` calling `onSuccess` arg
const newTransaction = { state: 'committed' };
Expand Down

0 comments on commit 5e73e9c

Please sign in to comment.