From 52115b8e3e3da4163663df0823a21ff072d64c2a Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Thu, 10 Oct 2024 10:40:24 -0400 Subject: [PATCH] feat: incrementally adopt Dashboard BFF API --- .../hooks/useEnterpriseCourseEnrollments.js | 36 +++- .../app/data/hooks/useSubscriptions.js | 28 +++- src/components/app/data/queries/queries.ts | 9 + .../app/data/queries/queryKeyFactory.js | 10 ++ src/components/app/data/services/bffs.js | 21 +++ src/components/app/data/services/index.js | 1 + .../data/services/subsidies/subscriptions.js | 117 +++++++------ src/components/app/routes/data/utils.js | 155 ++++++++++++------ .../dashboard/data/dashboardLoader.ts | 5 +- .../course-cards/unenroll/UnenrollModal.jsx | 35 +++- .../course-enrollments/data/hooks.js | 72 +++++--- .../UserEnrollmentForm.jsx | 17 +- 12 files changed, 369 insertions(+), 137 deletions(-) create mode 100644 src/components/app/data/services/bffs.js diff --git a/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js b/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js index 003268dce3..293d9ba47f 100644 --- a/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js +++ b/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { useLocation } from 'react-router-dom'; import { queryEnterpriseCourseEnrollments } from '../queries'; import useEnterpriseCustomer from './useEnterpriseCustomer'; @@ -11,6 +12,7 @@ import { transformLearnerContentAssignment, transformSubsidyRequest, } from '../utils'; +import { resolveBFFQuery } from '../../routes/data/utils'; import { COURSE_STATUSES } from '../../../../constants'; export const transformAllEnrollmentsByStatus = ({ @@ -27,6 +29,34 @@ export const transformAllEnrollmentsByStatus = ({ return enrollmentsByStatus; }; +export function useBaseEnterpriseCourseEnrollments(queryOptions = {}) { + const { data: enterpriseCustomer } = useEnterpriseCustomer(); + const location = useLocation(); + + // Determine the BFF query to use based on the current location + const matchedBFFQuery = resolveBFFQuery(location.pathname); + + // Determine the query configuration: use the matched BFF query or fallback to default + let queryConfig; + if (matchedBFFQuery) { + queryConfig = { + ...matchedBFFQuery(enterpriseCustomer.uuid), + // TODO: move transforms into the BFF response + select: (data) => data.enterpriseCourseEnrollments.map(transformCourseEnrollment), + }; + } else { + queryConfig = { + ...queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid), + select: (data) => data.map(transformCourseEnrollment), + }; + } + + return useQuery({ + ...queryConfig, + enabled: queryOptions.enabled, + }); +} + /** * Retrieves the relevant enterprise course enrollments, subsidy requests (e.g., license * requests), and content assignments for the active enterprise customer user. @@ -35,11 +65,7 @@ export const transformAllEnrollmentsByStatus = ({ 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: enterpriseCourseEnrollments } = useBaseEnterpriseCourseEnrollments(queryOptions); const { data: { requests } } = useBrowseAndRequest({ subscriptionLicensesQueryOptions: { select: (data) => data.map((subsidyRequest) => transformSubsidyRequest({ diff --git a/src/components/app/data/hooks/useSubscriptions.js b/src/components/app/data/hooks/useSubscriptions.js index 511cdebda2..a91745bee8 100644 --- a/src/components/app/data/hooks/useSubscriptions.js +++ b/src/components/app/data/hooks/useSubscriptions.js @@ -1,6 +1,10 @@ import { useQuery } from '@tanstack/react-query'; +import { useLocation } from 'react-router-dom'; + import { querySubscriptions } from '../queries'; import useEnterpriseCustomer from './useEnterpriseCustomer'; +import { transformSubscriptionsData } from '../services'; +import { resolveBFFQuery } from '../../routes/data'; /** * Custom hook to get subscriptions data for the enterprise. @@ -9,8 +13,30 @@ import useEnterpriseCustomer from './useEnterpriseCustomer'; */ export default function useSubscriptions(queryOptions = {}) { const { data: enterpriseCustomer } = useEnterpriseCustomer(); + const location = useLocation(); + + const matchedBFFQuery = resolveBFFQuery(location.pathname); + + // Determine the query configuration: use the matched BFF query or fallback to default + let queryConfig; + if (matchedBFFQuery) { + queryConfig = { + ...matchedBFFQuery(enterpriseCustomer.uuid), + select: (data) => { + const { customerAgreement, subscriptionLicenses } = data?.enterpriseCustomerUserSubsidies?.subscriptions || {}; + if (!customerAgreement || !subscriptionLicenses) { + return {}; + } + // TODO: move transforms into the BFF response + const transformedSubscriptionsData = transformSubscriptionsData(customerAgreement, subscriptionLicenses); + return transformedSubscriptionsData; + }, + }; + } else { + queryConfig = querySubscriptions(enterpriseCustomer.uuid); + } return useQuery({ - ...querySubscriptions(enterpriseCustomer.uuid), + ...queryConfig, ...queryOptions, }); } diff --git a/src/components/app/data/queries/queries.ts b/src/components/app/data/queries/queries.ts index 9239c8e6fa..0fb1b44208 100644 --- a/src/components/app/data/queries/queries.ts +++ b/src/components/app/data/queries/queries.ts @@ -265,3 +265,12 @@ export function queryVideoDetail(videoUUID: string, enterpriseUUID: string) { ._ctx.video ._ctx.detail(videoUUID); } + +// BFF queries + +export function queryEnterpriseLearnerDashboardBFF(enterpriseUuid: string) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.bffs._ctx.dashboard; +} diff --git a/src/components/app/data/queries/queryKeyFactory.js b/src/components/app/data/queries/queryKeyFactory.js index e4c5d90e28..41bbdf366d 100644 --- a/src/components/app/data/queries/queryKeyFactory.js +++ b/src/components/app/data/queries/queryKeyFactory.js @@ -16,6 +16,7 @@ import { fetchEnterpriseCourseEnrollments, fetchEnterpriseCuration, fetchEnterpriseCustomerContainsContent, + fetchEnterpriseLearnerDashboard, fetchEnterpriseLearnerData, fetchEnterpriseOffers, fetchInProgressPathways, @@ -47,6 +48,15 @@ const enterprise = createQueryKeys('enterprise', { enterpriseCustomer: (enterpriseUuid) => ({ queryKey: [enterpriseUuid], contextQueries: { + bffs: { + queryKey: null, + contextQueries: { + dashboard: ({ + queryKey: null, + queryFn: ({ queryKey }) => fetchEnterpriseLearnerDashboard(queryKey[2]), + }), + }, + }, academies: { queryKey: null, contextQueries: { diff --git a/src/components/app/data/services/bffs.js b/src/components/app/data/services/bffs.js new file mode 100644 index 0000000000..e392fce93b --- /dev/null +++ b/src/components/app/data/services/bffs.js @@ -0,0 +1,21 @@ +import { getConfig } from '@edx/frontend-platform/config'; +import { logError } from '@edx/frontend-platform/logging'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; + +export async function fetchEnterpriseLearnerDashboard(enterpriseId, lmsUserId) { + const { ENTERPRISE_ACCESS_BASE_URL } = getConfig(); + const params = { + enterprise_customer_uuid: enterpriseId, + lms_user_id: lmsUserId, + }; + const url = `${ENTERPRISE_ACCESS_BASE_URL}/api/v1/bffs/learner/dashboard/`; + try { + const result = await getAuthenticatedHttpClient().post(url, params); + return camelCaseObject(result.data); + } catch (error) { + logError(error); + // TODO: consider returning a sane default API response structure here to mitigate complete failure. + return {}; + } +} diff --git a/src/components/app/data/services/index.js b/src/components/app/data/services/index.js index 6e48de2d6e..7d84a856e2 100644 --- a/src/components/app/data/services/index.js +++ b/src/components/app/data/services/index.js @@ -9,3 +9,4 @@ export * from './subsidies'; export * from './user'; export * from './utils'; export * from './videos'; +export * from './bffs'; diff --git a/src/components/app/data/services/subsidies/subscriptions.js b/src/components/app/data/services/subsidies/subscriptions.js index 04ac8f8b31..56084b0684 100644 --- a/src/components/app/data/services/subsidies/subscriptions.js +++ b/src/components/app/data/services/subsidies/subscriptions.js @@ -193,9 +193,59 @@ export async function activateOrAutoApplySubscriptionLicense({ return activatedOrAutoAppliedLicense; } +export function transformSubscriptionsData(customerAgreement, subscriptionLicenses) { + const licensesByStatus = { + [LICENSE_STATUS.ACTIVATED]: [], + [LICENSE_STATUS.ASSIGNED]: [], + [LICENSE_STATUS.REVOKED]: [], + }; + const subscriptionsData = { + subscriptionLicenses, + customerAgreement, + subscriptionLicense: null, + subscriptionPlan: null, + licensesByStatus, + showExpirationNotifications: false, + shouldShowActivationSuccessMessage: false, + }; + + subscriptionsData.customerAgreement = customerAgreement; + subscriptionsData.showExpirationNotifications = !(customerAgreement?.disableExpirationNotifications); + + // Sort licenses within each license status by whether the associated subscription plans + // are current; current plans should be prioritized over non-current plans. + const sortedSubscriptionLicenses = [...subscriptionLicenses].sort((a, b) => { + const aIsCurrent = a.subscriptionPlan.isCurrent; + const bIsCurrent = b.subscriptionPlan.isCurrent; + if (aIsCurrent && bIsCurrent) { return 0; } + return aIsCurrent ? -1 : 1; + }); + subscriptionsData.subscriptionLicenses = sortedSubscriptionLicenses; + + // Group licenses by status. + subscriptionLicenses.forEach((license) => { + const { subscriptionPlan, status } = license; + const isUnassignedLicense = status === LICENSE_STATUS.UNASSIGNED; + if (isUnassignedLicense || !subscriptionPlan.isActive) { + return; + } + licensesByStatus[license.status].push(license); + }); + + // Extracts a single subscription license for the user, from the ordered licenses by status. + const applicableSubscriptionLicense = Object.values(licensesByStatus).flat()[0]; + if (applicableSubscriptionLicense) { + subscriptionsData.subscriptionLicense = applicableSubscriptionLicense; + subscriptionsData.subscriptionPlan = applicableSubscriptionLicense.subscriptionPlan; + } + subscriptionsData.licensesByStatus = licensesByStatus; + + return subscriptionsData; +} + /** - * TODO - * @returns + * Fetches the subscription licenses for the specified enterprise customer. + * @returns {Promise} The subscription licenses and related data. * @param enterpriseUUID */ export async function fetchSubscriptions(enterpriseUUID) { @@ -213,61 +263,30 @@ export async function fetchSubscriptions(enterpriseUUID) { * Example: an activated license will be chosen as the applicable license because activated licenses * come first in ``licensesByStatus`` even if the user also has a revoked license. */ - const licensesByStatus = { - [LICENSE_STATUS.ACTIVATED]: [], - [LICENSE_STATUS.ASSIGNED]: [], - [LICENSE_STATUS.REVOKED]: [], - }; - const subscriptionsData = { - subscriptionLicenses: [], - customerAgreement: null, - subscriptionLicense: null, - subscriptionPlan: null, - licensesByStatus, - showExpirationNotifications: false, - shouldShowActivationSuccessMessage: false, - }; try { const { results: subscriptionLicenses, response, } = await fetchPaginatedData(url); const { customerAgreement } = response; - if (customerAgreement) { - subscriptionsData.customerAgreement = customerAgreement; - } - subscriptionsData.showExpirationNotifications = !(customerAgreement?.disableExpirationNotifications); - - // Sort licenses within each license status by whether the associated subscription plans - // are current; current plans should be prioritized over non-current plans. - subscriptionLicenses.sort((a, b) => { - const aIsCurrent = a.subscriptionPlan.isCurrent; - const bIsCurrent = b.subscriptionPlan.isCurrent; - if (aIsCurrent && bIsCurrent) { return 0; } - return aIsCurrent ? -1 : 1; - }); - subscriptionsData.subscriptionLicenses = subscriptionLicenses; - // Group licenses by status. - subscriptionLicenses.forEach((license) => { - const { subscriptionPlan, status } = license; - const isUnassignedLicense = status === LICENSE_STATUS.UNASSIGNED; - if (isUnassignedLicense || !subscriptionPlan.isActive) { - return; - } - licensesByStatus[license.status].push(license); - }); - - // Extracts a single subscription license for the user, from the ordered licenses by status. - const applicableSubscriptionLicense = Object.values(licensesByStatus).flat()[0]; - if (applicableSubscriptionLicense) { - subscriptionsData.subscriptionLicense = applicableSubscriptionLicense; - subscriptionsData.subscriptionPlan = applicableSubscriptionLicense.subscriptionPlan; - } - subscriptionsData.licensesByStatus = licensesByStatus; - return subscriptionsData; + const transformedSubscriptionsData = transformSubscriptionsData(customerAgreement, subscriptionLicenses); + return transformedSubscriptionsData; } catch (error) { logError(error); - return subscriptionsData; + const emptySubscriptionsData = { + subscriptionLicenses: [], + customerAgreement: null, + subscriptionLicense: null, + subscriptionPlan: null, + licensesByStatus: { + [LICENSE_STATUS.ACTIVATED]: [], + [LICENSE_STATUS.ASSIGNED]: [], + [LICENSE_STATUS.REVOKED]: [], + }, + showExpirationNotifications: false, + shouldShowActivationSuccessMessage: false, + }; + return emptySubscriptionsData; } } diff --git a/src/components/app/routes/data/utils.js b/src/components/app/routes/data/utils.js index 1ffef3878f..1203becd6a 100644 --- a/src/components/app/routes/data/utils.js +++ b/src/components/app/routes/data/utils.js @@ -15,7 +15,7 @@ import { getProxyLoginUrl } from '@edx/frontend-enterprise-logistration'; import Cookies from 'universal-cookie'; import { - activateOrAutoApplySubscriptionLicense, + queryEnterpriseLearnerDashboardBFF, queryBrowseAndRequestConfiguration, queryContentHighlightsConfiguration, queryCouponCodeRequests, @@ -25,8 +25,38 @@ import { queryNotices, queryRedeemablePolicies, querySubscriptions, +} from '../../data/queries'; + +import { + activateOrAutoApplySubscriptionLicense, updateUserActiveEnterprise, -} from '../../data'; +} from '../../data/services'; + +/** + * Resolves the appropriate BFF query function to use for the current route. + * @param {string} pathname - The current route pathname. + * @returns {Function|null} The BFF query function to use for the current route, or null if no match is found. + */ +export function resolveBFFQuery(pathname) { + // Define route patterns and their corresponding query functions + const routeToBFFQueryMap = [ + { + pattern: '/:enterpriseSlug', + query: queryEnterpriseLearnerDashboardBFF, + }, + // Add more routes and queries incrementally as needed + ]; + + // Find the matching route and return the corresponding query function + const matchedRoute = routeToBFFQueryMap.find((route) => matchPath(route.pattern, pathname)); + + if (matchedRoute) { + return matchedRoute.query; + } + + // No match found + return null; +} /** * Ensures all enterprise-related app data is loaded. @@ -47,58 +77,80 @@ export async function ensureEnterpriseAppData({ queryClient, requestUrl, }) { - const subscriptionsQuery = querySubscriptions(enterpriseCustomer.uuid); - const enterpriseAppDataQueries = [ - // Enterprise Customer User Subsidies - queryClient.ensureQueryData(subscriptionsQuery).then(async (subscriptionsData) => { - // Auto-activate the user's subscription license, if applicable. - const activatedOrAutoAppliedLicense = await activateOrAutoApplySubscriptionLicense({ - enterpriseCustomer, - allLinkedEnterpriseCustomerUsers, - subscriptionsData, - requestUrl, - queryClient, - subscriptionsQuery, - }); - if (activatedOrAutoAppliedLicense) { - const { licensesByStatus } = subscriptionsData; - const updatedLicensesByStatus = { ...licensesByStatus }; - Object.entries(licensesByStatus).forEach(([status, licenses]) => { - const licensesIncludesActivatedOrAutoAppliedLicense = licenses.some( - (license) => license.uuid === activatedOrAutoAppliedLicense.uuid, - ); - const isCurrentStatusMatchingLicenseStatus = status === activatedOrAutoAppliedLicense.status; - if (licensesIncludesActivatedOrAutoAppliedLicense) { - updatedLicensesByStatus[status] = isCurrentStatusMatchingLicenseStatus - ? licenses.filter((license) => license.uuid !== activatedOrAutoAppliedLicense.uuid) - : [...licenses, activatedOrAutoAppliedLicense]; - } else if (isCurrentStatusMatchingLicenseStatus) { - updatedLicensesByStatus[activatedOrAutoAppliedLicense.status].push(activatedOrAutoAppliedLicense); - } + const enterpriseAppDataQueries = []; + const resolvedBFFQuery = resolveBFFQuery(requestUrl.pathname); + if (!resolvedBFFQuery) { + /** + * If the user is visiting a route configured to use a BFF, return early to avoid + * auto-activating or auto-applying the user's subscription license. All other + * routes will auto-activate or auto-apply the user's subscription license through + * the below logic. + * + * This is to an incremental migration to the Learner Portal's suite of + * Backend-for-Frontend (BFF) APIs, where the subscription license activation + * or auto-application is handled by the Learner BFF. + * + * As such, the dashboardLoader is now responsible for the auto-activation or auto-application of the + * user's subscription license via the Dashboard BFF. The existing subscriptions-related query cache will be + * optimistilly updated with the auto-activated or auto-applied subscription license, if applicable, + * after resolving the dashboardLoader's request to the dashboard's BFF API. + */ + const subscriptionsQuery = querySubscriptions(enterpriseCustomer.uuid); + enterpriseAppDataQueries.push( + queryClient.ensureQueryData(subscriptionsQuery).then(async (subscriptionsData) => { + // Auto-activate or auto-apply the user's subscription license, if applicable. + const activatedOrAutoAppliedLicense = await activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer, + allLinkedEnterpriseCustomerUsers, + subscriptionsData, + requestUrl, + queryClient, + subscriptionsQuery, }); - // Optimistically update the query cache with the auto-activated or auto-applied subscription license. - const updatedSubscriptionLicenses = subscriptionsData.subscriptionLicenses.length > 0 - ? subscriptionsData.subscriptionLicenses.map((license) => { - // Ensures an auto-activated license is updated in the query cache to change - // its status from "assigned" to "activated". - if (license.uuid === activatedOrAutoAppliedLicense.uuid) { - return activatedOrAutoAppliedLicense; + if (activatedOrAutoAppliedLicense) { + const { licensesByStatus } = subscriptionsData; + const updatedLicensesByStatus = { ...licensesByStatus }; + Object.entries(licensesByStatus).forEach(([status, licenses]) => { + const licensesIncludesActivatedOrAutoAppliedLicense = licenses.some( + (license) => license.uuid === activatedOrAutoAppliedLicense.uuid, + ); + const isCurrentStatusMatchingLicenseStatus = status === activatedOrAutoAppliedLicense.status; + if (licensesIncludesActivatedOrAutoAppliedLicense) { + updatedLicensesByStatus[status] = isCurrentStatusMatchingLicenseStatus + ? licenses.filter((license) => license.uuid !== activatedOrAutoAppliedLicense.uuid) + : [...licenses, activatedOrAutoAppliedLicense]; + } else if (isCurrentStatusMatchingLicenseStatus) { + updatedLicensesByStatus[activatedOrAutoAppliedLicense.status].push(activatedOrAutoAppliedLicense); } - return license; - }) - : [activatedOrAutoAppliedLicense]; + }); + // Optimistically update the query cache with the auto-activated or auto-applied subscription license. + const updatedSubscriptionLicenses = subscriptionsData.subscriptionLicenses.length > 0 + ? subscriptionsData.subscriptionLicenses.map((license) => { + // Ensures an auto-activated license is updated in the query cache to change + // its status from "assigned" to "activated". + if (license.uuid === activatedOrAutoAppliedLicense.uuid) { + return activatedOrAutoAppliedLicense; + } + return license; + }) + : [activatedOrAutoAppliedLicense]; - queryClient.setQueryData(subscriptionsQuery.queryKey, { - ...queryClient.getQueryData(subscriptionsQuery.queryKey), - licensesByStatus: updatedLicensesByStatus, - subscriptionPlan: activatedOrAutoAppliedLicense.subscriptionPlan, - subscriptionLicense: activatedOrAutoAppliedLicense, - subscriptionLicenses: updatedSubscriptionLicenses, - }); - } + queryClient.setQueryData(subscriptionsQuery.queryKey, { + ...queryClient.getQueryData(subscriptionsQuery.queryKey), + licensesByStatus: updatedLicensesByStatus, + subscriptionPlan: activatedOrAutoAppliedLicense.subscriptionPlan, + subscriptionLicense: activatedOrAutoAppliedLicense, + subscriptionLicenses: updatedSubscriptionLicenses, + }); + } - return subscriptionsData; - }), + return subscriptionsData; + }), + ); + } + + // Load the rest of the enterprise app data. + enterpriseAppDataQueries.push(...[ queryClient.ensureQueryData( queryRedeemablePolicies({ enterpriseUuid: enterpriseCustomer.uuid, @@ -124,7 +176,8 @@ export async function ensureEnterpriseAppData({ queryClient.ensureQueryData( queryContentHighlightsConfiguration(enterpriseCustomer.uuid), ), - ]; + ]); + if (getConfig().ENABLE_NOTICES) { enterpriseAppDataQueries.push( queryClient.ensureQueryData(queryNotices()), diff --git a/src/components/dashboard/data/dashboardLoader.ts b/src/components/dashboard/data/dashboardLoader.ts index 1e9d981ad2..d719240e01 100644 --- a/src/components/dashboard/data/dashboardLoader.ts +++ b/src/components/dashboard/data/dashboardLoader.ts @@ -1,9 +1,9 @@ import { ensureAuthenticatedUser } from '../../app/routes/data'; import { extractEnterpriseCustomer, - queryEnterpriseCourseEnrollments, queryEnterprisePathwaysList, queryEnterpriseProgramsList, + queryEnterpriseLearnerDashboardBFF, } from '../../app/data'; type DashboardRouteParams = Types.RouteParams & { @@ -31,8 +31,9 @@ const makeDashboardLoader: Types.MakeRouteLoaderFunctionWithQueryClient = functi authenticatedUser, enterpriseSlug, }); + await Promise.all([ - queryClient.ensureQueryData(queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid)), + queryClient.ensureQueryData(queryEnterpriseLearnerDashboardBFF(enterpriseCustomer.uuid)), queryClient.ensureQueryData(queryEnterpriseProgramsList(enterpriseCustomer.uuid)), queryClient.ensureQueryData(queryEnterprisePathwaysList(enterpriseCustomer.uuid)), ]); 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..e9811a5810 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 @@ -8,13 +8,37 @@ import { logError } from '@edx/frontend-platform/logging'; import { ToastsContext } from '../../../../../Toasts'; import { unenrollFromCourse } from './data'; -import { queryEnterpriseCourseEnrollments, useEnterpriseCustomer } from '../../../../../app/data'; +import { queryEnterpriseCourseEnrollments, queryEnterpriseLearnerDashboardBFF, useEnterpriseCustomer } from '../../../../../app/data'; const btnLabels = { default: 'Unenroll', pending: 'Unenrolling...', }; +function handleQueriesForUnroll(queryClient, courseRunId, enterpriseCustomer) { + const enrollmentForCourseFilter = (enrollment) => enrollment.courseRunId !== courseRunId; + + // Determine which BFF queries need to be updated after unenrolling. + const learnerDashboardBFFQueryKey = queryEnterpriseLearnerDashboardBFF(enterpriseCustomer.uuid).queryKey; + const bffQueryKeysToUpdate = [learnerDashboardBFFQueryKey]; + + // Update the enterpriseCourseEnrollments data in the cache for each BFF query. + bffQueryKeysToUpdate.forEach((queryKey) => { + const existingBFFData = queryClient.getQueryData(queryKey); + 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); + const updatedCourseEnrollmentsData = existingCourseEnrollmentsData.filter(enrollmentForCourseFilter); + queryClient.setQueryData(enterpriseCourseEnrollmentsQueryKey, updatedCourseEnrollmentsData); +} + const UnenrollModal = ({ courseRunId, isOpen, @@ -43,14 +67,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), - ); + handleQueriesForUnroll(queryClient, courseRunId, enterpriseCustomer); addToast('You have been unenrolled from the course.'); onSuccess(); }; 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..31d228dd3d 100644 --- a/src/components/dashboard/main-content/course-enrollments/data/hooks.js +++ b/src/components/dashboard/main-content/course-enrollments/data/hooks.js @@ -28,6 +28,7 @@ import { LEARNER_CREDIT_SUBSIDY_TYPE, LICENSE_SUBSIDY_TYPE, queryEnterpriseCourseEnrollments, + queryEnterpriseLearnerDashboardBFF, queryRedeemablePolicies, transformCourseEnrollment, useCanUpgradeWithLearnerCredit, @@ -525,29 +526,62 @@ export function useCourseEnrollmentsBySection(courseEnrollmentsByStatus) { }; } +function handleQueriesForUpdatedCourseEnrollmentStatus( + queryClient, + location, + enterpriseCustomer, + courseRunId, + updatedEnrollmentParams, +) { + const { newStatus, savedForLater } = updatedEnrollmentParams; + const transformUpdatedEnrollment = (enrollment) => { + if (enrollment.courseRunId !== courseRunId) { + return enrollment; + } + return { + ...enrollment, + courseRunStatus: newStatus, + savedForLater, + }; + }; + + // Determine which BFF queries need to be updated after unenrolling. + const learnerDashboardBFFQueryKey = queryEnterpriseLearnerDashboardBFF(enterpriseCustomer.uuid).queryKey; + const bffQueryKeysToUpdate = [learnerDashboardBFFQueryKey]; + + // Update the enterpriseCourseEnrollments data in the cache for each BFF query. + bffQueryKeysToUpdate.forEach((queryKey) => { + const existingBFFData = queryClient.getQueryData(queryKey); + const updatedBFFData = { + ...existingBFFData, + enterpriseCourseEnrollments: existingBFFData.enterpriseCourseEnrollments.map(transformUpdatedEnrollment), + }; + queryClient.setQueryData(queryKey, updatedBFFData); + }); + + // Update the legacy queryEnterpriseCourseEnrollments cache as well. + const enterpriseCourseEnrollmentsQueryKey = queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid).queryKey; + const existingCourseEnrollmentsData = queryClient.getQueryData(enterpriseCourseEnrollmentsQueryKey); + const updatedCourseEnrollmentsData = existingCourseEnrollmentsData.map(transformUpdatedEnrollment); + queryClient.setQueryData(enterpriseCourseEnrollmentsQueryKey, updatedCourseEnrollmentsData); +} + export const useUpdateCourseEnrollmentStatus = ({ enterpriseCustomer }) => { const queryClient = useQueryClient(); + const location = useLocation(); 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, - }; - } - return enrollment; - }), - ); - }, [ - enterpriseCustomer.uuid, - queryClient, - ]); + handleQueriesForUpdatedCourseEnrollmentStatus({ + queryClient, + location, + enterpriseCustomer, + courseRunId, + updatedEnrollmentParams: { + newStatus, + savedForLater, + }, + }); + }, [enterpriseCustomer, queryClient, location]); return updateCourseEnrollmentStatus; }; diff --git a/src/components/executive-education-2u/UserEnrollmentForm.jsx b/src/components/executive-education-2u/UserEnrollmentForm.jsx index e906f08c30..bd32d240c6 100644 --- a/src/components/executive-education-2u/UserEnrollmentForm.jsx +++ b/src/components/executive-education-2u/UserEnrollmentForm.jsx @@ -23,6 +23,7 @@ import { LEARNER_CREDIT_SUBSIDY_TYPE, queryCanRedeemContextQueryKey, queryEnterpriseCourseEnrollments, + queryEnterpriseLearnerDashboardBFF, queryRedeemablePolicies, useCourseMetadata, useEnterpriseCourseEnrollments, @@ -30,6 +31,20 @@ import { } from '../app/data'; import { useUserSubsidyApplicableToCourse } from '../course/data'; +function handleQueriesForEnrollSuccess(queryClient, enterpriseCustomer) { + // Determine which BFF queries need to be updated after successfully enrolling. + const learnerDashboardBFFQueryKey = queryEnterpriseLearnerDashboardBFF(enterpriseCustomer.uuid).queryKey; + const bffQueryKeysToUpdate = [learnerDashboardBFFQueryKey]; + + // Invalidate the cache for each BFF query. + bffQueryKeysToUpdate.forEach((queryKey) => { + queryClient.invalidateQueries({ queryKey }); + }); + + // Invalidate the legacy queryEnterpriseCourseEnrollments cache as well. + queryClient.invalidateQueries({ queryKey: queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid) }); +} + const UserEnrollmentForm = ({ className }) => { const navigate = useNavigate(); const config = getConfig(); @@ -66,7 +81,7 @@ const UserEnrollmentForm = ({ className }) => { lmsUserId: userId, }), }), - queryClient.invalidateQueries({ queryKey: queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid) }), + handleQueriesForEnrollSuccess(queryClient, enterpriseCustomer), sendEnterpriseTrackEventWithDelay( enterpriseCustomer.uuid, 'edx.ui.enterprise.learner_portal.executive_education.checkout_form.submitted',