Skip to content

Commit

Permalink
feat: integrate redemption into learner credit audit upgrade modal (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
adamstankiewicz authored Jul 22, 2024
1 parent ffeea85 commit a9026e3
Show file tree
Hide file tree
Showing 14 changed files with 454 additions and 120 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ jobs:
run: make extract_translations
- name: Test
run: npm run test
- name: Build
run: npm run build
- name: Upload Coverage
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
- name: Build
run: npm run build
5 changes: 4 additions & 1 deletion src/components/app/data/hooks/useCourseMetadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ export default function useCourseMetadata(queryOptions = {}) {
// `requestUrl.searchParams` uses `URLSearchParams`, which decodes `+` as a space, so we
// need to replace it with `+` again to be a valid course run key.
const courseRunKey = searchParams.get('course_run_key')?.replaceAll(' ', '+');
const isEnrollableBufferDays = useLateRedemptionBufferDays();
const isEnrollableBufferDays = useLateRedemptionBufferDays({
enabled: !!courseKey,
});
return useQuery({
...queryCourseMetadata(courseKey, courseRunKey),
enabled: !!courseKey,
...queryOptionsRest,
select: (data) => {
if (!data) {
Expand Down
13 changes: 9 additions & 4 deletions src/components/app/data/hooks/useEnterpriseCourseEnrollments.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ export const transformAllEnrollmentsByStatus = ({
contentAssignments,
}) => {
const enrollmentsByStatus = groupCourseEnrollmentsByStatus(enterpriseCourseEnrollments);
const licenseRequests = requests.subscriptionLicenses;
const couponCodeRequests = requests.couponCodes;
const licenseRequests = requests?.subscriptionLicenses || [];
const couponCodeRequests = requests.couponCodes || [];
const subsidyRequests = [].concat(licenseRequests).concat(couponCodeRequests);
enrollmentsByStatus[COURSE_STATUSES.requested] = subsidyRequests;
enrollmentsByStatus[COURSE_STATUSES.assigned] = contentAssignments;
enrollmentsByStatus[COURSE_STATUSES.assigned] = contentAssignments || [];
return enrollmentsByStatus;
};

Expand All @@ -32,24 +32,28 @@ export const transformAllEnrollmentsByStatus = ({
* requests), and content assignments for the active enterprise customer user.
* @returns {Types.UseQueryResult} The query results.
*/
export default function useEnterpriseCourseEnrollments() {
export default function useEnterpriseCourseEnrollments(queryOptions = {}) {
const isEnabled = queryOptions.enabled;
const { data: enterpriseCustomer } = useEnterpriseCustomer();
const { data: enterpriseCourseEnrollments } = useQuery({
...queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid),
select: (data) => data.map(transformCourseEnrollment),
enabled: isEnabled,
});
const { data: { requests } } = useBrowseAndRequest({
subscriptionLicensesQueryOptions: {
select: (data) => data.map((subsidyRequest) => transformSubsidyRequest({
subsidyRequest,
slug: enterpriseCustomer.slug,
})),
enabled: isEnabled,
},
couponCodesQueryOptions: {
select: (data) => data.map((subsidyRequest) => transformSubsidyRequest({
subsidyRequest,
slug: enterpriseCustomer.slug,
})),
enabled: isEnabled,
},
});
const { data: contentAssignments } = useRedeemablePolicies({
Expand All @@ -67,6 +71,7 @@ export default function useEnterpriseCourseEnrollments() {
});
return transformedAssignments;
},
enabled: isEnabled,
});

const allEnrollmentsByStatus = useMemo(() => transformAllEnrollmentsByStatus({
Expand Down
5 changes: 3 additions & 2 deletions src/components/app/data/hooks/useLateRedemptionBufferDays.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { getLateRedemptionBufferDays } from '../utils';
import useRedeemablePolicies from './useRedeemablePolicies';

export default function useLateRedemptionBufferDays() {
const { data: { redeemablePolicies } } = useRedeemablePolicies();
export default function useLateRedemptionBufferDays(queryOptions = {}) {
const { data } = useRedeemablePolicies(queryOptions);
const { redeemablePolicies } = data || {};
return getLateRedemptionBufferDays(redeemablePolicies);
}
3 changes: 3 additions & 0 deletions src/components/app/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,9 @@ export function retrieveErrorMessage(error) {
* if any policy has late redemption enabled.
*/
export function getLateRedemptionBufferDays(redeemablePolicies) {
if (!redeemablePolicies) {
return undefined;
}
const anyPolicyHasLateRedemptionEnabled = redeemablePolicies.some((policy) => (
// is_late_redemption_enabled=True on the serialized policy represents the fact that late
// redemption has been temporarily enabled by an operator for the policy. It will toggle
Expand Down
96 changes: 55 additions & 41 deletions src/components/course/EnrollModal.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import {
ActionRow, AlertModal, Button, Icon, Spinner, Stack,
ActionRow, AlertModal, Button, Icon, Stack, StatefulButton,
} from '@openedx/paragon';
import { Check } from '@openedx/paragon/icons';
import { FormattedMessage, defineMessages, useIntl } from '@edx/frontend-platform/i18n';
Expand All @@ -22,10 +21,25 @@ export const messages = defineMessages({
description: 'Text for the enroll button in the confirmation modal',
},
upgradeModalConfirmCta: {
id: 'enterprise.learner_portal.enroll-upgrade-modal.buttons.upgrade.text',
id: 'enterprise.learner_portal.enroll-upgrade-modal.buttons.upgrade.text.default',
defaultMessage: 'Confirm upgrade',
description: 'Text for the upgrade button in the confirmation modal',
},
upgradeModalConfirmCtaPending: {
id: 'enterprise.learner_portal.enroll-upgrade-modal.buttons.upgrade.text.pending',
defaultMessage: 'Upgrading...',
description: 'Text for the upgrade button in the confirmation modal, while an upgrade redemption is in pending.',
},
upgradeModalConfirmCtaComplete: {
id: 'enterprise.learner_portal.enroll-upgrade-modal.buttons.upgrade.text.complete',
defaultMessage: 'Upgraded',
description: 'Text for the upgrade button in the confirmation modal, when an upgrade redemption is complete.',
},
upgradeModalConfirmCtaError: {
id: 'enterprise.learner_portal.enroll-upgrade-modal.buttons.upgrade.text.error',
defaultMessage: 'Try again',
description: 'Text for the upgrade button in the confirmation modal, when an upgrade redemption is errored.',
},
modalCancelCta: {
id: 'enterprise.learner_portal.enroll-upgrade-modal.buttons.cancel.text',
defaultMessage: 'Cancel',
Expand Down Expand Up @@ -155,8 +169,12 @@ export const MODAL_TEXTS = {
</>
);
},
// TODO: button text should be stateful to account for async loading
button: messages.upgradeModalConfirmCta,
button: {
default: messages.upgradeModalConfirmCta,
pending: messages.upgradeModalConfirmCtaPending,
complete: messages.upgradeModalConfirmCtaComplete,
error: messages.upgradeModalConfirmCtaError,
},
title: messages.learnerCreditModalTitle,
},
};
Expand All @@ -174,7 +192,9 @@ const useModalTexts = ({ userSubsidyApplicableToCourse, couponCodesCount, course
if (subsidyType === COUPON_CODE_SUBSIDY_TYPE) {
return {
paymentRequiredForCourse: false,
buttonText: intl.formatMessage(HAS_COUPON_CODE.button),
buttonLabels: {
default: intl.formatMessage(HAS_COUPON_CODE.button),
},
enrollText: intl.formatMessage(HAS_COUPON_CODE.body, { couponCodesCount }),
titleText: intl.formatMessage(HAS_COUPON_CODE.title),
};
Expand All @@ -183,7 +203,9 @@ const useModalTexts = ({ userSubsidyApplicableToCourse, couponCodesCount, course
if (subsidyType === ENTERPRISE_OFFER_SUBSIDY_TYPE) {
return {
paymentRequiredForCourse: false,
buttonText: intl.formatMessage(HAS_ENTERPRISE_OFFER.button),
buttonLabels: {
default: intl.formatMessage(HAS_ENTERPRISE_OFFER.button),
},
enrollText: intl.formatMessage(
HAS_ENTERPRISE_OFFER.body({
offerType: userSubsidyApplicableToCourse.offerType,
Expand All @@ -199,7 +221,12 @@ const useModalTexts = ({ userSubsidyApplicableToCourse, couponCodesCount, course
if (subsidyType === LEARNER_CREDIT_SUBSIDY_TYPE) {
return {
paymentRequiredForCourse: false,
buttonText: intl.formatMessage(HAS_LEARNER_CREDIT.button),
buttonLabels: {
default: intl.formatMessage(HAS_LEARNER_CREDIT.button.default),
pending: intl.formatMessage(HAS_LEARNER_CREDIT.button.pending),
complete: intl.formatMessage(HAS_LEARNER_CREDIT.button.complete),
error: intl.formatMessage(HAS_LEARNER_CREDIT.button.error),
},
enrollText: <HAS_LEARNER_CREDIT.Body />,
titleText: intl.formatMessage(HAS_LEARNER_CREDIT.title),
};
Expand All @@ -208,43 +235,35 @@ const useModalTexts = ({ userSubsidyApplicableToCourse, couponCodesCount, course
// Otherwise, given subsidy type is not supported for the enroll/upgrade modal
return {
paymentRequiredForCourse: true,
buttonText: null,
buttonLabels: {
default: null,
},
enrollText: null,
titleText: null,
};
};

const EnrollModal = ({
isModalOpen,
setIsModalOpen,
confirmationButtonState,
onClose,
enrollmentUrl,
courseRunPrice,
userSubsidyApplicableToCourse,
couponCodesCount,
onEnroll,
}) => {
const intl = useIntl();
const [isLoading, setIsLoading] = useState(false);

const handleEnroll = async (e) => {
if (!onEnroll) {
return;
}
setIsLoading(true);
onEnroll(e);
setIsLoading(false);
};

const dismissModal = () => {
setIsModalOpen(false);
setIsLoading(false);
onClose();
};

const {
paymentRequiredForCourse,
titleText,
enrollText,
buttonText,
buttonLabels,
} = useModalTexts({
userSubsidyApplicableToCourse,
couponCodesCount,
Expand All @@ -257,6 +276,10 @@ const EnrollModal = ({
return null;
}

const confirmationButtonHref = userSubsidyApplicableToCourse.subsidyType === LEARNER_CREDIT_SUBSIDY_TYPE
? undefined
: enrollmentUrl;

return (
<AlertModal
isOpen={isModalOpen}
Expand All @@ -270,23 +293,12 @@ const EnrollModal = ({
>
<FormattedMessage {...messages.modalCancelCta} />
</Button>
{/* FIXME: the following Button should be using StatefulButton from @openedx/paragon */}
<Button
// TODO: remove no-op behavior for learner credit
href={userSubsidyApplicableToCourse.subsidyType === LEARNER_CREDIT_SUBSIDY_TYPE ? undefined : enrollmentUrl}
onClick={handleEnroll}
>
{isLoading && (
<Spinner
animation="border"
className="mr-2"
variant="light"
size="sm"
screenReaderText={intl.formatMessage(messages.confirmationCtaLoading)}
/>
)}
{buttonText}
</Button>
<StatefulButton
href={confirmationButtonHref}
onClick={onEnroll}
state={confirmationButtonState}
labels={buttonLabels}
/>
</ActionRow>
)}
onClose={dismissModal}
Expand All @@ -298,7 +310,8 @@ const EnrollModal = ({

EnrollModal.propTypes = {
isModalOpen: PropTypes.bool.isRequired,
setIsModalOpen: PropTypes.func.isRequired,
confirmationButtonState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
onClose: PropTypes.func.isRequired,
enrollmentUrl: PropTypes.string.isRequired,
userSubsidyApplicableToCourse: PropTypes.shape({
subsidyType: PropTypes.oneOf(
Expand All @@ -316,6 +329,7 @@ EnrollModal.propTypes = {
EnrollModal.defaultProps = {
userSubsidyApplicableToCourse: undefined,
onEnroll: undefined,
confirmationButtonState: 'default',
};

export default EnrollModal;
19 changes: 14 additions & 5 deletions src/components/course/data/hooks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -401,12 +401,15 @@ export const useExtractAndRemoveSearchParamsFromURL = () => {
*/
export const useTrackSearchConversionClickHandler = ({ href = undefined, eventName }) => {
const { data: enterpriseCustomer } = useEnterpriseCustomer();
const { data: { activeCourseRun } } = useCourseMetadata();
const {
algoliaSearchParams,
} = useContext(CourseContext);
const { data: courseMetadata } = useCourseMetadata();
const activeCourseRun = courseMetadata?.activeCourseRun;
const { algoliaSearchParams } = useContext(CourseContext) || {};

const handleClick = useCallback(
(e) => {
if (!activeCourseRun) {
return;
}
const { queryId, objectId } = algoliaSearchParams;
// If tracking is on a link with an external href destination, we must intentionally delay the default click
// behavior to allow enough time for the async analytics event call to resolve.
Expand All @@ -427,7 +430,13 @@ export const useTrackSearchConversionClickHandler = ({ href = undefined, eventNa
},
);
},
[algoliaSearchParams, href, enterpriseCustomer.uuid, eventName, activeCourseRun.key],
[
activeCourseRun,
algoliaSearchParams,
href,
enterpriseCustomer.uuid,
eventName,
],
);

return handleClick;
Expand Down
Loading

0 comments on commit a9026e3

Please sign in to comment.