diff --git a/src/components/app/data/constants.js b/src/components/app/data/constants.js index c54728416..3d0925e32 100644 --- a/src/components/app/data/constants.js +++ b/src/components/app/data/constants.js @@ -65,6 +65,7 @@ export const ASSIGNMENT_TYPES = { EXPIRED: 'expired', ERRORED: 'errored', EXPIRING: 'expiring', + REVERSED: 'reversed', }; // When the start date is before this number of days before today, display the alternate start date (fixed to today). diff --git a/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js b/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js index 7edc70ef5..bc50eaf04 100644 --- a/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js +++ b/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js @@ -30,41 +30,90 @@ export const transformAllEnrollmentsByStatus = ({ /** * Retrieves the relevant enterprise course enrollments, subsidy requests (e.g., license * requests), and content assignments for the active enterprise customer user. + * @param {Types.UseQueryOptions} queryOptions The query options. * @returns {Types.UseQueryResult} The query results. */ export default function useEnterpriseCourseEnrollments(queryOptions = {}) { - const isEnabled = queryOptions.enabled; + const { + enrollmentQueryOptions = {}, + licenseRequestQueryOptions = {}, + couponCodeRequestQueryOptions = {}, + contentAssignmentQueryOptions = {}, + } = queryOptions; + const { select: selectEnrollment, ...enrollmentQueryOptionsRest } = enrollmentQueryOptions; + const { select: selectLicenseRequest, ...licenseRequestQueryOptionsRest } = licenseRequestQueryOptions; + const { select: selectCouponCodeRequest, ...couponCodeRequestQueryOptionsRest } = couponCodeRequestQueryOptions; + const { select: selectContentAssignment, ...contentAssignmentQueryOptionsRest } = contentAssignmentQueryOptions; + const { data: enterpriseCustomer } = useEnterpriseCustomer(); - const bffQueryFallback = { - ...queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid), - ...queryOptions, - select: (data) => data.map(transformCourseEnrollment), - enabled: isEnabled, - }; + const { data: enterpriseCourseEnrollments } = useBFF({ bffQueryOptions: { ...queryOptions, - select: (data) => data.enterpriseCourseEnrollments.map(transformCourseEnrollment), - enabled: isEnabled, + select: (data) => { + const transformedData = data.enterpriseCourseEnrollments.map(transformCourseEnrollment); + if (selectEnrollment) { + return selectEnrollment({ + original: data, + transformed: transformedData, + }); + } + return transformedData; + }, + ...enrollmentQueryOptionsRest, + }, + fallbackQueryConfig: { + ...queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid), + ...queryOptions, + select: (data) => { + const transformedData = data.map(transformCourseEnrollment); + if (selectEnrollment) { + return selectEnrollment({ + original: data, + transformed: transformedData, + }); + } + return transformedData; + }, + ...enrollmentQueryOptionsRest, }, - fallbackQueryConfig: bffQueryFallback, }); + const { data: { requests } } = useBrowseAndRequest({ subscriptionLicensesQueryOptions: { - select: (data) => data.map((subsidyRequest) => transformSubsidyRequest({ - subsidyRequest, - slug: enterpriseCustomer.slug, - })), - enabled: isEnabled, + select: (data) => { + const transformedData = data.map((subsidyRequest) => transformSubsidyRequest({ + subsidyRequest, + slug: enterpriseCustomer.slug, + })); + if (selectLicenseRequest) { + return selectLicenseRequest({ + original: data, + transformed: transformedData, + }); + } + return transformedData; + }, + ...licenseRequestQueryOptionsRest, }, couponCodesQueryOptions: { - select: (data) => data.map((subsidyRequest) => transformSubsidyRequest({ - subsidyRequest, - slug: enterpriseCustomer.slug, - })), - enabled: isEnabled, + select: (data) => { + const transformedData = data.map((subsidyRequest) => transformSubsidyRequest({ + subsidyRequest, + slug: enterpriseCustomer.slug, + })); + if (selectCouponCodeRequest) { + return selectCouponCodeRequest({ + original: data, + transformed: transformedData, + }); + } + return transformedData; + }, + ...couponCodeRequestQueryOptionsRest, }, }); + const { data: contentAssignments } = useRedeemablePolicies({ select: (data) => { const { learnerContentAssignments } = data; @@ -78,10 +127,17 @@ export default function useEnterpriseCourseEnrollments(queryOptions = {}) { enterpriseCustomer.slug, )); }); + if (selectContentAssignment) { + return selectContentAssignment({ + original: data, + transformed: transformedAssignments, + }); + } return transformedAssignments; }, - enabled: isEnabled, + ...contentAssignmentQueryOptionsRest, }); + // TODO: Talk about how we don't have access to weeksToComplete on the dashboard page. const allEnrollmentsByStatus = useMemo(() => transformAllEnrollmentsByStatus({ enterpriseCourseEnrollments, diff --git a/src/components/app/data/hooks/useEnterpriseCourseEnrollments.test.jsx b/src/components/app/data/hooks/useEnterpriseCourseEnrollments.test.jsx index 818c5f0e7..087bbc61b 100644 --- a/src/components/app/data/hooks/useEnterpriseCourseEnrollments.test.jsx +++ b/src/components/app/data/hooks/useEnterpriseCourseEnrollments.test.jsx @@ -30,7 +30,7 @@ jest.mock('./useBFF'); const mockEnterpriseCustomer = enterpriseCustomerFactory(); const mockAuthenticatedUser = authenticatedUserFactory(); -const mockCourseEnrollments = { +const mockCourseEnrollment = { displayName: 'Education', micromastersTitle: 'Demo in higher education', courseRunUrl: 'test-course-url', @@ -114,6 +114,16 @@ const mockRedeemablePolicies = { }, }; +const expectedTransformedRequests = (request) => ({ + courseRunId: request.courseId, + title: request.courseTitle, + orgName: request.coursePartners?.map(partner => partner.name).join(', '), + courseRunStatus: COURSE_STATUSES.requested, + linkToCourse: `${mockEnterpriseCustomer.slug}/course/${request.courseId}`, + created: request.created, + notifications: [], +}); + describe('useEnterpriseCourseEnrollments', () => { const Wrapper = ({ children }) => ( @@ -125,35 +135,28 @@ describe('useEnterpriseCourseEnrollments', () => { beforeEach(() => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); - fetchEnterpriseCourseEnrollments.mockResolvedValue([mockCourseEnrollments]); + fetchEnterpriseCourseEnrollments.mockResolvedValue([mockCourseEnrollment]); fetchBrowseAndRequestConfiguration.mockResolvedValue(mockBrowseAndRequestConfiguration); fetchLicenseRequests.mockResolvedValue([mockLicenseRequests]); fetchCouponCodeRequests.mockResolvedValue([mockCouponCodeRequests]); fetchRedeemablePolicies.mockResolvedValue(mockRedeemablePolicies); - useBFF.mockReturnValue({ data: [mockCourseEnrollments].map(transformCourseEnrollment) }); + useBFF.mockReturnValue({ data: [mockCourseEnrollment].map(transformCourseEnrollment) }); }); - it('should return transformed return values from course enrollments API', async () => { - const { result, waitForNextUpdate } = renderHook(() => useEnterpriseCourseEnrollments(), { wrapper: Wrapper }); - await waitForNextUpdate(); - const expectedEnterpriseCourseEnrollments = { - title: mockCourseEnrollments.displayName, - microMastersTitle: mockCourseEnrollments.micromastersTitle, - linkToCourse: mockCourseEnrollments.courseRunUrl, - linkToCertificate: mockCourseEnrollments.certificateDownloadUrl, - hasEmailsEnabled: mockCourseEnrollments.emailsEnabled, - notifications: mockCourseEnrollments.dueDates, - canUnenroll: canUnenrollCourseEnrollment(mockCourseEnrollments), + + it.each([ + { hasQueryOptions: false }, + { hasQueryOptions: true }, + ])('should return transformed enrollments data (%s)', async ({ hasQueryOptions }) => { + const expectedEnterpriseCourseEnrollments = [{ + title: mockCourseEnrollment.displayName, + microMastersTitle: mockCourseEnrollment.micromastersTitle, + linkToCourse: mockCourseEnrollment.courseRunUrl, + linkToCertificate: mockCourseEnrollment.certificateDownloadUrl, + hasEmailsEnabled: mockCourseEnrollment.emailsEnabled, + notifications: mockCourseEnrollment.dueDates, + canUnenroll: canUnenrollCourseEnrollment(mockCourseEnrollment), isCourseAssigned: false, - }; - const expectedTransformedRequests = (request) => ({ - courseRunId: request.courseId, - title: request.courseTitle, - orgName: request.coursePartners?.map(partner => partner.name).join(', '), - courseRunStatus: COURSE_STATUSES.requested, - linkToCourse: `${mockEnterpriseCustomer.slug}/course/${request.courseId}`, - created: request.created, - notifications: [], - }); + }]; const expectedRequests = { couponCodes: [expectedTransformedRequests(mockCouponCodeRequests)], subscriptionLicenses: [expectedTransformedRequests(mockLicenseRequests)], @@ -177,7 +180,7 @@ describe('useEnterpriseCourseEnrollments', () => { uuid: mockContentAssignment.uuid, learnerAcknowledged: mockContentAssignment.learnerAcknowledged, }; - const expectedContentAssignment = { + const expectedContentAssignmentData = { acceptedAssignments: [], allocatedAssignments: [expectedTransformedLearnerContentAssignment], assignmentsForDisplay: [expectedTransformedLearnerContentAssignment], @@ -187,15 +190,64 @@ describe('useEnterpriseCourseEnrollments', () => { expiredAssignments: [], }; + const mockSelectEnrollment = jest.fn().mockReturnValue(expectedEnterpriseCourseEnrollments); + const mockSelectLicenseRequest = jest.fn().mockReturnValue(expectedRequests.subscriptionLicenses); + const mockSelectCouponCodeRequest = jest.fn().mockReturnValue(expectedRequests.couponCodes); + const mockSelectContentAssignment = jest.fn().mockReturnValue(expectedContentAssignmentData); + const mockQueryOptions = { + enrollmentQueryOptions: { select: mockSelectEnrollment }, + licenseRequestQueryOptions: { select: mockSelectLicenseRequest }, + couponCodeRequestQueryOptions: { select: mockSelectCouponCodeRequest }, + contentAssignmentQueryOptions: { select: mockSelectContentAssignment }, + }; + const queryOptions = hasQueryOptions ? mockQueryOptions : undefined; + const { result, waitForNextUpdate } = renderHook( + () => { + if (hasQueryOptions) { + return useEnterpriseCourseEnrollments(queryOptions); + } + return useEnterpriseCourseEnrollments() + }, + { wrapper: Wrapper } + ); + await waitForNextUpdate(); + + // Call the mocked useBFF's input select functions + const useBFFArgs = useBFF.mock.calls[0][0]; + const { select: selectBFFQuery } = useBFFArgs.bffQueryOptions; + const { select: selectFallbackBFFQuery } = useBFFArgs.fallbackQueryConfig; + selectBFFQuery({ enterpriseCourseEnrollments: [mockCourseEnrollment] }); + selectFallbackBFFQuery([mockCourseEnrollment]); + + // Assert that passed select fn query options were called with the correct arguments + if (hasQueryOptions) { + expect(mockSelectLicenseRequest).toHaveBeenCalledWith({ + original: [mockLicenseRequests], + transformed: expectedRequests.subscriptionLicenses, + }); + expect(mockSelectCouponCodeRequest).toHaveBeenCalledWith({ + original: [mockCouponCodeRequests], + transformed: expectedRequests.couponCodes, + }); + expect(mockSelectContentAssignment).toHaveBeenCalledWith({ + original: mockRedeemablePolicies, + transformed: expectedContentAssignmentData, + }); + expect(mockSelectEnrollment).toHaveBeenCalledWith({ + original: [mockCourseEnrollment], + transformed: expectedEnterpriseCourseEnrollments, + }); + } + const expectedTransformedAllEnrollmentsByStatus = transformAllEnrollmentsByStatus({ - enterpriseCourseEnrollments: [expectedEnterpriseCourseEnrollments], + enterpriseCourseEnrollments: expectedEnterpriseCourseEnrollments, requests: expectedRequests, - contentAssignments: expectedContentAssignment, + contentAssignments: expectedContentAssignmentData, }); expect(result.current.data.allEnrollmentsByStatus).toEqual(expectedTransformedAllEnrollmentsByStatus); - expect(result.current.data.enterpriseCourseEnrollments).toEqual([expectedEnterpriseCourseEnrollments]); - expect(result.current.data.contentAssignments).toEqual(expectedContentAssignment); + expect(result.current.data.enterpriseCourseEnrollments).toEqual(expectedEnterpriseCourseEnrollments); + expect(result.current.data.contentAssignments).toEqual(expectedContentAssignmentData); expect(result.current.data.requests).toEqual(expectedRequests); }); }); diff --git a/src/components/app/data/hooks/useSubscriptions.js b/src/components/app/data/hooks/useSubscriptions.js index 0e290f7e2..dffbdb7b4 100644 --- a/src/components/app/data/hooks/useSubscriptions.js +++ b/src/components/app/data/hooks/useSubscriptions.js @@ -10,12 +10,28 @@ import { transformSubscriptionsData } from '../services'; */ export default function useSubscriptions(queryOptions = {}) { const { data: enterpriseCustomer } = useEnterpriseCustomer(); + const { select, ...queryOptionsRest } = queryOptions; + return useBFF({ bffQueryOptions: { - select: (data) => transformSubscriptionsData( - data?.enterpriseCustomerUserSubsidies?.subscriptions, - { isBFFData: true }, - ), + ...queryOptionsRest, + select: (data) => { + const transformedData = transformSubscriptionsData( + data?.enterpriseCustomerUserSubsidies?.subscriptions, + { isBFFData: true }, + ); + + // When custom `select` function is provided in `queryOptions`, call it with original and transformed data. + if (select) { + return select({ + original: data, + transformed: transformedData, + }); + } + + // Otherwise, return the transformed data. + return transformedData; + }, }, fallbackQueryConfig: { ...querySubscriptions(enterpriseCustomer.uuid), diff --git a/src/components/app/data/hooks/useSubscriptions.test.jsx b/src/components/app/data/hooks/useSubscriptions.test.jsx index 6da80c629..7712ee505 100644 --- a/src/components/app/data/hooks/useSubscriptions.test.jsx +++ b/src/components/app/data/hooks/useSubscriptions.test.jsx @@ -3,16 +3,17 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { useLocation, useParams } from 'react-router-dom'; import { enterpriseCustomerFactory } from '../services/data/__factories__'; import { queryClient } from '../../../../utils/tests'; -import { fetchSubscriptions } from '../services'; +import { fetchSubscriptions, fetchEnterpriseLearnerDashboard } from '../services'; import useEnterpriseCustomer from './useEnterpriseCustomer'; import useSubscriptions from './useSubscriptions'; import { LICENSE_STATUS } from '../../../enterprise-user-subsidy/data/constants'; -import { resolveBFFQuery } from '../queries'; +import { queryEnterpriseLearnerDashboardBFF, resolveBFFQuery } from '../queries'; jest.mock('./useEnterpriseCustomer'); jest.mock('../services', () => ({ ...jest.requireActual('../services'), fetchSubscriptions: jest.fn().mockResolvedValue(null), + fetchEnterpriseLearnerDashboard: jest.fn(), })); jest.mock('../queries', () => ({ ...jest.requireActual('../queries'), @@ -44,6 +45,7 @@ describe('useSubscriptions', () => { {children} ); + beforeEach(() => { jest.clearAllMocks(); useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer }); @@ -52,14 +54,99 @@ describe('useSubscriptions', () => { useParams.mockReturnValue({ enterpriseSlug: 'test-enterprise' }); resolveBFFQuery.mockReturnValue(null); }); - it('should handle resolved value correctly', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSubscriptions(), { wrapper: Wrapper }); + + it.each([ + { + hasQueryOptions: false, + isBFFQueryEnabled: false, + }, + { + hasQueryOptions: false, + isBFFQueryEnabled: true, + }, + { + hasQueryOptions: true, + isBFFQueryEnabled: false, + }, + { + hasQueryOptions: true, + isBFFQueryEnabled: true, + }, + ])('should handle resolved value correctly (%s)', async ({ hasQueryOptions, isBFFQueryEnabled }) => { + const mockSubscriptionLicense = { + uuid: 'mock-subscription-license-uuid', + status: LICENSE_STATUS.ACTIVATED, + subscriptionPlan: { + uuid: 'mock-subscription-plan-uuid', + }, + }; + let mockSelect = jest.fn((data) => data); + if (isBFFQueryEnabled) { + mockSelect = jest.fn(({ original, transformed }) => { + return transformed.subscriptionLicense; + }) + } + const queryOptions = hasQueryOptions ? { select: mockSelect } : undefined; + const mockSubscriptionLicensesByStatus = { + ...mockSubscriptionsData.licensesByStatus, + [mockSubscriptionLicense.status]: [mockSubscriptionLicense], + }; + const mockSubscriptionsDataWithLicense = { + ...mockSubscriptionsData, + subscriptionLicenses: [mockSubscriptionLicense], + customerAgreement: { + uuid: 'mock-customer-agreement-uuid', + disableExpirationNotifications: true, + }, + subscriptionLicense: mockSubscriptionLicense, + subscriptionPlan: mockSubscriptionLicense.subscriptionPlan, + licensesByStatus: mockSubscriptionLicensesByStatus, + showExpirationNotifications: false, + }; + if (isBFFQueryEnabled) { + mockSubscriptionsDataWithLicense.subscriptionLicensesByStatus = mockSubscriptionLicensesByStatus; + delete mockSubscriptionsDataWithLicense.licensesByStatus; + resolveBFFQuery.mockReturnValue(queryEnterpriseLearnerDashboardBFF); + fetchEnterpriseLearnerDashboard.mockResolvedValue({ + enterpriseCustomerUserSubsidies: { + subscriptions: mockSubscriptionsDataWithLicense, + }, + }); + } + fetchSubscriptions.mockResolvedValue(mockSubscriptionsDataWithLicense); + + const { result, waitForNextUpdate } = renderHook( + () => { + if (queryOptions) { + return useSubscriptions(queryOptions); + } + return useSubscriptions(); + }, + { wrapper: Wrapper } + ); await waitForNextUpdate(); + const expectedSubscriptionsdata = { + ...mockSubscriptionsDataWithLicense, + licensesByStatus: mockSubscriptionLicensesByStatus, + }; + delete expectedSubscriptionsdata.subscriptionLicensesByStatus; + + if (hasQueryOptions && isBFFQueryEnabled) { + expect(mockSelect).toHaveBeenCalledWith({ + original: { + enterpriseCustomerUserSubsidies: { + subscriptions: mockSubscriptionsDataWithLicense, + }, + }, + transformed: expectedSubscriptionsdata, + }); + } + expect(result.current).toEqual( expect.objectContaining({ - data: mockSubscriptionsData, + data: hasQueryOptions && isBFFQueryEnabled ? mockSubscriptionLicense : expectedSubscriptionsdata, isLoading: false, isFetching: false, }), diff --git a/src/components/app/data/utils.js b/src/components/app/data/utils.js index 5489b53dc..fa32b56c5 100644 --- a/src/components/app/data/utils.js +++ b/src/components/app/data/utils.js @@ -173,6 +173,7 @@ export function getAssignmentsByState(assignments = []) { const canceledAssignments = []; const expiredAssignments = []; const erroredAssignments = []; + const reversedAssignments = []; const assignmentsForDisplay = []; assignments.forEach((assignment) => { @@ -192,6 +193,9 @@ export function getAssignmentsByState(assignments = []) { case ASSIGNMENT_TYPES.ERRORED: erroredAssignments.push(assignment); break; + case ASSIGNMENT_TYPES.REVERSED: + reversedAssignments.push(assignment); + break; default: logError(`[getAssignmentsByState] Unsupported state ${assignment.state} for assignment ${assignment.uuid}`); break; 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 47eac0523..2804f9054 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/hooks.js +++ b/src/components/dashboard/main-content/course-enrollments/data/hooks.js @@ -160,8 +160,8 @@ export const useCourseUpgradeData = ({ // Metadata required to allow upgrade via applicable subscription license const { data: subscriptionLicense } = useSubscriptions({ - select: (data) => { - const license = data?.subscriptionLicense; + select: ({ transformed }) => { + const license = transformed?.subscriptionLicense; const isLicenseActivated = !!(license?.status === LICENSE_STATUS.ACTIVATED); const isSubscriptionPlanCurrent = !!license?.subscriptionPlan.isCurrent; if (!isLicenseActivated || !isSubscriptionPlanCurrent) {