diff --git a/.env.development b/.env.development index 3c0bbe0461..444be43740 100644 --- a/.env.development +++ b/.env.development @@ -56,3 +56,4 @@ GETSMARTER_PRIVACY_POLICY_URL='https://www.getsmarter.com/privacy-policy' GETSMARTER_LEARNER_DASHBOARD_URL='https://www.getsmarter.com/account' FEATURE_CONTENT_HIGHLIGHTS='true' FEATURE_ENABLE_EMET_REDEMPTION='true' +FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT='true' diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index 123c24efbc..eda170308c 100644 --- a/src/components/app/App.jsx +++ b/src/components/app/App.jsx @@ -21,6 +21,7 @@ import { EnterpriseInvitePage } from '../enterprise-invite'; import { ExecutiveEducation2UPage } from '../executive-education-2u'; import { ToastsProvider, Toasts } from '../Toasts'; import EnrollmentCompleted from '../executive-education-2u/EnrollmentCompleted'; +import { UserSubsidy } from '../enterprise-user-subsidy'; // Create a query client for @tanstack/react-query const queryClient = new QueryClient(); @@ -60,7 +61,9 @@ const App = () => { path="/:enterpriseSlug/executive-education-2u" render={(routeProps) => ( - + + + )} /> @@ -69,7 +72,9 @@ const App = () => { path="/:enterpriseSlug/executive-education-2u/enrollment-completed" render={(routeProps) => ( - + + + )} /> diff --git a/src/components/course/CourseSidebarPrice.jsx b/src/components/course/CourseSidebarPrice.jsx index 4825a2aa04..f80d0a43b1 100644 --- a/src/components/course/CourseSidebarPrice.jsx +++ b/src/components/course/CourseSidebarPrice.jsx @@ -8,8 +8,12 @@ import { numberWithPrecision } from './data/utils'; import { SubsidyRequestsContext } from '../enterprise-subsidy-requests'; import { ENTERPRISE_OFFER_SUBSIDY_TYPE, LEARNER_CREDIT_SUBSIDY_TYPE, LICENSE_SUBSIDY_TYPE } from './data/constants'; import { canUserRequestSubsidyForCourse } from './enrollment/utils'; +import { UserSubsidyContext } from '../enterprise-user-subsidy'; +import { useIsCourseAssigned } from './data/hooks'; +import { features } from '../../config'; export const INCLUDED_IN_SUBSCRIPTION_MESSAGE = 'Included in your subscription'; +export const ASSIGNED_COURSE_MESSAGE = 'This course is assigned to you. The price of this course is already covered by your organization.'; export const FREE_WHEN_APPROVED_MESSAGE = 'Free to me\n(when approved)'; export const COVERED_BY_ENTERPRISE_OFFER_MESSAGE = 'This course can be purchased with your organization\'s learner credit'; @@ -20,7 +24,15 @@ const CourseSidebarPrice = () => { coursePrice, currency, subsidyRequestCatalogsApplicableToCourse, + state: { + course, + }, } = useContext(CourseContext); + const { + redeemableLearnerCreditPolicies, + } = useContext(UserSubsidyContext); + const isCourseAssigned = useIsCourseAssigned(redeemableLearnerCreditPolicies, course?.key); + const { subsidyRequestConfiguration } = useContext(SubsidyRequestsContext); if (!coursePrice) { @@ -35,6 +47,17 @@ const CourseSidebarPrice = () => { ); + if (features.FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT && coursePrice && isCourseAssigned) { + return ( + <> +
+ {crossedOutOriginalPrice} $0 +
+ {ASSIGNED_COURSE_MESSAGE} + + ); + } + // Case 1: License subsidy found if (userSubsidyApplicableToCourse?.subsidyType === LICENSE_SUBSIDY_TYPE) { return ( diff --git a/src/components/course/course-header/CourseHeader.jsx b/src/components/course/course-header/CourseHeader.jsx index 67fbc9f033..037e0c7d2f 100644 --- a/src/components/course/course-header/CourseHeader.jsx +++ b/src/components/course/course-header/CourseHeader.jsx @@ -5,6 +5,7 @@ import { Container, Row, Col, + Badge, } from '@edx/paragon'; import { Link } from 'react-router-dom'; import { AppContext } from '@edx/frontend-platform/react'; @@ -18,12 +19,14 @@ import { getDefaultProgram, formatProgramType, } from '../data/utils'; -import { useCoursePartners } from '../data/hooks'; +import { useCoursePartners, useIsCourseAssigned } from '../data/hooks'; import LicenseRequestedAlert from '../LicenseRequestedAlert'; import SubsidyRequestButton from '../SubsidyRequestButton'; import CourseReview from '../CourseReview'; import CoursePreview from './CoursePreview'; +import { UserSubsidyContext } from '../../enterprise-user-subsidy'; +import { features } from '../../../config'; const CourseHeader = () => { const { enterpriseConfig } = useContext(AppContext); @@ -34,6 +37,11 @@ const CourseHeader = () => { }, isPolicyRedemptionEnabled, } = useContext(CourseContext); + const { + redeemableLearnerCreditPolicies, + } = useContext(UserSubsidyContext); + const isCourseAssigned = useIsCourseAssigned(redeemableLearnerCreditPolicies, course?.key); + const [partners] = useCoursePartners(course); const defaultProgram = useMemo( @@ -80,8 +88,9 @@ const CourseHeader = () => { ))} )} -
+

