diff --git a/src/components/app/data/services/subsidies/index.js b/src/components/app/data/services/subsidies/index.js index 2077ee8477..13288d358c 100644 --- a/src/components/app/data/services/subsidies/index.js +++ b/src/components/app/data/services/subsidies/index.js @@ -65,7 +65,7 @@ export async function fetchEnterpriseOffers(enterpriseId, options = {}) { // Redeemable Policies /** - * TODO + * Fetches the redeemable policies for the specified enterprise and user. * @param {*} enterpriseUUID * @param {*} userID * @returns diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx index d4f6f249dc..1454f8baa5 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/InProgressCourseCard.jsx @@ -85,9 +85,8 @@ export const InProgressCourseCard = ({ const [isMarkCompleteModalOpen, setIsMarkCompleteModalOpen] = useState(false); const { courseCards } = useContext(AppContext); const { data: enterpriseCustomer } = useEnterpriseCustomer(); - const updateCourseEnrollmentStatus = useUpdateCourseEnrollmentStatus({ enterpriseCustomer }); + const updateCourseEnrollmentStatus = useUpdateCourseEnrollmentStatus(); const isExecutiveEducation = EXECUTIVE_EDUCATION_COURSE_MODES.includes(mode); - const coursewareOrUpgradeLink = useLinkToCourse({ linkToCourse, subsidyForCourse, @@ -196,7 +195,6 @@ export const InProgressCourseCard = ({ updateCourseEnrollmentStatus({ courseRunId: response.courseRunId, newStatus: response.courseRunStatus, - savedForLater: response.savedForLater, }); navigate('.', { replace: true, diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/SavedForLaterCourseCard.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/SavedForLaterCourseCard.jsx index 78018d05f2..df80ee9eae 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/SavedForLaterCourseCard.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/SavedForLaterCourseCard.jsx @@ -35,8 +35,7 @@ const SavedForLaterCourseCard = (props) => { const navigate = useNavigate(); const { data: enterpriseCustomer } = useEnterpriseCustomer(); - const updateCourseEnrollmentStatus = useUpdateCourseEnrollmentStatus({ enterpriseCustomer }); - + const updateCourseEnrollmentStatus = useUpdateCourseEnrollmentStatus(); const [isModalOpen, setIsModalOpen] = useState(false); const handleMoveToInProgressOnClose = () => { @@ -63,7 +62,6 @@ const SavedForLaterCourseCard = (props) => { updateCourseEnrollmentStatus({ courseRunId: response.courseRunId, newStatus: response.courseRunStatus, - savedForLater: response.savedForLater, }); navigate('.', { replace: true, diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/unenroll/UnenrollModal.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/unenroll/UnenrollModal.jsx index 5c4c7da813..87293009d0 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/unenroll/UnenrollModal.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/unenroll/UnenrollModal.jsx @@ -1,14 +1,19 @@ -import React, { useContext, useState } from 'react'; +import { useContext, useState } from 'react'; import PropTypes from 'prop-types'; import { useQueryClient } from '@tanstack/react-query'; import { - AlertModal, Alert, StatefulButton, Button, ActionRow, + ActionRow, Alert, AlertModal, Button, StatefulButton, } from '@openedx/paragon'; -import { logError } from '@edx/frontend-platform/logging'; +import { logError, logInfo } from '@edx/frontend-platform/logging'; import { ToastsContext } from '../../../../../Toasts'; import { unenrollFromCourse } from './data'; -import { queryEnterpriseCourseEnrollments, useEnterpriseCustomer } from '../../../../../app/data'; +import { + isBFFEnabledForEnterpriseCustomer, + queryEnterpriseCourseEnrollments, + queryEnterpriseLearnerDashboardBFF, + useEnterpriseCustomer, +} from '../../../../../app/data'; const btnLabels = { default: 'Unenroll', @@ -33,6 +38,42 @@ const UnenrollModal = ({ onClose(); }; + const updateQueriesAfterUnenrollment = () => { + const enrollmentForCourseFilter = (enrollment) => enrollment.courseRunId !== courseRunId; + + const isBFFEnabled = isBFFEnabledForEnterpriseCustomer(enterpriseCustomer.uuid); + if (isBFFEnabled) { + // Determine which BFF queries need to be updated after unenrolling. + const dashboardBFFQueryKey = queryEnterpriseLearnerDashboardBFF({ + enterpriseSlug: enterpriseCustomer.slug, + }).queryKey; + const bffQueryKeysToUpdate = [dashboardBFFQueryKey]; + // Update the enterpriseCourseEnrollments data in the cache for each BFF query. + bffQueryKeysToUpdate.forEach((queryKey) => { + const existingBFFData = queryClient.getQueryData(queryKey); + if (!existingBFFData) { + logInfo(`Skipping optimistic cache update of ${JSON.stringify(queryKey)} as no cached query data exists yet.`); + return; + } + const updatedBFFData = { + ...existingBFFData, + enterpriseCourseEnrollments: existingBFFData.enterpriseCourseEnrollments.filter(enrollmentForCourseFilter), + }; + queryClient.setQueryData(queryKey, updatedBFFData); + }); + } + + // Update the legacy queryEnterpriseCourseEnrollments cache as well. + const enterpriseCourseEnrollmentsQueryKey = queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid).queryKey; + const existingCourseEnrollmentsData = queryClient.getQueryData(enterpriseCourseEnrollmentsQueryKey); + if (!existingCourseEnrollmentsData) { + logInfo(`Skipping optimistic cache update of ${JSON.stringify(enterpriseCourseEnrollmentsQueryKey)} as no cached query data exists yet.`); + return; + } + const updatedCourseEnrollmentsData = existingCourseEnrollmentsData.filter(enrollmentForCourseFilter); + queryClient.setQueryData(enterpriseCourseEnrollmentsQueryKey, updatedCourseEnrollmentsData); + }; + const handleUnenrollButtonClick = async () => { setBtnState('pending'); try { @@ -43,14 +84,7 @@ const UnenrollModal = ({ setBtnState('default'); return; } - const enrollmentsQueryKey = queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid).queryKey; - const existingEnrollments = queryClient.getQueryData(enrollmentsQueryKey); - // Optimistically remove the unenrolled course from the list of enrollments in - // the cache for the `queryEnterpriseCourseEnrollments` query. - queryClient.setQueryData( - enrollmentsQueryKey, - existingEnrollments.filter((enrollment) => enrollment.courseRunId !== courseRunId), - ); + updateQueriesAfterUnenrollment(); addToast('You have been unenrolled from the course.'); onSuccess(); }; diff --git a/src/components/dashboard/main-content/course-enrollments/course-cards/unenroll/UnenrollModal.test.jsx b/src/components/dashboard/main-content/course-enrollments/course-cards/unenroll/UnenrollModal.test.jsx index 5f20835d6d..698691d9cf 100644 --- a/src/components/dashboard/main-content/course-enrollments/course-cards/unenroll/UnenrollModal.test.jsx +++ b/src/components/dashboard/main-content/course-enrollments/course-cards/unenroll/UnenrollModal.test.jsx @@ -2,14 +2,23 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/extend-expect'; - +import { logInfo } from '@edx/frontend-platform/logging'; import { COURSE_STATUSES } from '../../../../../../constants'; import { unenrollFromCourse } from './data'; import UnenrollModal from './UnenrollModal'; import { ToastsContext } from '../../../../../Toasts'; -import { queryEnterpriseCourseEnrollments, useEnterpriseCustomer } from '../../../../../app/data'; +import { + isBFFEnabledForEnterpriseCustomer, + learnerDashboardBFFResponse, + queryEnterpriseCourseEnrollments, + queryEnterpriseLearnerDashboardBFF, + useEnterpriseCustomer, +} from '../../../../../app/data'; import { queryClient } from '../../../../../../utils/tests'; -import { enterpriseCourseEnrollmentFactory, enterpriseCustomerFactory } from '../../../../../app/data/services/data/__factories__'; +import { + enterpriseCourseEnrollmentFactory, + enterpriseCustomerFactory, +} from '../../../../../app/data/services/data/__factories__'; jest.mock('./data', () => ({ unenrollFromCourse: jest.fn(), @@ -22,10 +31,21 @@ jest.mock('@edx/frontend-platform/logging', () => ({ jest.mock('../../../../../app/data', () => ({ ...jest.requireActual('../../../../../app/data'), useEnterpriseCustomer: jest.fn(), + isBFFEnabledForEnterpriseCustomer: jest.fn(), + fetchEnterpriseLearnerDashboard: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logInfo: jest.fn(), })); const mockEnterpriseCustomer = enterpriseCustomerFactory(); const mockEnterpriseCourseEnrollment = enterpriseCourseEnrollmentFactory(); +const mockEnterpriseCourseEnrollments = [mockEnterpriseCourseEnrollment]; +const mockBFFDashboardDataWithEnrollments = { + ...learnerDashboardBFFResponse, + enterpriseCourseEnrollments: mockEnterpriseCourseEnrollments, +}; const mockOnClose = jest.fn(); const mockOnSuccess = jest.fn(); @@ -41,12 +61,24 @@ const baseUnenrollModalProps = { const mockAddToast = jest.fn(); let mockQueryClient; -const UnenrollModalWrapper = ({ ...props }) => { +const UnenrollModalWrapper = ({ + existingEnrollmentsQueryData = mockEnterpriseCourseEnrollments, + existingBFFDashboardQueryData = mockBFFDashboardDataWithEnrollments, + ...props +}) => { mockQueryClient = queryClient(); - mockQueryClient.setQueryData( - queryEnterpriseCourseEnrollments(mockEnterpriseCustomer.uuid).queryKey, - [mockEnterpriseCourseEnrollment], - ); + if (existingEnrollmentsQueryData) { + mockQueryClient.setQueryData( + queryEnterpriseCourseEnrollments(mockEnterpriseCustomer.uuid).queryKey, + existingEnrollmentsQueryData, + ); + } + if (existingBFFDashboardQueryData) { + mockQueryClient.setQueryData( + queryEnterpriseLearnerDashboardBFF({ enterpriseSlug: mockEnterpriseCustomer.slug }).queryKey, + existingBFFDashboardQueryData, + ); + } return ( @@ -60,6 +92,7 @@ describe('', () => { beforeEach(() => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); + isBFFEnabledForEnterpriseCustomer.mockReturnValue(false); }); test('should remain closed when `isOpen` is false', () => { @@ -92,20 +125,104 @@ describe('', () => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); - test('should handle unenroll click', async () => { + test.each([ + // BFF enabled + { + isBFFEnabled: true, + existingBFFDashboardQueryData: mockBFFDashboardDataWithEnrollments, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: true, + existingBFFDashboardQueryData: mockBFFDashboardDataWithEnrollments, + existingEnrollmentsQueryData: null, + }, + { + isBFFEnabled: true, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: true, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: null, + }, + // BFF disabled + { + isBFFEnabled: false, + existingBFFDashboardQueryData: mockBFFDashboardDataWithEnrollments, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: false, + existingBFFDashboardQueryData: mockBFFDashboardDataWithEnrollments, + existingEnrollmentsQueryData: null, + }, + { + isBFFEnabled: false, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: false, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: null, + }, + ])('should handle unenroll click (%s)', async ({ + isBFFEnabled, + existingBFFDashboardQueryData, + existingEnrollmentsQueryData, + }) => { + isBFFEnabledForEnterpriseCustomer.mockReturnValue(isBFFEnabled); unenrollFromCourse.mockResolvedValueOnce(); const props = { ...baseUnenrollModalProps, isOpen: true, + existingBFFDashboardQueryData, + existingEnrollmentsQueryData, }; render(); userEvent.click(screen.getByText('Unenroll')); await waitFor(() => { - const updatedEnrollments = mockQueryClient.getQueryData( + const bffDashboardData = mockQueryClient.getQueryData( + queryEnterpriseLearnerDashboardBFF({ enterpriseSlug: mockEnterpriseCustomer.slug }).queryKey, + ); + let expectedLogInfoCalls = 0; + if (isBFFEnabled) { + // Only verify the BFF queryEnterpriseCourseEnrollments cache is updated if BFF feature is enabled. + let expectedBFFDashboardData; + if (existingBFFDashboardQueryData) { + expectedBFFDashboardData = learnerDashboardBFFResponse; + } else { + expectedLogInfoCalls += 1; + } + expect(bffDashboardData).toEqual(expectedBFFDashboardData); + } else { + let expectedBFFDashboardData; + if (existingBFFDashboardQueryData) { + expectedBFFDashboardData = existingBFFDashboardQueryData; + } + // Without BFF feature enabled, the original query cache data should remain, if any. + expect(bffDashboardData).toEqual(expectedBFFDashboardData); + } + + // Always verify the legacy queryEnterpriseCourseEnrollments cache is updated. + const legacyEnrollmentsData = mockQueryClient.getQueryData( queryEnterpriseCourseEnrollments(mockEnterpriseCustomer.uuid).queryKey, ); - expect(updatedEnrollments).toEqual([]); + let expectedLegacyEnrollmentsData; + if (existingEnrollmentsQueryData) { + expectedLegacyEnrollmentsData = []; + } else { + expectedLogInfoCalls += 1; + } + expect(legacyEnrollmentsData).toEqual(expectedLegacyEnrollmentsData); + + // Verify logInfo calls + expect(logInfo).toHaveBeenCalledTimes(expectedLogInfoCalls); + + // Verify side effects expect(mockOnSuccess).toHaveBeenCalledTimes(1); expect(mockAddToast).toHaveBeenCalledTimes(1); expect(mockAddToast).toHaveBeenCalledWith('You have been unenrolled from the course.'); diff --git a/src/components/dashboard/main-content/course-enrollments/data/hooks.js b/src/components/dashboard/main-content/course-enrollments/data/hooks.js index 1aaa7eafe1..47eac0523b 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/hooks.js +++ b/src/components/dashboard/main-content/course-enrollments/data/hooks.js @@ -4,7 +4,7 @@ import { import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AppContext } from '@edx/frontend-platform/react'; import { camelCaseObject } from '@edx/frontend-platform/utils'; -import { logError } from '@edx/frontend-platform/logging'; +import { logError, logInfo } from '@edx/frontend-platform/logging'; import { sendEnterpriseTrackEventWithDelay } from '@edx/frontend-enterprise-utils'; import _camelCase from 'lodash.camelcase'; import _cloneDeep from 'lodash.clonedeep'; @@ -24,10 +24,12 @@ import { COUPON_CODE_SUBSIDY_TYPE, getSubsidyToApplyForCourse, groupCourseEnrollmentsByStatus, + isBFFEnabledForEnterpriseCustomer, isEnrollmentUpgradeable, LEARNER_CREDIT_SUBSIDY_TYPE, LICENSE_SUBSIDY_TYPE, queryEnterpriseCourseEnrollments, + queryEnterpriseLearnerDashboardBFF, queryRedeemablePolicies, transformCourseEnrollment, useCanUpgradeWithLearnerCredit, @@ -525,32 +527,62 @@ export function useCourseEnrollmentsBySection(courseEnrollmentsByStatus) { }; } -export const useUpdateCourseEnrollmentStatus = ({ enterpriseCustomer }) => { +/** + * - Provides a helper function to update the course enrollment status in the query cache. + * @param {Object} args + * @param {Object} args.enterpriseCustomer - Object containing enterprise customer data. + * @returns {Function} - Returns a function to update a course enrollment status in the query cache. The + * function accepts a courseRunId and newStatus (i.e., the new status for which to update the enrollment). + */ +export function useUpdateCourseEnrollmentStatus() { const queryClient = useQueryClient(); + const { data: enterpriseCustomer } = useEnterpriseCustomer(); + return useCallback(({ courseRunId, newStatus }) => { + // Transformation to update the course enrollment status. + const transformUpdatedEnrollment = (enrollment) => { + if (enrollment.courseRunId !== courseRunId) { + return enrollment; + } + return { + ...enrollment, + courseRunStatus: newStatus, + }; + }; - const updateCourseEnrollmentStatus = useCallback(({ courseRunId, newStatus, savedForLater }) => { - const enrollmentsQueryKey = queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid).queryKey; - const existingEnrollments = queryClient.getQueryData(enrollmentsQueryKey); - queryClient.setQueryData( - enrollmentsQueryKey, - existingEnrollments.map((enrollment) => { - if (enrollment.courseRunId === courseRunId) { - return { - ...enrollment, - courseRunStatus: newStatus, - savedForLater, - }; + const isBFFEnabled = isBFFEnabledForEnterpriseCustomer(enterpriseCustomer.uuid); + if (isBFFEnabled) { + // Determine which BFF queries need to be updated after updating enrollment status. + const dashboardBFFQueryKey = queryEnterpriseLearnerDashboardBFF({ + enterpriseSlug: enterpriseCustomer.slug, + }).queryKey; + + const bffQueryKeysToUpdate = [dashboardBFFQueryKey]; + // Update the enterpriseCourseEnrollments data in the cache for each BFF query. + bffQueryKeysToUpdate.forEach((queryKey) => { + const existingBFFData = queryClient.getQueryData(queryKey); + if (!existingBFFData) { + logInfo(`Skipping optimistic cache update of ${JSON.stringify(queryKey)} as no cached query data exists yet.`); + return; } - return enrollment; - }), - ); - }, [ - enterpriseCustomer.uuid, - queryClient, - ]); + const updatedBFFData = { + ...existingBFFData, + enterpriseCourseEnrollments: existingBFFData.enterpriseCourseEnrollments.map(transformUpdatedEnrollment), + }; + queryClient.setQueryData(queryKey, updatedBFFData); + }); + } - return updateCourseEnrollmentStatus; -}; + // Update the legacy queryEnterpriseCourseEnrollments cache as well. + const enterpriseCourseEnrollmentsQueryKey = queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid).queryKey; + const existingCourseEnrollmentsData = queryClient.getQueryData(enterpriseCourseEnrollmentsQueryKey); + if (!existingCourseEnrollmentsData) { + logInfo(`Skipping optimistic cache update of ${JSON.stringify(enterpriseCourseEnrollmentsQueryKey)} as no cached query data exists yet.`); + return; + } + const updatedCourseEnrollmentsData = existingCourseEnrollmentsData.map(transformUpdatedEnrollment); + queryClient.setQueryData(enterpriseCourseEnrollmentsQueryKey, updatedCourseEnrollmentsData); + }, [queryClient, enterpriseCustomer]); +} /** * - Parses a list of redeemable policies and checks if learner has acknowledged the new group. diff --git a/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx b/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx index 8079d7751c..d485edb283 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx +++ b/src/components/dashboard/main-content/course-enrollments/data/tests/hooks.test.jsx @@ -1,5 +1,6 @@ import { act, renderHook } from '@testing-library/react-hooks'; import * as logger from '@edx/frontend-platform/logging'; +import { logInfo } from '@edx/frontend-platform/logging'; import { AppContext } from '@edx/frontend-platform/react'; import { sendEnterpriseTrackEventWithDelay } from '@edx/frontend-enterprise-utils'; import camelCase from 'lodash.camelcase'; @@ -7,7 +8,6 @@ import dayjs from 'dayjs'; import { QueryClientProvider } from '@tanstack/react-query'; import { waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; - import { queryClient } from '../../../../../../utils/tests'; import { useContentAssignments, @@ -15,6 +15,7 @@ import { useCourseEnrollmentsBySection, useCourseUpgradeData, useGroupAssociationsAlert, + useUpdateCourseEnrollmentStatus, } from '../hooks'; import * as service from '../service'; import { COURSE_STATUSES, HAS_USER_DISMISSED_NEW_GROUP_ALERT } from '../constants'; @@ -29,6 +30,10 @@ import { COURSE_MODES_MAP, emptyRedeemableLearnerCreditPolicies, ENROLL_BY_DATE_WARNING_THRESHOLD_DAYS, + isBFFEnabledForEnterpriseCustomer, + learnerDashboardBFFResponse, + queryEnterpriseCourseEnrollments, + queryEnterpriseLearnerDashboardBFF, transformCourseEnrollment, transformLearnerContentAssignment, useCanUpgradeWithLearnerCredit, @@ -42,6 +47,7 @@ import { } from '../../../../../app/data'; import { authenticatedUserFactory, + enterpriseCourseEnrollmentFactory, enterpriseCustomerFactory, } from '../../../../../app/data/services/data/__factories__'; import { ASSIGNMENTS_EXPIRING_WARNING_LOCALSTORAGE_KEY } from '../../../../data/constants'; @@ -71,6 +77,7 @@ jest.mock('../../../../../app/data', () => ({ useCanUpgradeWithLearnerCredit: jest.fn(), useEnterpriseCustomerContainsContent: jest.fn(), useCourseRunMetadata: jest.fn(), + isBFFEnabledForEnterpriseCustomer: jest.fn(), })); jest.mock('../../../../../course/data/hooks', () => ({ ...jest.requireActual('../../../../../course/data/hooks'), @@ -83,6 +90,14 @@ const mockTransformedMockCourseEnrollment = transformCourseEnrollment(mockRawCou const mockEnterpriseCustomer = enterpriseCustomerFactory(); const mockAuthenticatedUser = authenticatedUserFactory(); +const mockEnterpriseCourseEnrollment = enterpriseCourseEnrollmentFactory({ + course_run_id: mockRawCourseEnrollment.courseRunId, +}); +const mockEnterpriseCourseEnrollments = [mockEnterpriseCourseEnrollment]; +const mockBFFEnterpriseCourseEnrollments = { + ...learnerDashboardBFFResponse, + enterpriseCourseEnrollments: mockEnterpriseCourseEnrollments, +}; const mockAppContextValue = { authenticatedUser: mockAuthenticatedUser, @@ -1051,3 +1066,217 @@ describe('useGroupAssociationsAlert', () => { }); }); }); + +describe('useUpdateCourseEnrollmentStatus', () => { + let mockQueryClient; + const Wrapper = ({ + existingEnrollmentsQueryData = mockEnterpriseCourseEnrollments, + existingBFFDashboardQueryData = mockBFFEnterpriseCourseEnrollments, + children, + }) => { + mockQueryClient = queryClient(); + if (existingEnrollmentsQueryData) { + mockQueryClient.setQueryData( + queryEnterpriseCourseEnrollments(mockEnterpriseCustomer.uuid).queryKey, + existingEnrollmentsQueryData, + ); + } + if (existingBFFDashboardQueryData) { + mockQueryClient.setQueryData( + queryEnterpriseLearnerDashboardBFF({ enterpriseSlug: mockEnterpriseCustomer.slug }).queryKey, + existingBFFDashboardQueryData, + ); + } + return ( + + {children} + + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); + }); + + it.each([ + // BFF enabled && course run id matches + { + isBFFEnabled: true, + doesCourseRunIdMatch: true, + existingBFFDashboardQueryData: mockBFFEnterpriseCourseEnrollments, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: true, + doesCourseRunIdMatch: true, + existingBFFDashboardQueryData: mockBFFEnterpriseCourseEnrollments, + existingEnrollmentsQueryData: null, + }, + { + isBFFEnabled: true, + doesCourseRunIdMatch: true, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: true, + doesCourseRunIdMatch: true, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: null, + }, + // BFF disabled && course run id matches + { + isBFFEnabled: false, + doesCourseRunIdMatch: true, + existingBFFDashboardQueryData: mockBFFEnterpriseCourseEnrollments, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: false, + doesCourseRunIdMatch: true, + existingBFFDashboardQueryData: mockBFFEnterpriseCourseEnrollments, + existingEnrollmentsQueryData: null, + }, + { + isBFFEnabled: false, + doesCourseRunIdMatch: true, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: false, + doesCourseRunIdMatch: true, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: null, + }, + // BFF enabled && course run id does not match + { + isBFFEnabled: true, + doesCourseRunIdMatch: false, + existingBFFDashboardQueryData: mockBFFEnterpriseCourseEnrollments, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: true, + doesCourseRunIdMatch: false, + existingBFFDashboardQueryData: mockBFFEnterpriseCourseEnrollments, + existingEnrollmentsQueryData: null, + }, + { + isBFFEnabled: true, + doesCourseRunIdMatch: false, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: true, + doesCourseRunIdMatch: false, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: null, + }, + // BFF disabled && course run id does not match + { + isBFFEnabled: false, + doesCourseRunIdMatch: false, + existingBFFDashboardQueryData: mockBFFEnterpriseCourseEnrollments, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: false, + doesCourseRunIdMatch: false, + existingBFFDashboardQueryData: mockBFFEnterpriseCourseEnrollments, + existingEnrollmentsQueryData: null, + }, + { + isBFFEnabled: false, + doesCourseRunIdMatch: false, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments, + }, + { + isBFFEnabled: false, + doesCourseRunIdMatch: false, + existingBFFDashboardQueryData: null, + existingEnrollmentsQueryData: null, + }, + ])('updates the enrollment status (%s)', async ({ + isBFFEnabled, + doesCourseRunIdMatch, + existingBFFDashboardQueryData, + existingEnrollmentsQueryData, + }) => { + isBFFEnabledForEnterpriseCustomer.mockReturnValue(isBFFEnabled); + const mockCorrectCourseRunId = mockEnterpriseCourseEnrollment.courseRunId; + const mockIncorrectCourseRunId = 'course-v1:edX+DemoY+Demo'; + const mockCourseRunId = doesCourseRunIdMatch ? mockCorrectCourseRunId : mockIncorrectCourseRunId; + const newEnrollmentStatus = 'saved_for_later'; + + // Validate initial courseRunStatus as `in_progress` + const originalEnrollmentStatus = mockEnterpriseCourseEnrollment.courseRunStatus; + expect(originalEnrollmentStatus).toEqual('in_progress'); + + // Render the hook + const { result } = renderHook( + () => useUpdateCourseEnrollmentStatus(), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + expect(result.current).toBeDefined(); + expect(result.current).toBeInstanceOf(Function); + // Call the returned function to update the enrollment status + result.current({ + courseRunId: mockCourseRunId, + newStatus: newEnrollmentStatus, + }); + + await waitFor(() => { + const dashboardBFFData = mockQueryClient.getQueryData( + queryEnterpriseLearnerDashboardBFF({ + enterpriseSlug: mockEnterpriseCustomer.slug, + }).queryKey, + ); + let expectedLogInfoCalls = 0; + const expectedCourseRunStatus = doesCourseRunIdMatch + ? newEnrollmentStatus + : originalEnrollmentStatus; + if (isBFFEnabled) { + // Validate the updated enrollment status in BFF-related queries + const foundMockEnrollment = dashboardBFFData?.enterpriseCourseEnrollments.find( + (enrollment) => enrollment.courseRunId === mockCorrectCourseRunId, + ); + if (existingBFFDashboardQueryData) { + expect(foundMockEnrollment.courseRunStatus).toEqual(expectedCourseRunStatus); + } else { + expectedLogInfoCalls += 1; + expect(dashboardBFFData).toBeUndefined(); + } + } + + // Always validate the updated enrollment status in non-BFF-related query + const enrollmentsData = mockQueryClient.getQueryData( + queryEnterpriseCourseEnrollments(mockEnterpriseCustomer.uuid).queryKey, + ); + const foundMockEnrollment = enrollmentsData?.find( + (enrollment) => enrollment.courseRunId === mockCorrectCourseRunId, + ); + if (existingEnrollmentsQueryData) { + expect(foundMockEnrollment.courseRunStatus).toEqual(expectedCourseRunStatus); + } else { + expectedLogInfoCalls += 1; + expect(enrollmentsData).toBeUndefined(); + } + + // Verify logInfo calls + expect(logInfo).toHaveBeenCalledTimes(expectedLogInfoCalls); + }); + }); +}); diff --git a/src/components/executive-education-2u/UserEnrollmentForm.jsx b/src/components/executive-education-2u/UserEnrollmentForm.jsx index e906f08c30..ba26fc81a5 100644 --- a/src/components/executive-education-2u/UserEnrollmentForm.jsx +++ b/src/components/executive-education-2u/UserEnrollmentForm.jsx @@ -20,9 +20,11 @@ import { checkoutExecutiveEducation2U, isDuplicateExternalCourseOrder, toISOStri import { useStatefulEnroll } from '../stateful-enroll/data'; import { CourseContext } from '../course/CourseContextProvider'; import { + isBFFEnabledForEnterpriseCustomer, LEARNER_CREDIT_SUBSIDY_TYPE, queryCanRedeemContextQueryKey, queryEnterpriseCourseEnrollments, + queryEnterpriseLearnerDashboardBFF, queryRedeemablePolicies, useCourseMetadata, useEnterpriseCourseEnrollments, @@ -46,27 +48,49 @@ const UserEnrollmentForm = ({ className }) => { externalCourseFormSubmissionError, setExternalCourseFormSubmissionError, } = useContext(CourseContext); + const { data: { courseEntitlementProductSku } } = useCourseMetadata(); const [isFormSubmitted, setIsFormSubmitted] = useState(false); const [enrollButtonState, setEnrollButtonState] = useState('default'); + const handleQueryInvalidationForEnrollSuccess = () => { + const isBFFEnabled = isBFFEnabledForEnterpriseCustomer(enterpriseCustomer.uuid); + const canRedeemQueryKey = queryCanRedeemContextQueryKey(enterpriseCustomer.uuid, courseKey); + const redeemablePoliciesQueryKey = queryRedeemablePolicies({ + enterpriseUuid: enterpriseCustomer.uuid, + lmsUserId: userId, + }).queryKey; + const enterpriseCourseEnrollmentsQueryKey = queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid).queryKey; + + // List of queries to invalidate after successfully enrolling in the course. + const queriesToInvalidate = [ + canRedeemQueryKey, + redeemablePoliciesQueryKey, + enterpriseCourseEnrollmentsQueryKey, + ]; + + if (isBFFEnabled) { + // Determine which BFF queries need to be updated after successfully enrolling. + const dashboardBFFQueryKey = queryEnterpriseLearnerDashboardBFF({ + enterpriseSlug: enterpriseCustomer.slug, + }).queryKey; + const bffQueriesToInvalidate = [dashboardBFFQueryKey]; + queriesToInvalidate.push(...bffQueriesToInvalidate); + } + + queriesToInvalidate.forEach((queryKey) => { + queryClient.invalidateQueries({ queryKey }); + }); + }; + const handleFormSubmissionSuccess = async (newTransaction) => { // If a transaction is passed, it must be in the 'committed' state to proceed if (!isNil(newTransaction) && newTransaction.state !== 'committed') { return; } - - const canRedeemQueryKey = queryCanRedeemContextQueryKey(enterpriseCustomer.uuid, courseKey); await Promise.all([ - queryClient.invalidateQueries({ queryKey: canRedeemQueryKey }), - queryClient.invalidateQueries({ - queryKey: queryRedeemablePolicies({ - enterpriseUuid: enterpriseCustomer.uuid, - lmsUserId: userId, - }), - }), - queryClient.invalidateQueries({ queryKey: queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid) }), + handleQueryInvalidationForEnrollSuccess(), sendEnterpriseTrackEventWithDelay( enterpriseCustomer.uuid, 'edx.ui.enterprise.learner_portal.executive_education.checkout_form.submitted', diff --git a/src/components/executive-education-2u/UserEnrollmentForm.test.jsx b/src/components/executive-education-2u/UserEnrollmentForm.test.jsx index 806525a717..9c7b9bef54 100644 --- a/src/components/executive-education-2u/UserEnrollmentForm.test.jsx +++ b/src/components/executive-education-2u/UserEnrollmentForm.test.jsx @@ -1,6 +1,4 @@ -import { - act, screen, waitFor, -} from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom/extend-expect'; import { AppContext } from '@edx/frontend-platform/react'; @@ -10,20 +8,24 @@ import { logError, logInfo } from '@edx/frontend-platform/logging'; import dayjs from 'dayjs'; import MockDate from 'mockdate'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useParams } from 'react-router-dom'; +import { QueryClientProvider } from '@tanstack/react-query'; import UserEnrollmentForm from './UserEnrollmentForm'; import { checkoutExecutiveEducation2U, toISOStringWithoutMilliseconds } from './data'; import { useStatefulEnroll } from '../stateful-enroll/data'; import { CourseContext } from '../course/CourseContextProvider'; import { LEARNER_CREDIT_SUBSIDY_TYPE, + queryCanRedeemContextQueryKey, + queryEnterpriseCourseEnrollments, + queryRedeemablePolicies, useCourseMetadata, useEnterpriseCourseEnrollments, useEnterpriseCustomer, + isBFFEnabledForEnterpriseCustomer, + queryEnterpriseLearnerDashboardBFF, } from '../app/data'; import { authenticatedUserFactory, enterpriseCustomerFactory } from '../app/data/services/data/__factories__'; -import { renderWithRouter } from '../../utils/tests'; +import { queryClient, renderWithRouter, renderWithRouterProvider } from '../../utils/tests'; import { useUserSubsidyApplicableToCourse } from '../course/data'; const termsLabelText = "I agree to GetSmarter's Terms and Conditions for Students"; @@ -34,6 +36,7 @@ const mockFirstName = 'John'; const mockLastName = 'Doe'; const mockDateOfBirth = '1993-06-10'; const mockProductSKU = 'ABC123'; +const mockCourseKey = 'edX+DemoX'; const mockCourseRunKey = 'course-v1:edX+DemoX+Demo_Course'; jest.mock('@edx/frontend-platform/logging', () => ({ @@ -58,6 +61,7 @@ jest.mock('../app/data', () => ({ useEnterpriseCustomer: jest.fn(), useEnterpriseCourseEnrollments: jest.fn(), useCourseMetadata: jest.fn(), + isBFFEnabledForEnterpriseCustomer: jest.fn(), })); jest.mock('../course/data', () => ({ @@ -65,11 +69,6 @@ jest.mock('../course/data', () => ({ useUserSubsidyApplicableToCourse: jest.fn(), })); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: jest.fn(), -})); - const mockEnterpriseCustomer = enterpriseCustomerFactory({ enable_executive_education_2u_fulfillment: true, enable_data_sharing_consent: true, @@ -97,8 +96,8 @@ const initialAppContextValue = { authenticatedUser: mockAuthenticatedUser, }; -const queryClient = new QueryClient(); - +let mockQueryClient; +let invalidateQueriesSpy; const UserEnrollmentFormWrapper = ({ appContextValue = initialAppContextValue, courseContextValue = { @@ -108,17 +107,21 @@ const UserEnrollmentFormWrapper = ({ setExternalFormSubmissionError: jest.fn(), formSubmissionError: {}, }, -}) => ( - - - - - - - - - -); +}) => { + mockQueryClient = queryClient(); + invalidateQueriesSpy = jest.spyOn(mockQueryClient, 'invalidateQueries'); + return ( + + + + + + + + + + ); +}; describe('UserEnrollmentForm', () => { beforeEach(() => { @@ -132,7 +135,7 @@ describe('UserEnrollmentForm', () => { missingUserSubsidyReason: undefined, }); useCourseMetadata.mockReturnValue({ data: {} }); - useParams.mockReturnValue({ courseRunKey: mockCourseRunKey }); + isBFFEnabledForEnterpriseCustomer.mockReturnValue(false); }); afterEach(() => { @@ -225,21 +228,65 @@ describe('UserEnrollmentForm', () => { }); }); - it('handles successful form submission with subsidy access policy redemption', async () => { + it.each([ + // BFF Disabled + { + isBFFEnabled: false, + isDSCEnabled: false, + }, + { + isBFFEnabled: false, + isDSCEnabled: true, + }, + // BFF Enabled + { + isBFFEnabled: true, + isDSCEnabled: false, + }, + { + isBFFEnabled: true, + isDSCEnabled: true, + }, + ])('handles successful form submission with subsidy access policy redemption (%s)', async ({ + isBFFEnabled, + isDSCEnabled, + }) => { const mockTermsAcceptedAt = '2022-09-28T13:35:06Z'; MockDate.set(mockTermsAcceptedAt); - + isBFFEnabledForEnterpriseCustomer.mockReturnValue(isBFFEnabled); useUserSubsidyApplicableToCourse.mockReturnValue({ userSubsidyApplicableToCourse: { subsidyType: LEARNER_CREDIT_SUBSIDY_TYPE, }, }); - renderWithRouter(); + if (!isDSCEnabled) { + useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomerWithDisabledDataSharingConsent }); + } + + const mockExternalEnrollmentUrl = `/${mockEnterpriseCustomer.slug}/executive-education-2u/course/${mockCourseKey}/enroll/${mockCourseRunKey}`; + renderWithRouterProvider( + { + path: '/:enterpriseSlug/:courseType/course/:courseKey/enroll/:courseRunKey', + element: , + }, + { + initialEntries: [mockExternalEnrollmentUrl], + routes: [ + { + path: '/:enterpriseSlug/:courseType/course/:courseKey/enroll/:courseRunKey/complete', + element:
, + }, + ], + }, + ); + 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)); + if (isDSCEnabled) { + userEvent.click(screen.getByLabelText(dataSharingConsentLabelText)); + } userEvent.click(screen.getByText('Confirm registration')); await waitFor(() => { @@ -254,10 +301,11 @@ describe('UserEnrollmentForm', () => { geagEmail: mockAuthenticatedUser.email, geagDateOfBirth: mockDateOfBirth, geagTermsAcceptedAt: mockTermsAcceptedAt, - geagDataShareConsent: true, + geagDataShareConsent: isDSCEnabled ? true : undefined, }), }), ); + // Ensure the contentKey from the URL is passed along to the redeem endpoint via useStatefulEnroll. expect(useStatefulEnroll.mock.calls[0][0]).toEqual( expect.objectContaining({ @@ -271,49 +319,36 @@ describe('UserEnrollmentForm', () => { useStatefulEnroll.mock.calls[0][0].onSuccess(newTransaction); }); - // disabled after submitting - await waitFor(() => expect(screen.getByText('Registration confirmed').closest('button')).toHaveAttribute('aria-disabled', 'true')); - }); - - it('handles successful form submission with data sharing consent disabled', async () => { - const mockTermsAcceptedAt = '2022-09-28T13:35:06Z'; - MockDate.set(mockTermsAcceptedAt); - - useUserSubsidyApplicableToCourse.mockReturnValue({ - userSubsidyApplicableToCourse: { - subsidyType: LEARNER_CREDIT_SUBSIDY_TYPE, - }, - }); - useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomerWithDisabledDataSharingConsent }); - renderWithRouter(); - - 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.getByText('Confirm registration')); + const canRedeemQueryKey = queryCanRedeemContextQueryKey(mockEnterpriseCustomer.uuid, mockCourseKey); + const redeemablePoliciesQueryKey = queryRedeemablePolicies({ + enterpriseUuid: mockEnterpriseCustomer.uuid, + lmsUserId: mockAuthenticatedUser.userId, + }).queryKey; + const enterpriseCourseEnrollmentsQueryKey = queryEnterpriseCourseEnrollments(mockEnterpriseCustomer.uuid).queryKey; + const expectedQueriesToInvalidate = [ + canRedeemQueryKey, + redeemablePoliciesQueryKey, + enterpriseCourseEnrollmentsQueryKey, + ]; + + if (isBFFEnabled) { + const dashboardBFFQueryKey = queryEnterpriseLearnerDashboardBFF({ + enterpriseSlug: mockEnterpriseCustomer.slug, + }).queryKey; + const expectedBFFQueriesToInvalidate = [dashboardBFFQueryKey]; + expectedQueriesToInvalidate.push(...expectedBFFQueriesToInvalidate); + } await waitFor(() => { - expect(screen.getByText('Confirming registration...').closest('button')).toHaveAttribute('aria-disabled', 'true'); - }); - expect(mockRedeem).toHaveBeenCalledTimes(1); - expect(mockRedeem).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: snakeCaseObject({ - geagFirstName: mockFirstName, - geagLastName: mockLastName, - geagEmail: mockAuthenticatedUser.email, - geagDateOfBirth: mockDateOfBirth, - geagTermsAcceptedAt: mockTermsAcceptedAt, - geagDataShareConsent: undefined, - }), - }), - ); - - // simulate `useStatefulEnroll` calling `onSuccess` arg - const newTransaction = { state: 'committed' }; - act(() => { - useStatefulEnroll.mock.calls[0][0].onSuccess(newTransaction); + expect(invalidateQueriesSpy).toHaveBeenCalledTimes(expectedQueriesToInvalidate.length); + expectedQueriesToInvalidate.forEach((queryKey) => { + expect(invalidateQueriesSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey }), + ); + }); + + // Redirected to the enrollment confirmation page + expect(screen.getByTestId('enrollment-confirmation')).toBeInTheDocument(); }); }); @@ -334,6 +369,7 @@ describe('UserEnrollmentForm', () => { + 'that you are under the age of 18, and we need your parent or legal ' + 'guardian to consent to your registration and GetSmarter processing ' + 'your personal information.'; + await waitFor(() => { expect(screen.getByText(invalidAgeErrorMessage, { exact: false })).toBeInTheDocument(); expect(checkoutExecutiveEducation2U).toHaveBeenCalledTimes(0); diff --git a/src/components/stateful-enroll/data/hooks/useStatefulEnroll.js b/src/components/stateful-enroll/data/hooks/useStatefulEnroll.js index e207ed3d52..b8c78dd730 100644 --- a/src/components/stateful-enroll/data/hooks/useStatefulEnroll.js +++ b/src/components/stateful-enroll/data/hooks/useStatefulEnroll.js @@ -30,10 +30,10 @@ const useStatefulEnroll = ({ }) => { const { data: enterpriseCustomer } = useEnterpriseCustomer(); const [transaction, setTransaction] = useState(); - const optimizelyHandler = useOptimizelyEnrollmentClickHandler( - contentKey, + const optimizelyHandler = useOptimizelyEnrollmentClickHandler({ + courseRunKey: contentKey, userEnrollments, - ); + }); const searchHandler = useTrackSearchConversionClickHandler({ eventName: EVENT_NAMES.sucessfulEnrollment, }); diff --git a/src/components/stateful-enroll/data/hooks/useStatefulEnroll.test.jsx b/src/components/stateful-enroll/data/hooks/useStatefulEnroll.test.jsx index 0251845871..1872934b21 100644 --- a/src/components/stateful-enroll/data/hooks/useStatefulEnroll.test.jsx +++ b/src/components/stateful-enroll/data/hooks/useStatefulEnroll.test.jsx @@ -111,7 +111,7 @@ describe('useStatefulEnroll', () => { if (isSuccess) { expect(trackSearchSpy).toHaveBeenCalledWith({ eventName: EVENT_NAMES.sucessfulEnrollment }); - expect(optimizelySpy).toHaveBeenCalledWith('content_key', []); + expect(optimizelySpy).toHaveBeenCalledWith({ courseRunKey: 'content_key', userEnrollments: [] }); expect(onSuccess).toHaveBeenCalledTimes(1); expect(onSuccess).toHaveBeenCalledWith({ state: mockState }); } else {