{course.title}

+ {(features.FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT && isCourseAssigned) && Assigned}
{course.shortDescription && (
{ hasSuccessfulRedemption, ]); }; + +export const useIsCourseAssigned = (redeemableLCPolicies, courseKey) => { + if (!redeemableLCPolicies || redeemableLCPolicies.length < 1) { return false; } + + const learnerContentAssignmentsArray = redeemableLCPolicies.flatMap(item => item?.learnerContentAssignments || []); + if (!learnerContentAssignmentsArray) { return false; } + + return learnerContentAssignmentsArray.some(assignment => assignment?.contentKey === courseKey); +}; diff --git a/src/components/course/routes/tests/ExternalCourseEnrollmentConfirmation.test.jsx b/src/components/course/routes/tests/ExternalCourseEnrollmentConfirmation.test.jsx index 38bd484f54..9de97f5d6f 100644 --- a/src/components/course/routes/tests/ExternalCourseEnrollmentConfirmation.test.jsx +++ b/src/components/course/routes/tests/ExternalCourseEnrollmentConfirmation.test.jsx @@ -6,6 +6,7 @@ import { AppContext } from '@edx/frontend-platform/react'; import ExternalCourseEnrollmentConfirmation from '../ExternalCourseEnrollmentConfirmation'; import { CourseContext } from '../../CourseContextProvider'; import { DISABLED_ENROLL_REASON_TYPES, LEARNER_CREDIT_SUBSIDY_TYPE } from '../../data/constants'; +import { UserSubsidyContext } from '../../../enterprise-user-subsidy'; jest.mock('@edx/frontend-platform/config', () => ({ ...jest.requireActual('@edx/frontend-platform/config'), @@ -59,12 +60,22 @@ const appContextValue = { const ExternalCourseEnrollmentConfirmationWrapper = ({ courseContextValue = baseCourseContextValue, + initialUserSubsidyState = { + subscriptionLicense: null, + couponCodes: { + couponCodes: [{ discountValue: 90 }], + couponCodesCount: 0, + }, + redeemableLearnerCreditPolicies: [], + }, }) => ( - - - + + + + + ); diff --git a/src/components/course/tests/CourseSidebarPrice.test.jsx b/src/components/course/tests/CourseSidebarPrice.test.jsx index a40488cdd5..e3557e0698 100644 --- a/src/components/course/tests/CourseSidebarPrice.test.jsx +++ b/src/components/course/tests/CourseSidebarPrice.test.jsx @@ -12,6 +12,7 @@ import { SUBSIDY_DISCOUNT_TYPE_MAP, ENTERPRISE_OFFER_SUBSIDY_TYPE, } from '../data/constants'; +import { UserSubsidyContext } from '../../enterprise-user-subsidy'; const appStateWithOrigPriceHidden = { enterpriseConfig: { @@ -95,12 +96,22 @@ const SidebarWithContext = ({ initialAppState = appStateWithOrigPriceHidden, subsidyRequestsState = defaultSubsidyRequestsState, courseContextProps = {}, + initialUserSubsidyState = { + subscriptionLicense: null, + couponCodes: { + couponCodes: [{ discountValue: 90 }], + couponCodesCount: 0, + }, + redeemableLearnerCreditPolicies: [], + }, }) => ( - - - + + + + + ); diff --git a/src/components/executive-education-2u/EnrollmentCompleted.jsx b/src/components/executive-education-2u/EnrollmentCompleted.jsx index 8a5b0e6dc0..f06caf2cdf 100644 --- a/src/components/executive-education-2u/EnrollmentCompleted.jsx +++ b/src/components/executive-education-2u/EnrollmentCompleted.jsx @@ -23,7 +23,9 @@ const EnrollmentCompleted = () => { courseMetadata={location.state.data} enrollmentCompleted /> - +
); diff --git a/src/components/executive-education-2u/EnrollmentCompleted.test.jsx b/src/components/executive-education-2u/EnrollmentCompleted.test.jsx index 0075c8e411..080e4c58c1 100644 --- a/src/components/executive-education-2u/EnrollmentCompleted.test.jsx +++ b/src/components/executive-education-2u/EnrollmentCompleted.test.jsx @@ -6,6 +6,8 @@ import { renderWithRouter } from '@edx/frontend-enterprise-utils'; import EnrollmentCompleted from './EnrollmentCompleted'; import { CURRENCY_USD } from '../course/data/constants'; +import { CourseContext } from '../course/CourseContextProvider'; +import { UserSubsidyContext } from '../enterprise-user-subsidy'; const enterpriseSlug = 'test-enterprise-slug'; const initialAppContextValue = { @@ -44,14 +46,45 @@ jest.mock('@edx/frontend-platform/config', () => ({ getConfig: jest.fn(() => ({ GETSMARTER_STUDENT_TC_URL: 'https://example.url', GETSMARTER_LEARNER_DASHBOARD_URL: 'https://getsmarter.example.com/account', + BASE_URL: 'http://enterprise.edx.org/', })), })); +const mockCourseRunKey = 'course-run-key'; +const mockCourseRun = { + key: mockCourseRunKey, + uuid: 'course-run-uuid', +}; +const mockCourseKey = 'course-key'; +const defaultCourseContext = { + state: { + availableCourseRuns: [mockCourseRun], + userEntitlements: [], + userEnrollments: [], + course: { key: mockCourseKey, entitlements: [] }, + catalog: { catalogList: [] }, + }, + subsidyRequestCatalogsApplicableToCourse: [], + missingUserSubsidyReason: undefined, + redeemabilityPerContentKey: [], +}; const EnrollmentCompletedWrapper = ({ appContextValue = initialAppContextValue, + initialUserSubsidyState = { + subscriptionLicense: null, + couponCodes: { + couponCodes: [{ discountValue: 90 }], + couponCodesCount: 0, + }, + redeemableLearnerCreditPolicies: [], + }, }) => ( - + + + + + ); diff --git a/src/components/executive-education-2u/ExecutiveEducation2UPage.jsx b/src/components/executive-education-2u/ExecutiveEducation2UPage.jsx index 09c67d34c4..53d32f601a 100644 --- a/src/components/executive-education-2u/ExecutiveEducation2UPage.jsx +++ b/src/components/executive-education-2u/ExecutiveEducation2UPage.jsx @@ -20,9 +20,15 @@ import CourseSummaryCard from './components/CourseSummaryCard'; import RegistrationSummaryCard from './components/RegistrationSummaryCard'; import { getActiveCourseRun, getCourseStartDate } from '../course/data/utils'; import { getCourseOrganizationDetails, getExecutiveEducationCoursePrice } from './utils'; +import { UserSubsidyContext } from '../enterprise-user-subsidy'; +import { useIsCourseAssigned } from '../course/data/hooks'; +import { features } from '../../config'; const ExecutiveEducation2UPage = () => { const { enterpriseConfig } = useContext(AppContext); + const { + redeemableLearnerCreditPolicies, + } = useContext(UserSubsidyContext); const activeQueryParams = useActiveQueryParams(); const history = useHistory(); @@ -30,12 +36,10 @@ const ExecutiveEducation2UPage = () => { const hasRequiredQueryParams = (activeQueryParams.has('course_uuid') && activeQueryParams.has('sku')); return enterpriseConfig.enableExecutiveEducation2UFulfillment && hasRequiredQueryParams; }, [enterpriseConfig, activeQueryParams]); - const { isLoadingContentMetadata: isLoading, contentMetadata } = useExecutiveEducation2UContentMetadata({ courseUUID: activeQueryParams.get('course_uuid'), isExecEd2UFulfillmentEnabled, }); - useEffect(() => { if (!enterpriseConfig.enableExecutiveEducation2UFulfillment) { logError(`Enterprise ${enterpriseConfig.uuid} does not have executive education (2U) fulfillment enabled.`); @@ -55,6 +59,7 @@ const ExecutiveEducation2UPage = () => { sku: activeQueryParams.get('sku'), }; + const isCourseAssigned = useIsCourseAssigned(redeemableLearnerCreditPolicies, contentMetadata?.key); const courseMetadata = useMemo(() => { if (contentMetadata) { const activeCourseRun = getActiveCourseRun(contentMetadata); @@ -77,6 +82,7 @@ const ExecutiveEducation2UPage = () => { marketingUrl: organizationDetails.organizationMarketingUrl, }, title: contentMetadata.title, + key: contentMetadata.key, startDate: getCourseStartDate({ contentMetadata, courseRun: activeCourseRun }), duration: getDuration(), priceDetails: getExecutiveEducationCoursePrice(contentMetadata), @@ -134,7 +140,9 @@ const ExecutiveEducation2UPage = () => {   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. + {(features.FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT && isCourseAssigned) + ? 'Your learning administrator already allocated funds towards this registration.' + : 'Your Learner Credit funds will be redeemed at this point.'}

diff --git a/src/components/executive-education-2u/ExecutiveEducation2UPage.test.jsx b/src/components/executive-education-2u/ExecutiveEducation2UPage.test.jsx index 35ba332b69..39825e7604 100644 --- a/src/components/executive-education-2u/ExecutiveEducation2UPage.test.jsx +++ b/src/components/executive-education-2u/ExecutiveEducation2UPage.test.jsx @@ -12,6 +12,8 @@ import { useExecutiveEducation2UContentMetadata, } from './data'; import { CURRENCY_USD, PAID_EXECUTIVE_EDUCATION } from '../course/data/constants'; +import { UserSubsidyContext } from '../enterprise-user-subsidy'; +import { CourseContext } from '../course/CourseContextProvider'; const mockReceiptPageUrl = 'https://edx.org'; const courseTitle = 'edX Demonstration Course'; @@ -115,12 +117,35 @@ const initialAppContextValue = { }, }; +const baseCourseContextValue = { + state: { + courseEntitlementProductSku: 'test-sku', + course: { + organizationShortCodeOverride: 'Test Org', + organizationLogoOverrideUrl: 'https://test.org/logo.png', + }, + }, + missingUserSubsidyReason: undefined, +}; const ExecutiveEducation2UPageWrapper = ({ appContextValue = initialAppContextValue, + courseContextValue = baseCourseContextValue, + initialUserSubsidyState = { + subscriptionLicense: null, + couponCodes: { + couponCodes: [{ discountValue: 90 }], + couponCodesCount: 0, + }, + redeemableLearnerCreditPolicies: [], + }, }) => ( - + + + + + ); diff --git a/src/components/executive-education-2u/components/CourseSummaryCard.jsx b/src/components/executive-education-2u/components/CourseSummaryCard.jsx index ec9cfa82c4..6b13dcc3e2 100644 --- a/src/components/executive-education-2u/components/CourseSummaryCard.jsx +++ b/src/components/executive-education-2u/components/CourseSummaryCard.jsx @@ -86,6 +86,7 @@ CourseSummaryCard.propTypes = { logoImgUrl: PropTypes.string, }).isRequired, title: PropTypes.string.isRequired, + key: PropTypes.string.isRequired, startDate: PropTypes.string.isRequired, duration: PropTypes.string.isRequired, priceDetails: PropTypes.shape({ diff --git a/src/components/executive-education-2u/components/EnrollmentCompletedSummaryCard.jsx b/src/components/executive-education-2u/components/EnrollmentCompletedSummaryCard.jsx index 7e6878570d..e5133471a4 100644 --- a/src/components/executive-education-2u/components/EnrollmentCompletedSummaryCard.jsx +++ b/src/components/executive-education-2u/components/EnrollmentCompletedSummaryCard.jsx @@ -1,22 +1,31 @@ import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; import { Card, Col, Hyperlink, Row, } from '@edx/paragon'; import { getConfig } from '@edx/frontend-platform/config'; import { AppContext } from '@edx/frontend-platform/react'; import GetSmarterLogo from '../../../assets/icons/get-smarter-logo-black.svg'; +import { UserSubsidyContext } from '../../enterprise-user-subsidy'; +import { useIsCourseAssigned } from '../../course/data/hooks'; +import { features } from '../../../config'; -const EnrollmentCompletedSummaryCard = () => { +const EnrollmentCompletedSummaryCard = ({ courseKey }) => { const config = getConfig(); const { - enterpriseConfig: { authOrgId }, + enterpriseConfig: { authOrgId, slug }, } = useContext(AppContext); - + const { + redeemableLearnerCreditPolicies, + } = useContext(UserSubsidyContext); + const isCourseAssigned = useIsCourseAssigned(redeemableLearnerCreditPolicies, courseKey); const externalDashboardQueryParams = new URLSearchParams({ org_id: authOrgId, }); const externalDashboardQueryString = externalDashboardQueryParams ? `?${externalDashboardQueryParams.toString()}` : ''; const externalDashboardUrl = `${config.GETSMARTER_LEARNER_DASHBOARD_URL}${externalDashboardQueryString ?? ''}`; + const enterpriseSlug = `/${slug}`; + const dashboardUrl = `${config.BASE_URL}${enterpriseSlug}`; return ( @@ -39,10 +48,11 @@ const EnrollmentCompletedSummaryCard = () => {
GetSmarter will email you when your course starts. Alternatively, you can visit your{' '} - GetSmarter learner dashboard + {(features.FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT && isCourseAssigned) ? 'edX dashboard' : 'GetSmarter learner dashboard'} {' '}for course status updates.
@@ -72,4 +82,8 @@ const EnrollmentCompletedSummaryCard = () => { ); }; +EnrollmentCompletedSummaryCard.propTypes = { + courseKey: PropTypes.string.isRequired, +}; + export default EnrollmentCompletedSummaryCard; diff --git a/src/config/constants.js b/src/config/constants.js index 4eaf2c7024..0f1f98d0e1 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -8,6 +8,7 @@ export const FEATURE_ENABLE_MY_CAREER = 'ENABLE_MY_CAREER'; export const FEATURE_PROGRAM_TYPE_FACET = 'ENABLE_PROGRAM_TYPE_FACET'; export const FEATURE_ENABLE_AUTO_APPLIED_LICENSES = 'FEATURE_ENABLE_AUTO_APPLIED_LICENSES'; export const FEATURE_ENROLL_WITH_ENTERPRISE_OFFERS = 'FEATURE_ENROLL_WITH_ENTERPRISE_OFFERS'; +export const FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT = 'FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT'; // Subscription expiration constants export const SUBSCRIPTION_DAYS_REMAINING_SEVERE = 60; diff --git a/src/config/index.js b/src/config/index.js index 9d7f64bdde..f000bb7fa9 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -10,6 +10,7 @@ import { FEATURE_PROGRAM_TYPE_FACET, FEATURE_ENABLE_AUTO_APPLIED_LICENSES, FEATURE_ENROLL_WITH_ENTERPRISE_OFFERS, + FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT, } from './constants'; export const features = { @@ -27,4 +28,6 @@ export const features = { || hasFeatureFlagEnabled(FEATURE_ENABLE_PATHWAY_PROGRESS), FEATURE_ENABLE_MY_CAREER: process.env.FEATURE_ENABLE_MY_CAREER || hasFeatureFlagEnabled(FEATURE_ENABLE_MY_CAREER), + FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT: process.env.FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT + || hasFeatureFlagEnabled(FEATURE_ENABLE_TOP_DOWN_ASSIGNMENT), };