From e5e7deb0bb913cace57a15f3f9386b809b53f4b0 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 4 Mar 2024 08:54:22 -0500 Subject: [PATCH 01/16] feat: migrates license activation and auto-apply to route loaders --- src/components/app/App.jsx | 11 +- src/components/app/Layout.jsx | 15 +- src/components/app/Root.jsx | 22 +- src/components/app/data/createAppRouter.jsx | 74 +++-- .../data/queries/ensureEnterpriseAppData.js | 57 +++- .../app/routes/data/queries/index.js | 54 ---- src/components/app/routes/data/services.js | 51 ++-- src/components/app/routes/data/utils.js | 269 ++++++++++++++++-- src/components/app/routes/index.js | 7 + src/components/app/routes/loaders/index.js | 1 - .../app/routes/loaders/rootLoader.js | 49 +++- ...ctiveEnterpriseCustomerUserLoader.test.jsx | 167 ----------- ...pdateActiveEnterpriseCustomerUserLoader.js | 74 ----- .../data/hooks/hooks.js | 14 +- .../site-header/SiteHeaderNavMenu.jsx | 2 + 15 files changed, 445 insertions(+), 422 deletions(-) create mode 100644 src/components/app/routes/index.js delete mode 100644 src/components/app/routes/loaders/tests/updateActiveEnterpriseCustomerUserLoader.test.jsx delete mode 100644 src/components/app/routes/loaders/updateActiveEnterpriseCustomerUserLoader.js diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index 5bf94cb117..fa1fb301bb 100644 --- a/src/components/app/App.jsx +++ b/src/components/app/App.jsx @@ -14,6 +14,7 @@ import { // import extractNamedExport from '../../utils/extract-named-export'; import createAppRouter from './data/createAppRouter'; +import { useNProgressLoader } from './Root'; /* eslint-disable max-len */ // const EnterpriseAppPageRoutes = lazy(() => import(/* webpackChunkName: "enterprise-app-routes" */ './EnterpriseAppPageRoutes')); @@ -40,11 +41,19 @@ const queryClient = new QueryClient({ const router = createAppRouter(queryClient); +const RouterFallback = () => { + useNProgressLoader(); + return null; +}; + const App = () => ( - + } + /> {/* page routes for the app diff --git a/src/components/app/Layout.jsx b/src/components/app/Layout.jsx index 95b55e61ea..8577d35511 100644 --- a/src/components/app/Layout.jsx +++ b/src/components/app/Layout.jsx @@ -7,7 +7,6 @@ import SiteFooter from '@edx/frontend-component-footer'; import { useEnterpriseLearner, isSystemMaintenanceAlertOpen } from './data'; import { useStylesForCustomBrandColors } from '../layout/data/hooks'; import NotFoundPage from '../NotFoundPage'; -import DelayedFallbackContainer from '../DelayedFallback/DelayedFallbackContainer'; import { SiteHeader } from '../site-header'; import { EnterpriseBanner } from '../enterprise-banner'; import { SystemWideWarningBanner } from '../system-wide-banner'; @@ -16,7 +15,7 @@ export const TITLE_TEMPLATE = '%s - edX'; export const DEFAULT_TITLE = 'edX'; const Layout = () => { - const { authenticatedUser, config } = useContext(AppContext); + const { config } = useContext(AppContext); const { data: enterpriseLearnerData } = useEnterpriseLearner(); const brandStyles = useStylesForCustomBrandColors(enterpriseLearnerData.enterpriseCustomer); @@ -27,18 +26,6 @@ const Layout = () => { return ; } - // User is authenticated with an active enterprise customer, but - // the user account API data is still hydrating. If it is still - // hydrating, render a loading state. - if (!authenticatedUser.profileImage) { - return ( - - ); - } - return ( <> diff --git a/src/components/app/Root.jsx b/src/components/app/Root.jsx index 403d3274c2..0f85a800ca 100644 --- a/src/components/app/Root.jsx +++ b/src/components/app/Root.jsx @@ -18,23 +18,30 @@ import { ErrorPage } from '../error-page'; // for quick route transitions. export const NPROGRESS_DELAY_MS = 300; -const Root = () => { +export function useNProgressLoader() { const { authenticatedUser } = useContext(AppContext); const navigation = useNavigation(); const fetchers = useFetchers(); - const { enterpriseSlug } = useParams(); useEffect(() => { const timeoutId = setTimeout(() => { const fetchersIdle = fetchers.every((f) => f.state === 'idle'); - if (navigation.state === 'idle' && fetchersIdle) { + const isAuthenticatedUserHydrated = !!authenticatedUser?.profileImage; + if (navigation.state === 'idle' && fetchersIdle && isAuthenticatedUserHydrated) { NProgress.done(); } else { NProgress.start(); } }, NPROGRESS_DELAY_MS); return () => clearTimeout(timeoutId); - }, [navigation, fetchers]); + }, [navigation, fetchers, authenticatedUser]); +} + +const Root = () => { + const { authenticatedUser } = useContext(AppContext); + const { enterpriseSlug } = useParams(); + + useNProgressLoader(); // in the special case where there is not authenticated user and we are being told it's the logout // flow, we can show the logout message safely. @@ -55,6 +62,13 @@ const Root = () => { ); } + // User is authenticated with an active enterprise customer, but + // the user account API data is still hydrating. If the user is + // still hydrating, the NProgress loader will not complete. + if (!authenticatedUser.profileImage) { + return null; + } + // User is authenticated, so render the child routes (rest of the app). return ( diff --git a/src/components/app/data/createAppRouter.jsx b/src/components/app/data/createAppRouter.jsx index 5c39ed0bc7..83c3c05b56 100644 --- a/src/components/app/data/createAppRouter.jsx +++ b/src/components/app/data/createAppRouter.jsx @@ -1,16 +1,12 @@ import { PageWrap } from '@edx/frontend-platform/react'; import { - Outlet, - Route, - createBrowserRouter, - createRoutesFromElements, + Route, createBrowserRouter, createRoutesFromElements, } from 'react-router-dom'; import RouteErrorBoundary from '../routes/RouteErrorBoundary'; import { makeCourseLoader, makeRootLoader, - makeUpdateActiveEnterpriseCustomerUserLoader, makeDashboardLoader, } from '../routes/loaders'; import Root from '../Root'; @@ -32,45 +28,39 @@ export default function createAppRouter(queryClient) { > } + loader={makeRootLoader(queryClient)} + element={} > } - > - { - const { default: DashboardRoute } = await import('../routes/DashboardRoute'); - return { - Component: DashboardRoute, - loader: makeDashboardLoader(queryClient), - }; - }} - /> - { - const { default: SearchRoute } = await import('../routes/SearchRoute'); - return { - Component: SearchRoute, - }; - }} - /> - { - const { default: CourseRoute } = await import('../routes/CourseRoute'); - return { - Component: CourseRoute, - loader: makeCourseLoader(queryClient), - }; - }} - /> - } /> - + index + lazy={async () => { + const { DashboardRoute } = await import('../routes'); + return { + Component: DashboardRoute, + loader: makeDashboardLoader(queryClient), + }; + }} + /> + { + const { SearchRoute } = await import('../routes'); + return { + Component: SearchRoute, + }; + }} + /> + { + const { CourseRoute } = await import('../routes'); + return { + Component: CourseRoute, + loader: makeCourseLoader(queryClient), + }; + }} + /> + } /> } /> , diff --git a/src/components/app/routes/data/queries/ensureEnterpriseAppData.js b/src/components/app/routes/data/queries/ensureEnterpriseAppData.js index cfaf67dede..8f1cabba2b 100644 --- a/src/components/app/routes/data/queries/ensureEnterpriseAppData.js +++ b/src/components/app/routes/data/queries/ensureEnterpriseAppData.js @@ -1,3 +1,7 @@ +import dayjs from 'dayjs'; + +import { activateLicense, requestAutoAppliedUserLicense } from '../services'; +import { activateOrAutoApplySubscriptionLicense } from '../utils'; import queryContentHighlightsConfiguration from './contentHighlights'; import { queryCouponCodeRequests, @@ -9,17 +13,56 @@ import { queryBrowseAndRequestConfiguration, } from './subsidies'; -export default function ensureEnterpriseAppData({ +/** + * TODO + * @param {*} param0 + * @returns + */ +export default async function ensureEnterpriseAppData({ enterpriseCustomer, userId, userEmail, queryClient, + requestUrl, }) { - return [ + const subscriptionsQuery = querySubscriptions(enterpriseCustomer.uuid); + const enterpriseAppData = await Promise.all([ // Enterprise Customer User Subsidies - queryClient.ensureQueryData( - querySubscriptions(enterpriseCustomer.uuid), - ), + queryClient.ensureQueryData(subscriptionsQuery).then(async (subscriptionsData) => { + // Auto-activate the user's subscription license, if applicable. + await activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer, + requestUrl, + subscriptionsData, + async activateAllocatedSubscriptionLicense(subscriptionLicenseToActivate) { + await activateLicense(subscriptionLicenseToActivate.activationKey); + const autoActivatedSubscriptionLicense = { + ...subscriptionLicenseToActivate, + status: 'activated', + activationDate: dayjs().toISOString(), + }; + // Optimistically update the query cache with the auto-activated subscription license. + queryClient.setQueryData(subscriptionsQuery.queryKey, { + ...subscriptionsData, + subscriptionLicenses: subscriptionsData.subscriptionLicenses.map((license) => { + if (license.uuid === autoActivatedSubscriptionLicense.uuid) { + return autoActivatedSubscriptionLicense; + } + return license; + }), + }); + }, + async requestAutoAppliedSubscriptionLicense(customerAgreement) { + const autoAppliedSubscriptionLicense = await requestAutoAppliedUserLicense(customerAgreement.uuid); + // Optimistically update the query cache with the auto-applied subscription license. + queryClient.setQueryData(subscriptionsQuery.queryKey, { + ...subscriptionsData, + subscriptionLicenses: [autoAppliedSubscriptionLicense], + }); + }, + }); + return subscriptionsData; + }), queryClient.ensureQueryData( queryRedeemablePolicies({ enterpriseUuid: enterpriseCustomer.uuid, @@ -45,5 +88,7 @@ export default function ensureEnterpriseAppData({ queryClient.ensureQueryData( queryContentHighlightsConfiguration(enterpriseCustomer.uuid), ), - ]; + ]); + + return enterpriseAppData; } diff --git a/src/components/app/routes/data/queries/index.js b/src/components/app/routes/data/queries/index.js index b9126c0b02..19c4eaa208 100644 --- a/src/components/app/routes/data/queries/index.js +++ b/src/components/app/routes/data/queries/index.js @@ -1,5 +1,3 @@ -import { updateUserActiveEnterprise } from '../services'; -import ensureEnterpriseAppData from './ensureEnterpriseAppData'; import queryEnterpriseLearner from './enterpriseLearner'; export { default as queryCanRedeem } from './canRedeemCourse'; @@ -15,58 +13,6 @@ export { export * from './subsidies'; -/** - * Updates the active enterprise customer for the learner. - * @param {Object} params - The parameters object. - * @param {Object} params.enterpriseCustomerUser - The enterprise customer user. - * @param {Object[]} params.allLinkedEnterpriseCustomerUsers - All linked enterprise customer users. - * @param {string} params.userId - The user ID. - * @param {string} params.userEmail - The user email. - * @param {string} params.username - The user username. - * @param {Object} params.queryClient - The query client. - * @returns {Promise} - A promise that resolves when the active enterprise customer is updated - * and the query cache is updated with fresh data. - */ -export async function updateActiveEnterpriseCustomerUser({ - enterpriseCustomerUser, - allLinkedEnterpriseCustomerUsers, - userId, - userEmail, - username, - queryClient, -}) { - // Makes the POST API request to update the active enterprise customer - // for the learner in the backend for future sessions. - await updateUserActiveEnterprise({ - enterpriseCustomer: enterpriseCustomerUser.enterpriseCustomer, - }); - // Perform optimistic update of the query cache to avoid duplicate API request for the same data. The only - // difference is that the query key now contains the new enterprise slug, so we can proactively set the query - // cache for with the enterprise learner data we already have before resolving the loader. - const enterpriseLearnerQuery = queryEnterpriseLearner(username, enterpriseCustomerUser.enterpriseCustomer.slug); - queryClient.setQueryData(enterpriseLearnerQuery.queryKey, { - enterpriseCustomer: enterpriseCustomerUser.enterpriseCustomer, - enterpriseCustomerUserRoleAssignments: enterpriseCustomerUser.roleAssignments, - activeEnterpriseCustomer: enterpriseCustomerUser.enterpriseCustomer, - activeEnterpriseCustomerUserRoleAssignments: enterpriseCustomerUser.roleAssignments, - allLinkedEnterpriseCustomerUsers: allLinkedEnterpriseCustomerUsers.map( - ecu => ({ - ...ecu, - active: ( - ecu.enterpriseCustomer.uuid === enterpriseCustomerUser.enterpriseCustomer.uuid - ), - }), - ), - }); - // Refetch all enterprise app data for the new active enterprise customer. - await Promise.all(ensureEnterpriseAppData({ - enterpriseCustomer: enterpriseCustomerUser.enterpriseCustomer, - userId, - userEmail, - queryClient, - })); -} - /** * Extracts the appropriate enterprise ID for the current user and enterprise slug. * @param {Object} params - The parameters object. diff --git a/src/components/app/routes/data/services.js b/src/components/app/routes/data/services.js index 6d59508d70..738a81078a 100644 --- a/src/components/app/routes/data/services.js +++ b/src/components/app/routes/data/services.js @@ -85,9 +85,10 @@ export async function fetchEnterpriseLearnerData(username, enterpriseSlug, optio return { enterpriseCustomer: transformEnterpriseCustomer(enterpriseCustomer, enterpriseFeatures), enterpriseCustomerUserRoleAssignments: roleAssignments, - activeEnterpriseCustomer, + activeEnterpriseCustomer: transformEnterpriseCustomer(activeEnterpriseCustomer, enterpriseFeatures), activeEnterpriseCustomerUserRoleAssignments, allLinkedEnterpriseCustomerUsers: linkedEnterpriseCustomersUsers, + enterpriseFeatures, }; } @@ -338,30 +339,49 @@ export async function fetchRedeemablePolicies(enterpriseUUID, userID) { } // Subscriptions -async function fetchSubscriptionLicensesForUser(enterpriseUUID) { +/** + * TODO + * @returns + * @param enterpriseUUID + */ +export async function fetchSubscriptions(enterpriseUUID) { const queryParams = new URLSearchParams({ enterprise_customer_uuid: enterpriseUUID, include_revoked: true, }); - const config = getConfig(); - const url = `${config.LICENSE_MANAGER_URL}/api/v1/learner-licenses/?${queryParams.toString()}`; + const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/learner-licenses/?${queryParams.toString()}`; const response = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(response.data); + const responseData = camelCaseObject(response.data); + // Extracts customer agreement and removes it from the original response object + const { customerAgreement } = responseData; + const subscriptionsData = { + subscriptionLicenses: responseData.results, + customerAgreement, + }; + return subscriptionsData; } /** * TODO + * @param {*} activationKey * @returns - * @param enterpriseUuid */ -export async function fetchSubscriptions(enterpriseUuid) { - const response = await fetchSubscriptionLicensesForUser(enterpriseUuid); - // Extracts customer agreement and removes it from the original response object - const { customerAgreement } = response; - return { - subscriptionLicenses: response.results, - customerAgreement, - }; +export async function activateLicense(activationKey) { + const queryParams = new URLSearchParams({ activation_key: activationKey }); + const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/license-activation/?${queryParams.toString()}`; + return getAuthenticatedHttpClient().post(url); +} + +/** + * Attempts to auto-apply a license for the authenticated user and the specified customer agreement. + * + * @param {string} customerAgreementId The UUID of the customer agreement. + * @returns An object representing the auto-applied license or null if no license was auto-applied. + */ +export async function requestAutoAppliedUserLicense(customerAgreementId) { + const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/customer-agreement/${customerAgreementId}/auto-apply/`; + const response = await getAuthenticatedHttpClient().post(url); + return camelCaseObject(response.data); } /** @@ -372,8 +392,7 @@ export async function fetchSubscriptions(enterpriseUuid) { * @returns {Promise} - A promise that resolves when the active enterprise customer is updated. */ export async function updateUserActiveEnterprise({ enterpriseCustomer }) { - const config = getConfig(); - const url = `${config.LMS_BASE_URL}/enterprise/select/active/`; + const url = `${getConfig().LMS_BASE_URL}/enterprise/select/active/`; const formData = new FormData(); formData.append('enterprise', enterpriseCustomer.uuid); return getAuthenticatedHttpClient().post(url, formData); diff --git a/src/components/app/routes/data/utils.js b/src/components/app/routes/data/utils.js index f82bc14e9f..d940954115 100644 --- a/src/components/app/routes/data/utils.js +++ b/src/components/app/routes/data/utils.js @@ -12,6 +12,7 @@ import { logError, NewRelicLoggingService, } from '@edx/frontend-platform/logging'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { getProxyLoginUrl } from '@edx/frontend-enterprise-logistration'; import Cookies from 'universal-cookie'; @@ -82,6 +83,7 @@ export function redirectToRemoveTrailingSlash(requestUrl) { * Ensures that the user is authenticated. If not, redirects to the login page. * @param {URL} requestUrl - The current request URL to redirect back to if the * user is not authenticated. + * @param {Object} params - The parameters object. */ export async function ensureAuthenticatedUser(requestUrl, params) { configureLogging(NewRelicLoggingService, { @@ -134,34 +136,6 @@ export async function ensureAuthenticatedUser(requestUrl, params) { return authenticatedUser; } -/** - * Helper function to determine which linked enterprise customer user record - * should be used for display in the UI. - * @param {*} param0 - * @returns - */ -export function determineEnterpriseCustomerUserForDisplay({ - activeEnterpriseCustomer, - activeEnterpriseCustomerUserRoleAssignments, - enterpriseSlug, - foundEnterpriseCustomerUserForCurrentSlug, -}) { - const activeEnterpriseCustomerUser = { - enterpriseCustomer: activeEnterpriseCustomer, - roleAssignments: activeEnterpriseCustomerUserRoleAssignments, - }; - if (!enterpriseSlug) { - return activeEnterpriseCustomerUser; - } - if (enterpriseSlug !== activeEnterpriseCustomer.slug && foundEnterpriseCustomerUserForCurrentSlug) { - return { - enterpriseCustomer: foundEnterpriseCustomerUserForCurrentSlug.enterpriseCustomer, - roleAssignments: foundEnterpriseCustomerUserForCurrentSlug.roleAssignments, - }; - } - return activeEnterpriseCustomerUser; -} - /** * Transform enterprise customer metadata for use by consuming UI components. * @param {Object} enterpriseCustomer @@ -205,6 +179,107 @@ export function transformEnterpriseCustomer(enterpriseCustomer, enterpriseFeatur }; } +/** + * TODO + * @param {*} enterpriseSlug + * @param {*} activeEnterpriseCustomer + * @param {*} allLinkedEnterpriseCustomerUsers + * @param {*} requestUrl + * @param {*} queryClient + * @param {*} updateActiveEnterpriseCustomerUser + * @param {*} enterpriseFeatures + * @returns + */ +export async function ensureActiveEnterpriseCustomerUser({ + enterpriseSlug, + activeEnterpriseCustomer, + allLinkedEnterpriseCustomerUsers, + requestUrl, + queryClient, + updateActiveEnterpriseCustomerUser, + enterpriseFeatures, +}) { + // If the enterprise slug in the URL matches the active enterprise customer's slug, return early. + if (enterpriseSlug === activeEnterpriseCustomer.slug) { + return null; + } + + // Otherwise, try to find the enterprise customer for the given slug and, if found, update it + // as the active enterprise customer for the learner. + const foundEnterpriseCustomerUserForSlug = allLinkedEnterpriseCustomerUsers.find( + enterpriseCustomerUser => enterpriseCustomerUser.enterpriseCustomer.slug === enterpriseSlug, + ); + if (!foundEnterpriseCustomerUserForSlug) { + throw redirect(generatePath('/:enterpriseSlug/*', { + enterpriseSlug: activeEnterpriseCustomer.slug, + '*': requestUrl.pathname.split('/').filter(pathPart => !!pathPart).slice(1).join('/'), + })); + } + + const { + enterpriseCustomer: nextActiveEnterpriseCustomer, + roleAssignments: nextActiveEnterpriseCustomerRoleAssignments, + } = foundEnterpriseCustomerUserForSlug; + const transformedNextActiveEnterpriseCustomer = transformEnterpriseCustomer( + nextActiveEnterpriseCustomer, + enterpriseFeatures, + ); + // Perform POST API request to update the active enterprise customer user. + const nextEnterpriseLearnerQuery = await updateActiveEnterpriseCustomerUser(nextActiveEnterpriseCustomer); + const updatedLinkedEnterpriseCustomerUsers = allLinkedEnterpriseCustomerUsers.map( + ecu => ({ + ...ecu, + active: ( + ecu.enterpriseCustomer.uuid === nextActiveEnterpriseCustomer.uuid + ), + }), + ); + + // Perform optimistic update of the query cache to avoid duplicate API request for the same data. The only + // difference is that the query key now contains the new enterprise slug, so we can proactively set the query + // cache for with the enterprise learner data we already have before resolving the loader. + queryClient.setQueryData(nextEnterpriseLearnerQuery.queryKey, { + enterpriseCustomer: transformedNextActiveEnterpriseCustomer, + enterpriseCustomerUserRoleAssignments: nextActiveEnterpriseCustomerRoleAssignments, + activeEnterpriseCustomer: transformedNextActiveEnterpriseCustomer, + activeEnterpriseCustomerUserRoleAssignments: nextActiveEnterpriseCustomerRoleAssignments, + allLinkedEnterpriseCustomerUsers: updatedLinkedEnterpriseCustomerUsers, + }); + + return { + enterpriseCustomer: transformedNextActiveEnterpriseCustomer, + updatedLinkedEnterpriseCustomerUsers, + }; +} + +/** + * Helper function to determine which linked enterprise customer user record + * should be used for display in the UI. + * @param {*} param0 + * @returns + */ +export function determineEnterpriseCustomerUserForDisplay({ + activeEnterpriseCustomer, + activeEnterpriseCustomerUserRoleAssignments, + enterpriseSlug, + foundEnterpriseCustomerUserForCurrentSlug, +}) { + const activeEnterpriseCustomerUser = { + enterpriseCustomer: activeEnterpriseCustomer, + roleAssignments: activeEnterpriseCustomerUserRoleAssignments, + }; + if (!enterpriseSlug) { + return activeEnterpriseCustomerUser; + } + if (enterpriseSlug !== activeEnterpriseCustomer.slug && foundEnterpriseCustomerUserForCurrentSlug) { + return { + enterpriseCustomer: foundEnterpriseCustomerUserForCurrentSlug.enterpriseCustomer, + roleAssignments: foundEnterpriseCustomerUserForCurrentSlug.roleAssignments, + }; + } + return activeEnterpriseCustomerUser; +} + /** * Transforms the redeemable policies data by attaching the subsidy expiration date * to each assignment within the policies, if available. @@ -301,3 +376,141 @@ export function getAssignmentsByState(assignments = []) { hasAssignmentsForDisplay, }; } + +export function redirectToDashboardAfterLicenseActivation({ + shouldRedirect, + enterpriseCustomer, +}) { + // Redirect to the enterprise learner portal dashboard page when user + // is on the license activation page. Otherwise, let the user stay on + // the current page route. + if (shouldRedirect) { + throw redirect(generatePath('/:enterpriseSlug', { enterpriseSlug: enterpriseCustomer.slug })); + } +} + +/** + * TODO + * @param {*} param0 + * @returns + */ +export async function activateSubscriptionLicense({ + enterpriseCustomer, + subscriptionLicenseToActivate, + requestUrl, + activateAllocatedSubscriptionLicense, +}) { + // Activate the user's assigned subscription license. + const licenseActivationRouteMatch = matchPath('/:enterpriseSlug/licenses/:activationKey/activate', requestUrl.pathname); + try { + await activateAllocatedSubscriptionLicense(subscriptionLicenseToActivate); + } catch (error) { + logError(error); + redirectToDashboardAfterLicenseActivation({ + enterpriseCustomer, + shouldRedirect: licenseActivationRouteMatch, + }); + return; + } + sendEnterpriseTrackEvent( + enterpriseCustomer.uuid, + 'edx.ui.enterprise.learner_portal.license-activation.license-activated', + { + // `autoActivated` is true if the user is on a page route *other* than the license activation route. + autoActivated: !licenseActivationRouteMatch, + }, + ); + redirectToDashboardAfterLicenseActivation({ + enterpriseCustomer, + shouldRedirect: licenseActivationRouteMatch, + }); +} + +/** + * TODO + * @param {*} param0 + */ +export async function getAutoAppliedSubscriptionLicense({ + subscriptionsData, + enterpriseCustomer, + requestAutoAppliedSubscriptionLicense, +}) { + const { customerAgreement } = subscriptionsData; + const hasSubscriptionForAutoAppliedLicenses = ( + !!customerAgreement.subscriptionForAutoAppliedLicenses + && customerAgreement.netDaysUntilExpiration > 0 + ); + const hasIdentityProvider = enterpriseCustomer.identityProvider; + + // If customer agreement has no configured subscription plan for auto-applied + // licenses, or the enterprise customer does not have an identity provider, + // return early. + if (!hasSubscriptionForAutoAppliedLicenses || !hasIdentityProvider) { + return; + } + + try { + await requestAutoAppliedSubscriptionLicense(customerAgreement); + } catch (error) { + logError(error); + } +} + +/** + * TODO + * @param {*} enterpriseCustomer + * @param {*} subscriptionsData + * @param {*} queryClient + * @param {*} subscriptionsQuery + * @param {*} requestUrl + * @returns + */ +export async function activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer, + subscriptionsData, + requestUrl, + activateAllocatedSubscriptionLicense, + requestAutoAppliedSubscriptionLicense, +}) { + const { + customerAgreement, + subscriptionLicenses, + } = subscriptionsData; + if (!customerAgreement || customerAgreement.netDaysUntilExpiration <= 0) { + return; + } + + // Filter subscription licenses to only be those associated with + // subscription plans that are active and current. + const currentSubscriptionLicenses = subscriptionLicenses.filter((license) => { + const { subscriptionPlan } = license; + const { isActive, daysUntilExpiration } = subscriptionPlan; + const isCurrent = daysUntilExpiration > 0; + return isActive && isCurrent; + }); + + // Check if learner already has activated license. If so, return early. + const activatedSubscriptionLicense = currentSubscriptionLicenses.find((license) => license.status === 'activated'); + if (activatedSubscriptionLicense) { + return; + } + + // Otherwise, check if there is an assigned subscription + // license to activate OR if the user should request an + // auto-applied subscription license. + const subscriptionLicenseToActivate = subscriptionLicenses.find((license) => license.status === 'assigned'); + if (subscriptionLicenseToActivate) { + await activateSubscriptionLicense({ + enterpriseCustomer, + subscriptionLicenseToActivate, + requestUrl, + activateAllocatedSubscriptionLicense, + }); + } else { + await getAutoAppliedSubscriptionLicense({ + enterpriseCustomer, + subscriptionsData, + requestAutoAppliedSubscriptionLicense, + }); + } +} diff --git a/src/components/app/routes/index.js b/src/components/app/routes/index.js new file mode 100644 index 0000000000..007118a486 --- /dev/null +++ b/src/components/app/routes/index.js @@ -0,0 +1,7 @@ +export { default as CourseRoute } from './CourseRoute'; +export { default as DashboardRoute } from './DashboardRoute'; +export { default as SearchRoute } from './SearchRoute'; +export { default as RouteErrorBoundary } from './RouteErrorBoundary'; + +export * from './loaders'; +export * from './data'; diff --git a/src/components/app/routes/loaders/index.js b/src/components/app/routes/loaders/index.js index c9be055e62..8b9448454b 100644 --- a/src/components/app/routes/loaders/index.js +++ b/src/components/app/routes/loaders/index.js @@ -1,4 +1,3 @@ -export { default as makeUpdateActiveEnterpriseCustomerUserLoader } from './updateActiveEnterpriseCustomerUserLoader'; export { default as makeRootLoader } from './rootLoader'; export { default as makeCourseLoader } from './courseLoader'; export { default as makeDashboardLoader } from './dashboardLoader'; diff --git a/src/components/app/routes/loaders/rootLoader.js b/src/components/app/routes/loaders/rootLoader.js index 4b5a426797..7b608770f8 100644 --- a/src/components/app/routes/loaders/rootLoader.js +++ b/src/components/app/routes/loaders/rootLoader.js @@ -1,8 +1,11 @@ -import { ensureEnterpriseAppData, queryEnterpriseLearner } from '../data/queries'; import { ensureAuthenticatedUser, redirectToRemoveTrailingSlash, redirectToSearchPageForNewUser, + ensureEnterpriseAppData, + queryEnterpriseLearner, + ensureActiveEnterpriseCustomerUser, + updateUserActiveEnterprise, } from '../data'; /** @@ -26,20 +29,56 @@ export default function makeRootLoader(queryClient) { // or fetch from the server if not available. const linkedEnterpriseCustomersQuery = queryEnterpriseLearner(username, enterpriseSlug); const enterpriseLearnerData = await queryClient.ensureQueryData(linkedEnterpriseCustomersQuery); - const { activeEnterpriseCustomer } = enterpriseLearnerData; + let { + activeEnterpriseCustomer, + allLinkedEnterpriseCustomerUsers, + } = enterpriseLearnerData; + const { enterpriseFeatures } = enterpriseLearnerData; // User has no active, linked enterprise customer; return early. if (!activeEnterpriseCustomer) { return null; } - // Begin fetching all enterprise app data. - const enterpriseAppData = await Promise.all(ensureEnterpriseAppData({ + // Ensure the active enterprise customer user is updated, when applicable (e.g., the + // current enterprise slug in the URL does not match the active enterprise customer's slug). + const updateActiveEnterpriseCustomerUserResult = await ensureActiveEnterpriseCustomerUser({ + enterpriseSlug, + activeEnterpriseCustomer, + allLinkedEnterpriseCustomerUsers, + queryClient, + username, + requestUrl, + enterpriseFeatures, + async updateActiveEnterpriseCustomerUser(nextActiveEnterpriseCustomer) { + // Makes the POST API request to update the active enterprise customer + // for the learner in the backend for future sessions. + await updateUserActiveEnterprise({ + enterpriseCustomer: nextActiveEnterpriseCustomer, + }); + return queryEnterpriseLearner(username, nextActiveEnterpriseCustomer.slug); + }, + }); + // If the active enterprise customer user was updated, override the previous active + // enterprise customer user data with the new active enterprise customer user data + // for subsequent queries. + if (updateActiveEnterpriseCustomerUserResult) { + const { + enterpriseCustomer: nextActiveEnterpriseCustomer, + updatedLinkedEnterpriseCustomerUsers, + } = updateActiveEnterpriseCustomerUserResult; + activeEnterpriseCustomer = nextActiveEnterpriseCustomer; + allLinkedEnterpriseCustomerUsers = updatedLinkedEnterpriseCustomerUsers; + } + + // Fetch all enterprise app data. + const enterpriseAppData = await ensureEnterpriseAppData({ enterpriseCustomer: activeEnterpriseCustomer, userId, userEmail, queryClient, - })); + requestUrl, + }); // Redirect user to search page, for first-time users with no assignments. redirectToSearchPageForNewUser({ diff --git a/src/components/app/routes/loaders/tests/updateActiveEnterpriseCustomerUserLoader.test.jsx b/src/components/app/routes/loaders/tests/updateActiveEnterpriseCustomerUserLoader.test.jsx deleted file mode 100644 index cf46e4482a..0000000000 --- a/src/components/app/routes/loaders/tests/updateActiveEnterpriseCustomerUserLoader.test.jsx +++ /dev/null @@ -1,167 +0,0 @@ -import { screen } from '@testing-library/react'; -import { when } from 'jest-when'; -import '@testing-library/jest-dom/extend-expect'; - -import { renderWithRouterProvider } from '../../../../../utils/tests'; -import makeUpdateActiveEnterpriseCustomerUserLoader from '../updateActiveEnterpriseCustomerUserLoader'; -import { - ensureAuthenticatedUser, - extractEnterpriseId, - updateActiveEnterpriseCustomerUser, - queryEnterpriseLearner, -} from '../../data'; - -jest.mock('../../data', () => ({ - ...jest.requireActual('../../data'), - ensureAuthenticatedUser: jest.fn(), - extractEnterpriseId: jest.fn(), - updateActiveEnterpriseCustomerUser: jest.fn(), -})); - -const mockUsername = 'edx'; -const mockUserEmail = 'edx@example.com'; -const mockEnterpriseId = 'test-enterprise-uuid'; -const mockEnterpriseSlug = 'test-enterprise-slug'; -ensureAuthenticatedUser.mockResolvedValue({ - userId: 3, - email: mockUserEmail, - username: mockUsername, -}); -extractEnterpriseId.mockResolvedValue(mockEnterpriseId); -updateActiveEnterpriseCustomerUser.mockResolvedValue({}); - -const mockQueryClient = { - ensureQueryData: jest.fn().mockResolvedValue({}), - setQueryData: jest.fn(), -}; - -describe('updateActiveEnterpriseCustomerUserLoader', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('ensures only the enterprise-learner query is called if there is no active enterprise customer user', async () => { - const enterpriseLearnerQuery = queryEnterpriseLearner(mockUsername, mockEnterpriseSlug); - when(mockQueryClient.ensureQueryData).calledWith( - expect.objectContaining({ - queryKey: enterpriseLearnerQuery.queryKey, - }), - ).mockResolvedValue({ activeEnterpriseCustomer: null }); - - renderWithRouterProvider({ - path: '/:enterpriseSlug', - element:
hello world
, - loader: makeUpdateActiveEnterpriseCustomerUserLoader(mockQueryClient), - }, { - initialEntries: [`/${mockEnterpriseSlug}`], - }); - - expect(await screen.findByText('hello world')).toBeInTheDocument(); - - // Assert that the expected number of queries were made. - expect(mockQueryClient.ensureQueryData).toHaveBeenCalledTimes(1); - }); - - it('ensures only the enterprise-learner query is called when there active enterprise customer user matches the current enterprise slug', async () => { - const enterpriseLearnerQuery = queryEnterpriseLearner(mockUsername, mockEnterpriseSlug); - when(mockQueryClient.ensureQueryData).calledWith( - expect.objectContaining({ - queryKey: enterpriseLearnerQuery.queryKey, - }), - ).mockResolvedValue({ - activeEnterpriseCustomer: { - slug: mockEnterpriseSlug, - }, - }); - - renderWithRouterProvider({ - path: '/:enterpriseSlug', - element:
hello world
, - loader: makeUpdateActiveEnterpriseCustomerUserLoader(mockQueryClient), - }, { - initialEntries: [`/${mockEnterpriseSlug}`], - }); - - expect(await screen.findByText('hello world')).toBeInTheDocument(); - - // Assert that the expected number of queries were made. - expect(mockQueryClient.ensureQueryData).toHaveBeenCalledTimes(1); - }); - - it('updates the active enterprise customer user when the enterprise slug does not match the active enterprise customer and the user is linked', async () => { - const enterpriseLearnerQuery = queryEnterpriseLearner(mockUsername, mockEnterpriseSlug); - const activeEnterpriseCustomer = { - slug: 'other-enterprise-slug', - }; - when(mockQueryClient.ensureQueryData).calledWith( - expect.objectContaining({ - queryKey: enterpriseLearnerQuery.queryKey, - }), - ).mockResolvedValue({ - activeEnterpriseCustomer, - allLinkedEnterpriseCustomerUsers: [ - { - enterpriseCustomer: activeEnterpriseCustomer, - }, - { - enterpriseCustomer: { - slug: mockEnterpriseSlug, - }, - }, - ], - }); - - renderWithRouterProvider({ - path: '/:enterpriseSlug', - element:
hello world
, - loader: makeUpdateActiveEnterpriseCustomerUserLoader(mockQueryClient), - }, { - initialEntries: [`/${mockEnterpriseSlug}`], - }); - - expect(await screen.findByText('hello world')).toBeInTheDocument(); - - // Assert that the expected number of queries were made. - expect(mockQueryClient.ensureQueryData).toHaveBeenCalledTimes(1); - - // Assert that the active enterprise customer user was updated. - expect(updateActiveEnterpriseCustomerUser).toHaveBeenCalledTimes(1); - }); - - it('updates the active enterprise customer user when the enterprise slug does not match the active enterprise customer and the user is NOT linked', async () => { - const enterpriseLearnerQuery = queryEnterpriseLearner(mockUsername, mockEnterpriseSlug); - const activeEnterpriseCustomer = { - slug: 'other-enterprise-slug', - }; - when(mockQueryClient.ensureQueryData).calledWith( - expect.objectContaining({ - queryKey: enterpriseLearnerQuery.queryKey, - }), - ).mockResolvedValue({ - activeEnterpriseCustomer, - allLinkedEnterpriseCustomerUsers: [ - { - enterpriseCustomer: activeEnterpriseCustomer, - }, - ], - }); - - renderWithRouterProvider({ - path: '/:enterpriseSlug', - element:
hello world
, - loader: makeUpdateActiveEnterpriseCustomerUserLoader(mockQueryClient), - }, { - initialEntries: [`/${mockEnterpriseSlug}`], - }); - - expect(await screen.findByText('hello world')).toBeInTheDocument(); - - // Assert that the expected number of queries were made. The first call - // is due to the initial render, and the second call is due to the - // redirect to the slug of the active enterprise customer. - expect(mockQueryClient.ensureQueryData).toHaveBeenCalledTimes(2); - - // Assert that the active enterprise customer user was NOT updated. - expect(updateActiveEnterpriseCustomerUser).toHaveBeenCalledTimes(0); - }); -}); diff --git a/src/components/app/routes/loaders/updateActiveEnterpriseCustomerUserLoader.js b/src/components/app/routes/loaders/updateActiveEnterpriseCustomerUserLoader.js deleted file mode 100644 index f27ff6143f..0000000000 --- a/src/components/app/routes/loaders/updateActiveEnterpriseCustomerUserLoader.js +++ /dev/null @@ -1,74 +0,0 @@ -import { redirect, generatePath } from 'react-router-dom'; - -import { ensureAuthenticatedUser, updateActiveEnterpriseCustomerUser } from '../data'; -import { queryEnterpriseLearner } from '../data/queries'; -/** - * Updates the active enterprise customer for the learner, when the user navigates to a different enterprise - * customer's page. - * @param {Object} queryClient - The query client. - * @returns {Function} - A loader function. - */ -export default function makeUpdateActiveEnterpriseCustomerUserLoader(queryClient) { - return async function updateActiveEnterpriseCustomerUserLoader({ params = {}, request }) { - const requestUrl = new URL(request.url); - const authenticatedUser = await ensureAuthenticatedUser(requestUrl, params); - // User is not authenticated, so we can't do anything in this loader. - if (!authenticatedUser) { - return null; - } - - const { username, userId, email: userEmail } = authenticatedUser; - const { enterpriseSlug } = params; - - const linkedEnterpriseCustomersQuery = queryEnterpriseLearner(username, enterpriseSlug); - const enterpriseLearnerData = await queryClient.ensureQueryData(linkedEnterpriseCustomersQuery); - const { - activeEnterpriseCustomer, - activeEnterpriseCustomerUserRoleAssignments, - allLinkedEnterpriseCustomerUsers, - } = enterpriseLearnerData; - - // User has no active, linked enterprise customer; return early. - if (!activeEnterpriseCustomer) { - return null; - } - - if (enterpriseSlug !== activeEnterpriseCustomer.slug) { - // Otherwise, try to find the enterprise customer for the given slug and, if found, update it - // as the active enterprise customer for the learner. - const foundEnterpriseCustomerUserForSlug = allLinkedEnterpriseCustomerUsers.find( - enterpriseCustomerUser => enterpriseCustomerUser.enterpriseCustomer.slug === enterpriseSlug, - ); - if (foundEnterpriseCustomerUserForSlug) { - await updateActiveEnterpriseCustomerUser({ - queryClient, - enterpriseCustomerUser: foundEnterpriseCustomerUserForSlug, - userId, - userEmail, - username, - allLinkedEnterpriseCustomerUsers, - }); - return null; - } - - // Perform optimistic update of the query cache to avoid duplicate API request for the same - // data. The only difference is that the query key now contains the enterprise slug (it was - // previousy `undefined`), so we can proactively set the query cache for with the enterprise - // learner data we already have before performing the redirect. - const nextEnterpriseLearnerQuery = queryEnterpriseLearner(username, activeEnterpriseCustomer.slug); - queryClient.setQueryData(nextEnterpriseLearnerQuery.queryKey, { - enterpriseCustomer: activeEnterpriseCustomer, - enterpriseCustomerUserRoleAssignments: activeEnterpriseCustomerUserRoleAssignments, - activeEnterpriseCustomer, - activeEnterpriseCustomerUserRoleAssignments, - allLinkedEnterpriseCustomerUsers, - }); - return redirect(generatePath('/:enterpriseSlug/*', { - enterpriseSlug: activeEnterpriseCustomer.slug, - '*': requestUrl.pathname.split('/').filter(pathPart => !!pathPart).slice(1).join('/'), - })); - } - - return null; - }; -} diff --git a/src/components/enterprise-user-subsidy/data/hooks/hooks.js b/src/components/enterprise-user-subsidy/data/hooks/hooks.js index 59c2491645..e8a585a4f2 100644 --- a/src/components/enterprise-user-subsidy/data/hooks/hooks.js +++ b/src/components/enterprise-user-subsidy/data/hooks/hooks.js @@ -63,16 +63,10 @@ const fetchExistingUserLicense = async (enterpriseId) => { * @param {string} customerAgreementId The UUID of the customer agreement. * @returns An object representing the auto-applied license or null if no license was auto-applied. */ -const requestAutoAppliedUserLicense = async (customerAgreementId) => { - try { - const response = await requestAutoAppliedLicense(customerAgreementId); - const license = camelCaseObject(response.data); - return license; - } catch (error) { - logError(error); - return null; - } -}; +export async function requestAutoAppliedUserLicense(customerAgreementId) { + const response = await requestAutoAppliedLicense(customerAgreementId); + return camelCaseObject(response.data); +} /** * Retrieves a license for the authenticated user, if applicable. First attempts to find any existing licenses diff --git a/src/components/site-header/SiteHeaderNavMenu.jsx b/src/components/site-header/SiteHeaderNavMenu.jsx index 70086423cc..bcf25e33b1 100644 --- a/src/components/site-header/SiteHeaderNavMenu.jsx +++ b/src/components/site-header/SiteHeaderNavMenu.jsx @@ -8,6 +8,8 @@ const SiteHeaderNavMenu = () => { const intl = useIntl(); const mainMenuLinkClassName = 'nav-link'; + console.log('enterpriseCustomer!!!', enterpriseCustomer); + if (enterpriseCustomer.disableSearch) { return null; } From 2b8d4065690a19fd0e4b1832c447bfa1f734b5e4 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 4 Mar 2024 09:02:27 -0500 Subject: [PATCH 02/16] chore: quality --- .../enterprise-user-subsidy/data/hooks/hooks.js | 14 ++++++++++---- src/components/site-header/SiteHeaderNavMenu.jsx | 2 -- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/enterprise-user-subsidy/data/hooks/hooks.js b/src/components/enterprise-user-subsidy/data/hooks/hooks.js index e8a585a4f2..59c2491645 100644 --- a/src/components/enterprise-user-subsidy/data/hooks/hooks.js +++ b/src/components/enterprise-user-subsidy/data/hooks/hooks.js @@ -63,10 +63,16 @@ const fetchExistingUserLicense = async (enterpriseId) => { * @param {string} customerAgreementId The UUID of the customer agreement. * @returns An object representing the auto-applied license or null if no license was auto-applied. */ -export async function requestAutoAppliedUserLicense(customerAgreementId) { - const response = await requestAutoAppliedLicense(customerAgreementId); - return camelCaseObject(response.data); -} +const requestAutoAppliedUserLicense = async (customerAgreementId) => { + try { + const response = await requestAutoAppliedLicense(customerAgreementId); + const license = camelCaseObject(response.data); + return license; + } catch (error) { + logError(error); + return null; + } +}; /** * Retrieves a license for the authenticated user, if applicable. First attempts to find any existing licenses diff --git a/src/components/site-header/SiteHeaderNavMenu.jsx b/src/components/site-header/SiteHeaderNavMenu.jsx index bcf25e33b1..70086423cc 100644 --- a/src/components/site-header/SiteHeaderNavMenu.jsx +++ b/src/components/site-header/SiteHeaderNavMenu.jsx @@ -8,8 +8,6 @@ const SiteHeaderNavMenu = () => { const intl = useIntl(); const mainMenuLinkClassName = 'nav-link'; - console.log('enterpriseCustomer!!!', enterpriseCustomer); - if (enterpriseCustomer.disableSearch) { return null; } From bbcc6366f3ca8bb0df83d039d2566bd2d5e1ed33 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 4 Mar 2024 09:13:33 -0500 Subject: [PATCH 03/16] chore: fix Root tests --- src/components/app/Root.test.jsx | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/components/app/Root.test.jsx b/src/components/app/Root.test.jsx index d5f2d1466f..c413d89915 100644 --- a/src/components/app/Root.test.jsx +++ b/src/components/app/Root.test.jsx @@ -18,7 +18,7 @@ jest.mock('@edx/frontend-platform/auth', () => ({ getLoginRedirectUrl: jest.fn().mockReturnValue('http://test-login-redirect-url'), })); -const defaultAppContextValue = { +const baseAppContextValue = { config: {}, authenticatedUser: { userId: 3, @@ -26,14 +26,24 @@ const defaultAppContextValue = { }, }; +const hydratedUserAppContextValue = { + ...baseAppContextValue, + authenticatedUser: { + ...baseAppContextValue.authenticatedUser, + profileImage: { + url: 'http://test-profile-image-url', + }, + }, +}; + const unauthenticatedAppContextValue = { - ...defaultAppContextValue, + ...baseAppContextValue, authenticatedUser: null, }; const RootWrapper = ({ children, - appContextValue = defaultAppContextValue, + appContextValue = hydratedUserAppContextValue, }) => ( @@ -66,6 +76,20 @@ describe('Root tests', () => { expect(screen.queryByTestId('hidden-children')).not.toBeInTheDocument(); }); + test('page renders nothing loader when user is authenticated but not hydrated', () => { + const { container } = renderWithRouterProvider({ + path: '/:enterpriseSlug', + element: ( + +
+ + ), + }, { + initialEntries: ['/test-enterprise?logout=true'], + }); + expect(container).toBeEmptyDOMElement(); + }); + test('page renders child routes', () => { renderWithRouterProvider({ path: '/:enterpriseSlug', From f336fe952fc8412e1edf1fa7e2d34267d85bdd84 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 4 Mar 2024 09:45:14 -0500 Subject: [PATCH 04/16] chore: refactor and add tests for RouterFallback --- src/components/app/App.jsx | 8 +---- src/components/app/Root.jsx | 30 ++----------------- src/components/app/data/hooks/index.js | 1 + .../app/data/hooks/useNProgressLoader.js | 30 +++++++++++++++++++ src/components/app/routes/RouterFallback.jsx | 8 +++++ .../app/routes/RouterFallback.test.jsx | 23 ++++++++++++++ .../app/{data => routes}/createAppRouter.jsx | 10 +++---- src/components/app/routes/index.js | 2 ++ 8 files changed, 73 insertions(+), 39 deletions(-) create mode 100644 src/components/app/data/hooks/useNProgressLoader.js create mode 100644 src/components/app/routes/RouterFallback.jsx create mode 100644 src/components/app/routes/RouterFallback.test.jsx rename src/components/app/{data => routes}/createAppRouter.jsx (83%) diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index fa1fb301bb..7acde92430 100644 --- a/src/components/app/App.jsx +++ b/src/components/app/App.jsx @@ -13,8 +13,7 @@ import { } from '../../utils/common'; // import extractNamedExport from '../../utils/extract-named-export'; -import createAppRouter from './data/createAppRouter'; -import { useNProgressLoader } from './Root'; +import { RouterFallback, createAppRouter } from './routes'; /* eslint-disable max-len */ // const EnterpriseAppPageRoutes = lazy(() => import(/* webpackChunkName: "enterprise-app-routes" */ './EnterpriseAppPageRoutes')); @@ -41,11 +40,6 @@ const queryClient = new QueryClient({ const router = createAppRouter(queryClient); -const RouterFallback = () => { - useNProgressLoader(); - return null; -}; - const App = () => ( diff --git a/src/components/app/Root.jsx b/src/components/app/Root.jsx index 0f85a800ca..9ec140b6f9 100644 --- a/src/components/app/Root.jsx +++ b/src/components/app/Root.jsx @@ -1,8 +1,7 @@ import { - Outlet, ScrollRestoration, useFetchers, useNavigation, useParams, + Outlet, ScrollRestoration, useParams, } from 'react-router-dom'; -import { Suspense, useContext, useEffect } from 'react'; -import NProgress from 'accessible-nprogress'; +import { Suspense, useContext } from 'react'; import { AppContext } from '@edx/frontend-platform/react'; import { getConfig } from '@edx/frontend-platform'; import { getLoginRedirectUrl } from '@edx/frontend-platform/auth'; @@ -12,30 +11,7 @@ import DelayedFallbackContainer from '../DelayedFallback/DelayedFallbackContaine import { Toasts, ToastsProvider } from '../Toasts'; import NoticesProvider from '../notices-provider'; import { ErrorPage } from '../error-page'; - -// Determines amount of time that must elapse before the -// NProgress loader is shown in the UI. No need to show it -// for quick route transitions. -export const NPROGRESS_DELAY_MS = 300; - -export function useNProgressLoader() { - const { authenticatedUser } = useContext(AppContext); - const navigation = useNavigation(); - const fetchers = useFetchers(); - - useEffect(() => { - const timeoutId = setTimeout(() => { - const fetchersIdle = fetchers.every((f) => f.state === 'idle'); - const isAuthenticatedUserHydrated = !!authenticatedUser?.profileImage; - if (navigation.state === 'idle' && fetchersIdle && isAuthenticatedUserHydrated) { - NProgress.done(); - } else { - NProgress.start(); - } - }, NPROGRESS_DELAY_MS); - return () => clearTimeout(timeoutId); - }, [navigation, fetchers, authenticatedUser]); -} +import { useNProgressLoader } from './data'; const Root = () => { const { authenticatedUser } = useContext(AppContext); diff --git a/src/components/app/data/hooks/index.js b/src/components/app/data/hooks/index.js index b329f7691f..db9fa398ad 100644 --- a/src/components/app/data/hooks/index.js +++ b/src/components/app/data/hooks/index.js @@ -8,3 +8,4 @@ export { default as useUserEntitlements } from './useUserEntitlements'; export { default as useRecommendCoursesForMe } from './useRecommendCoursesForMe'; export { default as useBrowseAndRequestConfiguration } from './useBrowseAndRequestConfiguration'; export { default as useIsAssignmentsOnlyLearner } from './useIsAssignmentsOnlyLearner'; +export { default as useNProgressLoader } from './useNProgressLoader'; diff --git a/src/components/app/data/hooks/useNProgressLoader.js b/src/components/app/data/hooks/useNProgressLoader.js new file mode 100644 index 0000000000..3dd40ecddb --- /dev/null +++ b/src/components/app/data/hooks/useNProgressLoader.js @@ -0,0 +1,30 @@ +import { useContext, useEffect } from 'react'; +import { useFetchers, useNavigation } from 'react-router-dom'; +import nprogress from 'accessible-nprogress'; +import { AppContext } from '@edx/frontend-platform/react'; + +// Determines amount of time that must elapse before the +// NProgress loader is shown in the UI. No need to show it +// for quick route transitions. +export const NPROGRESS_DELAY_MS = 300; + +function useNProgressLoader() { + const { authenticatedUser } = useContext(AppContext); + const navigation = useNavigation(); + const fetchers = useFetchers(); + + useEffect(() => { + const timeoutId = setTimeout(() => { + const fetchersIdle = fetchers.every((f) => f.state === 'idle'); + const isAuthenticatedUserHydrated = !!authenticatedUser?.profileImage; + if (navigation.state === 'idle' && fetchersIdle && isAuthenticatedUserHydrated) { + nprogress.done(); + } else { + nprogress.start(); + } + }, NPROGRESS_DELAY_MS); + return () => clearTimeout(timeoutId); + }, [navigation, fetchers, authenticatedUser]); +} + +export default useNProgressLoader; diff --git a/src/components/app/routes/RouterFallback.jsx b/src/components/app/routes/RouterFallback.jsx new file mode 100644 index 0000000000..ee7c4cb50a --- /dev/null +++ b/src/components/app/routes/RouterFallback.jsx @@ -0,0 +1,8 @@ +import { useNProgressLoader } from '../data'; + +const RouterFallback = () => { + useNProgressLoader(); + return null; +}; + +export default RouterFallback; diff --git a/src/components/app/routes/RouterFallback.test.jsx b/src/components/app/routes/RouterFallback.test.jsx new file mode 100644 index 0000000000..c5378076c5 --- /dev/null +++ b/src/components/app/routes/RouterFallback.test.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import RouterFallback from './RouterFallback'; +import { useNProgressLoader } from '../data'; + +jest.mock('../data', () => ({ + ...jest.requireActual('../data'), + useNProgressLoader: jest.fn(), +})); + +describe('RouterFallback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + expect(useNProgressLoader).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/app/data/createAppRouter.jsx b/src/components/app/routes/createAppRouter.jsx similarity index 83% rename from src/components/app/data/createAppRouter.jsx rename to src/components/app/routes/createAppRouter.jsx index 83c3c05b56..c1957ccfa2 100644 --- a/src/components/app/data/createAppRouter.jsx +++ b/src/components/app/routes/createAppRouter.jsx @@ -3,12 +3,12 @@ import { Route, createBrowserRouter, createRoutesFromElements, } from 'react-router-dom'; -import RouteErrorBoundary from '../routes/RouteErrorBoundary'; +import RouteErrorBoundary from './RouteErrorBoundary'; import { makeCourseLoader, makeRootLoader, makeDashboardLoader, -} from '../routes/loaders'; +} from './loaders'; import Root from '../Root'; import Layout from '../Layout'; import NotFoundPage from '../../NotFoundPage'; @@ -34,7 +34,7 @@ export default function createAppRouter(queryClient) { { - const { DashboardRoute } = await import('../routes'); + const { default: DashboardRoute } = await import('./DashboardRoute'); return { Component: DashboardRoute, loader: makeDashboardLoader(queryClient), @@ -44,7 +44,7 @@ export default function createAppRouter(queryClient) { { - const { SearchRoute } = await import('../routes'); + const { default: SearchRoute } = await import('./SearchRoute'); return { Component: SearchRoute, }; @@ -53,7 +53,7 @@ export default function createAppRouter(queryClient) { { - const { CourseRoute } = await import('../routes'); + const { default: CourseRoute } = await import('./CourseRoute'); return { Component: CourseRoute, loader: makeCourseLoader(queryClient), diff --git a/src/components/app/routes/index.js b/src/components/app/routes/index.js index 007118a486..25a3b393f6 100644 --- a/src/components/app/routes/index.js +++ b/src/components/app/routes/index.js @@ -1,7 +1,9 @@ export { default as CourseRoute } from './CourseRoute'; export { default as DashboardRoute } from './DashboardRoute'; export { default as SearchRoute } from './SearchRoute'; +export { default as RouterFallback } from './RouterFallback'; export { default as RouteErrorBoundary } from './RouteErrorBoundary'; +export { default as createAppRouter } from './createAppRouter'; export * from './loaders'; export * from './data'; From 601f2fffc1a62845b6874a741f800bfcd5556f10 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 4 Mar 2024 10:36:42 -0500 Subject: [PATCH 05/16] chore: remove prior code related to subs license activation and auto-apply --- .../app/AuthenticatedUserSubsidyPage.jsx | 2 - .../app/AuthenticatedUserSubsidyPage.test.jsx | 5 +- .../app/EnterpriseAppPageRoutes.jsx | 2 - .../app/routes/RouterFallback.test.jsx | 2 +- src/components/app/routes/data/utils.js | 6 + .../AutoActivateLicense.jsx | 43 ----- .../data/hooks/hooks.js | 48 +----- .../data/hooks/hooks.test.jsx | 149 ------------------ .../enterprise-user-subsidy/data/service.js | 6 - .../enterprise-user-subsidy/index.js | 1 - .../tests/AutoActivateLicense.test.jsx | 88 ----------- .../license-activation/LicenseActivation.jsx | 69 -------- .../LicenseActivationErrorAlert.jsx | 25 --- .../LicenseActivationPage.jsx | 57 ------- src/components/license-activation/index.js | 1 - .../tests/LicenseActivation.test.jsx | 131 --------------- .../tests/LicenseActivationPage.test.jsx | 107 ------------- 17 files changed, 10 insertions(+), 732 deletions(-) delete mode 100644 src/components/enterprise-user-subsidy/AutoActivateLicense.jsx delete mode 100644 src/components/enterprise-user-subsidy/tests/AutoActivateLicense.test.jsx delete mode 100644 src/components/license-activation/LicenseActivation.jsx delete mode 100644 src/components/license-activation/LicenseActivationErrorAlert.jsx delete mode 100644 src/components/license-activation/LicenseActivationPage.jsx delete mode 100644 src/components/license-activation/index.js delete mode 100644 src/components/license-activation/tests/LicenseActivation.test.jsx delete mode 100644 src/components/license-activation/tests/LicenseActivationPage.test.jsx diff --git a/src/components/app/AuthenticatedUserSubsidyPage.jsx b/src/components/app/AuthenticatedUserSubsidyPage.jsx index 64636ccded..4cba94a736 100644 --- a/src/components/app/AuthenticatedUserSubsidyPage.jsx +++ b/src/components/app/AuthenticatedUserSubsidyPage.jsx @@ -2,11 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import AuthenticatedPage from './AuthenticatedPage'; -import { AutoActivateLicense } from '../enterprise-user-subsidy'; const AuthenticatedUserSubsidyPage = ({ children }) => ( - {children} ); diff --git a/src/components/app/AuthenticatedUserSubsidyPage.test.jsx b/src/components/app/AuthenticatedUserSubsidyPage.test.jsx index 108e1a475f..3f8c4d98b2 100644 --- a/src/components/app/AuthenticatedUserSubsidyPage.test.jsx +++ b/src/components/app/AuthenticatedUserSubsidyPage.test.jsx @@ -4,7 +4,7 @@ import '@testing-library/jest-dom/extend-expect'; import AuthenticatedUserSubsidyPage from './AuthenticatedUserSubsidyPage'; import AuthenticatedPage from './AuthenticatedPage'; -import { AutoActivateLicense, UserSubsidy } from '../enterprise-user-subsidy'; +import { UserSubsidy } from '../enterprise-user-subsidy'; describe('', () => { let wrapper; @@ -21,9 +21,6 @@ describe('', () => { it('renders ', () => { expect(wrapper.find(UserSubsidy)).toBeTruthy(); }); - it('renders ', () => { - expect(wrapper.find(AutoActivateLicense)).toBeTruthy(); - }); it('renders children', () => { expect(wrapper.find('div.did-i-render')).toBeTruthy(); }); diff --git a/src/components/app/EnterpriseAppPageRoutes.jsx b/src/components/app/EnterpriseAppPageRoutes.jsx index 4c8f3a3a73..da65012bdf 100644 --- a/src/components/app/EnterpriseAppPageRoutes.jsx +++ b/src/components/app/EnterpriseAppPageRoutes.jsx @@ -14,7 +14,6 @@ const ProgramPage = lazy(() => extractNamedExport(import(/* webpackChunkName: "p const ProgramProgressRedirect = lazy(() => extractNamedExport(import(/* webpackChunkName: "program-progress-redirect" */ '../program-progress'), 'ProgramProgressRedirect')); const ProgramProgressPage = lazy(() => extractNamedExport(import(/* webpackChunkName: "program-progress" */ '../program-progress'), 'ProgramProgressPage')); const SkillsQuizPage = lazy(() => extractNamedExport(import(/* webpackChunkName: "skills-quiz" */ '../skills-quiz'), 'SkillsQuizPage')); -const LicenseActivationPage = lazy(() => extractNamedExport(import(/* webpackChunkName: "license-activation" */ '../license-activation'), 'LicenseActivationPage')); const PathwayProgressPage = lazy(() => extractNamedExport(import(/* webpackChunkName: "pathway-progress" */ '../pathway-progress'), 'PathwayProgressPage')); const AcademyDetailPage = lazy(() => extractNamedExport(import(/* webpackChunkName: "academy" */ '../academies'), 'AcademyDetailPage')); @@ -46,7 +45,6 @@ const EnterpriseAppPageRoutes = () => ( } /> } /> } /> - } /> {features.FEATURE_ENABLE_PATHWAY_PROGRESS && ( } /> )} diff --git a/src/components/app/routes/RouterFallback.test.jsx b/src/components/app/routes/RouterFallback.test.jsx index c5378076c5..ab42276f9e 100644 --- a/src/components/app/routes/RouterFallback.test.jsx +++ b/src/components/app/routes/RouterFallback.test.jsx @@ -15,7 +15,7 @@ describe('RouterFallback', () => { jest.clearAllMocks(); }); - it('renders without crashing', () => { + it('renders', () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); expect(useNProgressLoader).toHaveBeenCalledTimes(1); diff --git a/src/components/app/routes/data/utils.js b/src/components/app/routes/data/utils.js index d940954115..76a4cd0d4a 100644 --- a/src/components/app/routes/data/utils.js +++ b/src/components/app/routes/data/utils.js @@ -18,6 +18,7 @@ import Cookies from 'universal-cookie'; import { getBrandColorsFromCSSVariables } from '../../../../utils/common'; import { ASSIGNMENT_TYPES } from '../../../enterprise-user-subsidy/enterprise-offers/data/constants'; +import { features } from '../../../../config'; /** * Determines whether the user is visiting the dashboard for the first time. @@ -435,6 +436,11 @@ export async function getAutoAppliedSubscriptionLicense({ enterpriseCustomer, requestAutoAppliedSubscriptionLicense, }) { + // If the feature flag for auto-applied licenses is not enabled, return early. + if (!features.ENABLE_AUTO_APPLIED_LICENSES) { + return; + } + const { customerAgreement } = subscriptionsData; const hasSubscriptionForAutoAppliedLicenses = ( !!customerAgreement.subscriptionForAutoAppliedLicenses diff --git a/src/components/enterprise-user-subsidy/AutoActivateLicense.jsx b/src/components/enterprise-user-subsidy/AutoActivateLicense.jsx deleted file mode 100644 index 9c3ff3486d..0000000000 --- a/src/components/enterprise-user-subsidy/AutoActivateLicense.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import React, { useContext } from 'react'; -import { Navigate, useLocation, useMatch } from 'react-router-dom'; -import { AppContext } from '@edx/frontend-platform/react'; - -import { UserSubsidyContext } from './UserSubsidy'; -import { LICENSE_STATUS } from './data/constants'; - -/** - * Redirects users to the license activation page if they have an assigned license. - * - * TODO: move to route loader when we pick up work to migrate license activation route. - */ -const AutoActivateLicense = () => { - const { enterpriseConfig } = useContext(AppContext); - const { subscriptionLicense } = useContext(UserSubsidyContext); - const location = useLocation(); - - const isLicenseActivationRouteMatch = useMatch('/:enterpriseSlug/licenses/:activationKey/activate'); - // If user is on the license activation page, do not redirect them to the - // same license activation page again. - if (isLicenseActivationRouteMatch) { - return null; - } - - // If the user does not have an assigned license or their license status is not assigned, do not redirect them. - if (!subscriptionLicense?.activationKey || subscriptionLicense?.status !== LICENSE_STATUS.ASSIGNED) { - return null; - } - - // Redirect to license activation page. - const activationPath = `/${enterpriseConfig.slug}/licenses/${subscriptionLicense.activationKey}/activate`; - return ( - - ); -}; - -export default AutoActivateLicense; diff --git a/src/components/enterprise-user-subsidy/data/hooks/hooks.js b/src/components/enterprise-user-subsidy/data/hooks/hooks.js index 59c2491645..3b0659ba9f 100644 --- a/src/components/enterprise-user-subsidy/data/hooks/hooks.js +++ b/src/components/enterprise-user-subsidy/data/hooks/hooks.js @@ -15,7 +15,6 @@ import { fetchCustomerAgreementData, fetchRedeemableLearnerCreditPolicies, fetchSubscriptionLicensesForUser, - requestAutoAppliedLicense, } from '../service'; import { features } from '../../../../config'; import { fetchCouponsOverview } from '../../coupons/data/service'; @@ -57,28 +56,9 @@ const fetchExistingUserLicense = async (enterpriseId) => { } }; -/** - * Attempts to auto-apply a license for the authenticated user and the specified customer agreement. - * - * @param {string} customerAgreementId The UUID of the customer agreement. - * @returns An object representing the auto-applied license or null if no license was auto-applied. - */ -const requestAutoAppliedUserLicense = async (customerAgreementId) => { - try { - const response = await requestAutoAppliedLicense(customerAgreementId); - const license = camelCaseObject(response.data); - return license; - } catch (error) { - logError(error); - return null; - } -}; - /** * Retrieves a license for the authenticated user, if applicable. First attempts to find any existing licenses - * for the user. If a license is found, the app uses it; otherwise, if the enterprise has an SSO/LMS identity - * provider configured and the customer agreement has a subscription plan suitable for auto-applied licenses, - * attempt to auto-apply a license for the user. + * for the user. If a license is found, the app uses it. * * @param {object} args * @param {object} args.enterpriseConfig The enterprise customer config @@ -107,31 +87,7 @@ export function useSubscriptionLicense({ useEffect(() => { async function retrieveUserLicense() { - let result = await fetchExistingUserLicense(enterpriseId); - - if (!features.ENABLE_AUTO_APPLIED_LICENSES) { - return result; - } - - const customerAgreementMetadata = [ - customerAgreementConfig?.uuid, - customerAgreementConfig?.subscriptionForAutoAppliedLicenses, - ]; - const hasCustomerAgreementData = customerAgreementMetadata.every(item => !!item); - - // Only request an auto-applied license if ther user is a learner of the enterprise. - // This is mainly to prevent edx operators from accidently getting a license. - const isEnterpriseLearner = !!user.roles.find(userRole => { - const [role, enterprise] = userRole.split(':'); - return role === 'enterprise_learner' && enterprise === enterpriseId; - }); - - // Per the product requirements, we only want to attempt requesting an auto-applied license - // when the enterprise customer has an SSO/LMS provider configured. - if (!result && enterpriseIdentityProvider && isEnterpriseLearner && hasCustomerAgreementData) { - result = await requestAutoAppliedUserLicense(customerAgreementConfig.uuid); - } - + const result = await fetchExistingUserLicense(enterpriseId); return result; } diff --git a/src/components/enterprise-user-subsidy/data/hooks/hooks.test.jsx b/src/components/enterprise-user-subsidy/data/hooks/hooks.test.jsx index 40916ea720..dace79a3c2 100644 --- a/src/components/enterprise-user-subsidy/data/hooks/hooks.test.jsx +++ b/src/components/enterprise-user-subsidy/data/hooks/hooks.test.jsx @@ -1,5 +1,4 @@ import { renderHook } from '@testing-library/react-hooks'; -import * as logging from '@edx/frontend-platform/logging'; import { camelCaseObject } from '@edx/frontend-platform/utils'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { logError } from '@edx/frontend-platform/logging'; @@ -12,8 +11,6 @@ import { } from '.'; import { fetchSubscriptionLicensesForUser, - activateLicense, - requestAutoAppliedLicense, fetchCustomerAgreementData, fetchRedeemableLearnerCreditPolicies, } from '../service'; @@ -26,10 +23,6 @@ jest.mock('../../data/service'); jest.mock('../../coupons/data/service'); jest.mock('../../coupons'); -jest.mock('@edx/frontend-platform/logging', () => ({ - logError: jest.fn(), -})); - jest.mock('../../../../config', () => ({ ...jest.requireActual('../../../../config'), features: { @@ -74,7 +67,6 @@ const mockLearnerCreditPolicy = { learner_content_assignments: undefined, }; const mockUser = { roles: [] }; -const mockEnterpriseUser = { roles: [`enterprise_learner:${TEST_ENTERPRISE_UUID}`] }; const mockCustomerAgreement = { uuid: 'test-customer-agreement-uuid', }; @@ -127,147 +119,6 @@ describe('useSubscriptionLicense', () => { expect(fetchSubscriptionLicensesForUser).toHaveBeenCalledWith(TEST_ENTERPRISE_UUID); expect(result.current.license.subscriptionPlan).toEqual(mockSubscriptionPlan); }); - - it.each([ - { - hasExistingLicense: false, - hasIdentityProvider: true, - isEnterpriseLearner: true, - hasCustomerAgreementData: true, - shouldAutoApplyLicense: true, - }, - { - hasExistingLicense: true, - hasIdentityProvider: true, - isEnterpriseLearner: true, - hasCustomerAgreementData: true, - shouldAutoApplyLicense: false, - }, - { - hasExistingLicense: false, - hasIdentityProvider: false, - isEnterpriseLearner: false, - hasCustomerAgreementData: false, - shouldAutoApplyLicense: false, - }, - { - hasExistingLicense: false, - hasIdentityProvider: false, - isEnterpriseLearner: true, - hasCustomerAgreementData: true, - shouldAutoApplyLicense: false, - }, - { - hasExistingLicense: false, - hasIdentityProvider: true, - isEnterpriseLearner: true, - hasCustomerAgreementData: false, - shouldAutoApplyLicense: false, - }, - ])('auto-applies user license when applicable (%s)', async ({ - hasExistingLicense, - hasIdentityProvider, - isEnterpriseLearner, - hasCustomerAgreementData, - shouldAutoApplyLicense, - }) => { - const mockLicenses = hasExistingLicense ? [mockLicense] : []; - const mockAuthenticatedUser = isEnterpriseLearner ? mockEnterpriseUser : mockUser; - const mockEnterpriseConfiguration = { ...mockEnterpriseConfig }; - if (hasIdentityProvider) { - mockEnterpriseConfiguration.identityProvider = { id: 1 }; - } - const anotherMockCustomerAgreement = {}; - if (hasCustomerAgreementData) { - anotherMockCustomerAgreement.uuid = 'test-customer-agreement-uuid'; - anotherMockCustomerAgreement.subscriptionForAutoAppliedLicenses = TEST_SUBSCRIPTION_UUID; - anotherMockCustomerAgreement.subscriptions = [mockSubscriptionPlan]; - } - fetchSubscriptionLicensesForUser.mockResolvedValueOnce({ - data: { - results: mockLicenses, - }, - }); - requestAutoAppliedLicense.mockResolvedValueOnce({ - data: mockLicense, - }); - const args = { - enterpriseConfig: mockEnterpriseConfiguration, - customerAgreementConfig: anotherMockCustomerAgreement, - user: mockAuthenticatedUser, - isLoadingCustomerAgreementConfig: false, - }; - const { result, waitForNextUpdate } = renderHook(() => useSubscriptionLicense(args)); - await waitForNextUpdate(); - expect(fetchSubscriptionLicensesForUser).toHaveBeenCalledWith(TEST_ENTERPRISE_UUID); - - if (shouldAutoApplyLicense) { - expect(requestAutoAppliedLicense).toHaveBeenCalledTimes(1); - expect(result.current.license).toEqual(expect.objectContaining({ - ...camelCaseObject(mockLicense), - })); - } else { - expect(requestAutoAppliedLicense).not.toHaveBeenCalled(); - if (!hasExistingLicense) { - expect(result.current.license).toEqual(null); - } - } - }); - - describe('activateUserLicense', () => { - beforeEach(() => { - fetchSubscriptionLicensesForUser.mockResolvedValue({ - data: { - results: [mockLicense], - }, - }); - }); - - afterEach(() => jest.clearAllMocks()); - - it('activates the user license and updates the license status', async () => { - activateLicense.mockResolvedValueOnce(true); - const args = { - enterpriseConfig: mockEnterpriseConfig, - customerAgreementConfig: { - subscriptions: [mockSubscriptionPlan], - }, - isLoadingCustomerAgreementConfig: false, - user: mockUser, - }; - const { result, waitForNextUpdate } = renderHook(() => useSubscriptionLicense(args)); - await waitForNextUpdate(); - const { activateUserLicense } = result.current; - activateUserLicense(); - await waitForNextUpdate(); - expect(activateLicense).toHaveBeenCalledWith(mockLicense.activation_key); - expect(result.current.license.status).toEqual(LICENSE_STATUS.ACTIVATED); - }); - - it('handles errors', async () => { - const mockError = new Error('something went swrong'); - activateLicense.mockRejectedValueOnce(mockError); - const args = { - enterpriseConfig: mockEnterpriseConfig, - customerAgreementConfig: { - subscriptions: [mockSubscriptionPlan], - }, - isLoadingCustomerAgreementConfig: false, - user: mockUser, - }; - const { result, waitForNextUpdate } = renderHook(() => useSubscriptionLicense(args)); - await waitForNextUpdate(); - const { activateUserLicense } = result.current; - try { - await activateUserLicense(); - } catch (error) { - expect(error).toEqual(mockError); - } - expect(activateLicense).toHaveBeenCalledWith(mockLicense.activation_key); - expect(result.current.license.status).toEqual(LICENSE_STATUS.ASSIGNED); - expect(logging.logError).toHaveBeenCalledWith(mockError); - }); - }); }); describe('useCouponCodes', () => { diff --git a/src/components/enterprise-user-subsidy/data/service.js b/src/components/enterprise-user-subsidy/data/service.js index 667846e526..c3fd9f26ee 100644 --- a/src/components/enterprise-user-subsidy/data/service.js +++ b/src/components/enterprise-user-subsidy/data/service.js @@ -44,9 +44,3 @@ export function fetchRedeemableLearnerCreditPolicies(enterpriseUUID, userID) { const url = `${config.ENTERPRISE_ACCESS_BASE_URL}/api/v1/policy-redemption/credits_available/?${queryParams.toString()}`; return getAuthenticatedHttpClient().get(url); } - -export function requestAutoAppliedLicense(customerAgreementId) { - const config = getConfig(); - const url = `${config.LICENSE_MANAGER_URL}/api/v1/customer-agreement/${customerAgreementId}/auto-apply/`; - return getAuthenticatedHttpClient().post(url); -} diff --git a/src/components/enterprise-user-subsidy/index.js b/src/components/enterprise-user-subsidy/index.js index 1b4e9a83fa..5b71e947de 100644 --- a/src/components/enterprise-user-subsidy/index.js +++ b/src/components/enterprise-user-subsidy/index.js @@ -1,3 +1,2 @@ export { default as UserSubsidy, UserSubsidyContext } from './UserSubsidy'; -export { default as AutoActivateLicense } from './AutoActivateLicense'; export { default as EnterpriseOffersBalanceAlert } from './enterprise-offers/EnterpriseOffersBalanceAlert'; diff --git a/src/components/enterprise-user-subsidy/tests/AutoActivateLicense.test.jsx b/src/components/enterprise-user-subsidy/tests/AutoActivateLicense.test.jsx deleted file mode 100644 index b99343f6b3..0000000000 --- a/src/components/enterprise-user-subsidy/tests/AutoActivateLicense.test.jsx +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react'; -import { - MemoryRouter, Route, Routes, mockNavigate, -} from 'react-router-dom'; -import { AppContext } from '@edx/frontend-platform/react'; -import '@testing-library/jest-dom/extend-expect'; - -import { render } from '@testing-library/react'; -import AutoActivateLicense from '../AutoActivateLicense'; - -import { UserSubsidyContext } from '../UserSubsidy'; -import { renderWithRouter } from '../../../utils/tests'; - -const TEST_ENTERPRISE_SLUG = 'test-slug'; -const initialPathname = `/${TEST_ENTERPRISE_SLUG}`; - -jest.mock('react-router-dom', () => { - const mockNavigation = jest.fn(); - - const Navigate = ({ to, state }) => { - mockNavigation(to, state); - return
; - }; - - return { - ...jest.requireActual('react-router-dom'), - Navigate, - mockNavigate: mockNavigation, - }; -}); - -const AutoActivateLicenseWrapper = ({ subscriptionLicense }) => ( - - - - - - - )} - /> - -); - -describe('', () => { - beforeEach(() => jest.clearAllMocks()); - - it('does not render when no license exists', () => { - const { history } = renderWithRouter(, { - route: initialPathname, - }); - expect(history.location.pathname).toEqual(initialPathname); - }); - - it.each( - ['activated', 'revoked'], - )('does not render when license status is %s', (status) => { - const subscriptionLicense = { status }; - const { history } = renderWithRouter(, { - route: initialPathname, - }); - expect(history.location.pathname).toEqual(initialPathname); - }); - - test('does not render when user is on the license activation page', () => { - const subscriptionLicense = { status: 'assigned', activationKey: 'test-uuid' }; - const { history } = renderWithRouter(, { - route: initialPathname, - }); - expect(history.location.pathname).toEqual(initialPathname); - }); - - test('renders when license status is assigned', () => { - const activationKey = 'test-uuid'; - const subscriptionLicense = { status: 'assigned', activationKey }; - render( - - - , - - ); - expect(mockNavigate).toHaveBeenCalledWith(`/test-slug/licenses/${activationKey}/activate`, { from: initialPathname }); - }); -}); diff --git a/src/components/license-activation/LicenseActivation.jsx b/src/components/license-activation/LicenseActivation.jsx deleted file mode 100644 index be08af37f3..0000000000 --- a/src/components/license-activation/LicenseActivation.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { Navigate, useLocation } from 'react-router-dom'; -import { Helmet } from 'react-helmet'; -import { AppContext } from '@edx/frontend-platform/react'; -import { Alert, Container } from '@openedx/paragon'; - -import { LoadingSpinner } from '../loading-spinner'; - -import { useRenderContactHelpText } from '../../utils/hooks'; -import LicenseActivationErrorAlert from './LicenseActivationErrorAlert'; -import { UserSubsidyContext } from '../enterprise-user-subsidy/UserSubsidy'; - -export const LOADING_MESSAGE = 'Your enterprise license is being activated! You will be automatically redirected to your organization\'s learner portal shortly.'; - -const LicenseActivation = () => { - const { enterpriseConfig } = useContext(AppContext); - const renderContactHelpText = useRenderContactHelpText(enterpriseConfig); - const location = useLocation(); - const fromLocation = location.state?.from; - const { activateUserLicense } = useContext(UserSubsidyContext); - const [activationSuccess, setActivationSuccess] = useState(); - - useEffect(() => { - const activateLicense = async () => { - const autoActivated = !!fromLocation; - try { - await activateUserLicense(autoActivated); - setActivationSuccess(true); - } catch (error) { - setActivationSuccess(false); - } - }; - - activateLicense(); - }, [activateUserLicense, fromLocation]); - - if (activationSuccess) { - const redirectToPath = fromLocation ?? `/${enterpriseConfig.slug}`; - return ( - - ); - } - - const PAGE_TITLE = `License Activation - ${enterpriseConfig.name}`; - - if (activationSuccess === false) { - return ( - - ); - } - - return ( - <> - - - - - - ); -}; - -export default LicenseActivation; diff --git a/src/components/license-activation/LicenseActivationErrorAlert.jsx b/src/components/license-activation/LicenseActivationErrorAlert.jsx deleted file mode 100644 index 2681560242..0000000000 --- a/src/components/license-activation/LicenseActivationErrorAlert.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { Helmet } from 'react-helmet'; -import { Alert, Container } from '@openedx/paragon'; - -const LicenseActivationErrorAlert = ({ title, contactHelpText }) => ( - <> - - - - We were unable to activate a license for this user. Please double-check that you have an - assigned license and verify the email to which it was sent. If you run into further issues, - please {contactHelpText} for assistance. - - - -); - -LicenseActivationErrorAlert.propTypes = { - title: PropTypes.string.isRequired, - contactHelpText: PropTypes.string.isRequired, -}; - -export default LicenseActivationErrorAlert; diff --git a/src/components/license-activation/LicenseActivationPage.jsx b/src/components/license-activation/LicenseActivationPage.jsx deleted file mode 100644 index fa1b91989a..0000000000 --- a/src/components/license-activation/LicenseActivationPage.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useContext } from 'react'; -import { Navigate, useParams } from 'react-router-dom'; -import { AppContext } from '@edx/frontend-platform/react'; -import { logInfo } from '@edx/frontend-platform/logging'; -import { Alert } from '@openedx/paragon'; - -import { UserSubsidyContext } from '../enterprise-user-subsidy/UserSubsidy'; -import { LICENSE_STATUS } from '../enterprise-user-subsidy/data/constants'; -import LicenseActivation from './LicenseActivation'; -import LicenseActivationErrorAlert from './LicenseActivationErrorAlert'; - -import { useRenderContactHelpText } from '../../utils/hooks'; - -export const LOADING_MESSAGE = 'Your enterprise license is being activated! You will be automatically redirected to your organization\'s learner portal shortly.'; - -const LicenseActivationPage = () => { - const { authenticatedUser: { userId }, enterpriseConfig } = useContext(AppContext); - const { subscriptionLicense } = useContext(UserSubsidyContext); - const { activationKey } = useParams(); - const renderContactHelpText = useRenderContactHelpText(enterpriseConfig); - - if (!subscriptionLicense || subscriptionLicense.status !== LICENSE_STATUS.ASSIGNED) { - if (!subscriptionLicense) { - logInfo(`User ${userId} attempted to activate a license with activation key ${activationKey}, but has no license.`); - } else { - logInfo( - `User ${userId} attempted to activate a license with activation key ${activationKey}` - + ` but their license ${subscriptionLicense.uuid} is ${subscriptionLicense.status}.`, - ); - } - - return ( - - ); - } - - if (activationKey !== subscriptionLicense.activationKey) { - logInfo( - `User ${userId} attempted to activate a license with activation key ${activationKey}` - + ` but their license ${subscriptionLicense.uuid} has activation key ${subscriptionLicense.activationKey}.`, - ); - // User will be redirected to the correct activation link due to AutoActivateLicense. - return ( - - ); - } - - return ; -}; - -export default LicenseActivationPage; diff --git a/src/components/license-activation/index.js b/src/components/license-activation/index.js deleted file mode 100644 index 60e28b6b1c..0000000000 --- a/src/components/license-activation/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as LicenseActivationPage } from './LicenseActivationPage'; diff --git a/src/components/license-activation/tests/LicenseActivation.test.jsx b/src/components/license-activation/tests/LicenseActivation.test.jsx deleted file mode 100644 index 1fc6c07940..0000000000 --- a/src/components/license-activation/tests/LicenseActivation.test.jsx +++ /dev/null @@ -1,131 +0,0 @@ -import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import { AppContext } from '@edx/frontend-platform/react'; -import '@testing-library/jest-dom/extend-expect'; - -import * as reactRouterDom from 'react-router-dom'; -import { - Route, Routes, MemoryRouter, mockNavigate, -} from 'react-router-dom'; -import LicenseActivation, { LOADING_MESSAGE } from '../LicenseActivation'; - -import { UserSubsidyContext } from '../../enterprise-user-subsidy/UserSubsidy'; - -jest.mock('react-router-dom', () => { - const mockNavigation = jest.fn(); - - // eslint-disable-next-line react/prop-types - const Navigate = ({ to }) => { - mockNavigation(to); - return
; - }; - - return { - ...jest.requireActual('react-router-dom'), - Navigate, - mockNavigate: mockNavigation, - useLocation: jest.fn(() => ({})), - }; -}); - -const TEST_ENTERPRISE_UUID = 'test-enterprise-uuid'; -const TEST_ENTERPRISE_SLUG = 'test-enterprise-slug'; -const TEST_ACTIVATION_KEY = '00000000-0000-0000-0000-000000000000'; -const TEST_ROUTE = `/${TEST_ENTERPRISE_SLUG}/licenses/${TEST_ACTIVATION_KEY}/activate`; - -const LicenseActivationWithAppContext = ({ - initialUserSubsidyState = { - activateUserLicense: jest.fn(() => true), - }, -}) => ( - - - - - } /> - - - - -); - -describe('LicenseActivation', () => { - beforeEach(() => jest.clearAllMocks()); - - test('renders a loading message initially', async () => { - // For the initial state, there is no activation success or error - const mockActivateUserLicense = jest.fn(); - - render(); - - await waitFor(() => { - expect(mockActivateUserLicense).toHaveBeenCalledWith(false); - - // assert component is initially loading and displays the loading message as screenreader text - expect(screen.queryAllByText(LOADING_MESSAGE)).toHaveLength(1); - - // assert we did NOT get redirected - expect(mockNavigate).not.toHaveBeenCalled(); - }); - }); - - test('renders an error alert when activation failed', async () => { - const mockActivateUserLicense = jest.fn().mockRejectedValueOnce( - new Error("Couldn't activate license"), - ); - - render( - , - ); - - expect(mockActivateUserLicense).toHaveBeenCalledWith(false); - - await waitFor(() => { - // assert an error alert appears - expect(screen.getByRole('alert')).toHaveClass('alert-danger'); - - // assert we did NOT get redirected - expect(mockNavigate).not.toHaveBeenCalledWith(); - }); - }); - - test.each([undefined, '/some-page'])('redirects on activation success', async (redirectedFrom) => { - if (redirectedFrom) { - reactRouterDom.useLocation.mockReturnValue({ - state: { - from: redirectedFrom, - }, - }); - } - - const mockActivateUserLicense = jest.fn(); - render( - , - ); - - await waitFor(() => { - expect(mockActivateUserLicense).toHaveBeenCalledWith(!!redirectedFrom); - expect(mockNavigate).toHaveBeenCalledWith(redirectedFrom ?? `/${TEST_ENTERPRISE_SLUG}`); - }); - }); -}); diff --git a/src/components/license-activation/tests/LicenseActivationPage.test.jsx b/src/components/license-activation/tests/LicenseActivationPage.test.jsx deleted file mode 100644 index 2ba10c6cee..0000000000 --- a/src/components/license-activation/tests/LicenseActivationPage.test.jsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react'; -import { AppContext } from '@edx/frontend-platform/react'; -import '@testing-library/jest-dom/extend-expect'; - -import LicenseActivationPage from '../LicenseActivationPage'; -import { UserSubsidyContext } from '../../enterprise-user-subsidy'; -import { LICENSE_STATUS } from '../../enterprise-user-subsidy/data/constants'; -import { renderWithRouter } from '../../../utils/tests'; - -const TEST_USER_ID = 1; - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: jest.fn(() => ({})), -})); -jest.mock('../LicenseActivation', () => ({ - __esModule: true, - default: () => '', -})); -jest.mock('../LicenseActivationErrorAlert', () => ({ - __esModule: true, - default: () => '', -})); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - activationKey: '00000000-0000-0000-0000-000000000000', - }), -})); - -const TEST_ENTERPRISE_UUID = 'test-enterprise-uuid'; -const TEST_ENTERPRISE_SLUG = 'test-enterprise-slug'; -const TEST_ACTIVATION_KEY = '00000000-0000-0000-0000-000000000000'; - -const LicenseActivationPageWithContext = ({ - initialUserSubsidyState = { - subscriptionLicense: undefined, - couponCodes: { - couponCodes: [], - couponCodesCount: 0, - }, - }, -}) => ( - - - - - -); - -describe('', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it.each( - [undefined, { status: LICENSE_STATUS.ACTIVATED }], - )('should redirect if the user has no license to activate', (subscriptionLicense) => { - renderWithRouter( - , - ); - - expect(window.location.pathname).toContain(`/${TEST_ENTERPRISE_SLUG}`); - }); - - it('should render error alert if attempting to activate a license that does not belong to the user', () => { - renderWithRouter( - , - ); - - expect(screen.getByText('')).toBeInTheDocument(); - }); - - it('should render if there is a license to activate', () => { - renderWithRouter( - , - ); - expect(screen.getByText('')).toBeInTheDocument(); - }); -}); From d0b1f414a149e128924a4abcab55dbf09a779d11 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 4 Mar 2024 21:06:04 -0500 Subject: [PATCH 06/16] chore: incremental tests --- src/components/app/Layout.jsx | 5 +- src/components/app/Layout.test.jsx | 147 ++++++++++++++++++ .../app/routes/createAppRouter.test.jsx | 124 +++++++++++++++ src/utils/tests.jsx | 9 +- 4 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 src/components/app/Layout.test.jsx create mode 100644 src/components/app/routes/createAppRouter.test.jsx diff --git a/src/components/app/Layout.jsx b/src/components/app/Layout.jsx index 8577d35511..d9d7932d39 100644 --- a/src/components/app/Layout.jsx +++ b/src/components/app/Layout.jsx @@ -1,8 +1,7 @@ -import { AppContext } from '@edx/frontend-platform/react'; -import { useContext } from 'react'; import { Helmet } from 'react-helmet'; import { Outlet } from 'react-router-dom'; import SiteFooter from '@edx/frontend-component-footer'; +import { getConfig } from '@edx/frontend-platform/config'; import { useEnterpriseLearner, isSystemMaintenanceAlertOpen } from './data'; import { useStylesForCustomBrandColors } from '../layout/data/hooks'; @@ -15,7 +14,7 @@ export const TITLE_TEMPLATE = '%s - edX'; export const DEFAULT_TITLE = 'edX'; const Layout = () => { - const { config } = useContext(AppContext); + const config = getConfig(); const { data: enterpriseLearnerData } = useEnterpriseLearner(); const brandStyles = useStylesForCustomBrandColors(enterpriseLearnerData.enterpriseCustomer); diff --git a/src/components/app/Layout.test.jsx b/src/components/app/Layout.test.jsx new file mode 100644 index 0000000000..aa662ebfc4 --- /dev/null +++ b/src/components/app/Layout.test.jsx @@ -0,0 +1,147 @@ +import { screen } from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { AppContext } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { mergeConfig } from '@edx/frontend-platform'; +import dayjs from 'dayjs'; +import '@testing-library/jest-dom/extend-expect'; + +import Layout from './Layout'; +import { queryClient, renderWithRouterProvider } from '../../utils/tests'; +import { useEnterpriseLearner } from './data'; + +const mockDefaultAppContextValue = { + authenticatedUser: { + userId: 3, + }, + config: { + LMS_BASE_URL: 'https://test-lms.url', + }, +}; + +const mockEnterpriseCustomer = { + uuid: 'test-enterprise-uuid', + brandingConfiguration: { + logo: 'https://test-logo.url', + primaryColor: '#000000', + secondaryColor: '#FF0000', + tertiaryColor: '#0000FF', + }, +}; + +jest.mock('@edx/frontend-component-footer', () => jest.fn(() =>
)); +jest.mock('../site-header', () => ({ + ...jest.requireActual('../site-header'), + SiteHeader: jest.fn(() =>
), +})); +jest.mock('../enterprise-banner', () => ({ + ...jest.requireActual('../enterprise-banner'), + EnterpriseBanner: jest.fn(() =>
), +})); +jest.mock('../../utils/common', () => ({ + ...jest.requireActual('../../utils/common'), + getBrandColorsFromCSSVariables: jest.fn().mockReturnValue({ + white: '#FFFFFF', + dark: '#000000', + }), +})); + +jest.mock('./data', () => ({ + ...jest.requireActual('./data'), + useEnterpriseLearner: jest.fn().mockReturnValue({ + data: { + enterpriseCustomer: null, + }, + }), +})); + +const LayoutWrapper = ({ + appContextValue = mockDefaultAppContextValue, +}) => ( + + + + + + + +); + +describe('Layout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the not found page when the user is not linked to an enterprise customer', () => { + renderWithRouterProvider(); + expect(screen.getByText('404', { selector: 'h1' })).toBeInTheDocument(); + }); + + it.each([ + { + isSystemMaintenanceAlertOpen: false, + maintenanceMessage: undefined, + maintenanceStartTimestamp: undefined, + }, + { + isSystemMaintenanceAlertOpen: true, + maintenanceMessage: 'Hello World!', + maintenanceStartTimestamp: undefined, + }, + { + isSystemMaintenanceAlertOpen: true, + maintenanceMessage: 'Hello World!', + maintenanceStartTimestamp: dayjs().subtract(1, 'm').toISOString(), + }, + { + isSystemMaintenanceAlertOpen: false, + maintenanceMessage: 'Hello World!', + maintenanceStartTimestamp: dayjs().add(1, 'm').toISOString(), + }, + ])('renders with enterprise customer (%s)', ({ + isSystemMaintenanceAlertOpen, + maintenanceMessage, + maintenanceStartTimestamp, + }) => { + useEnterpriseLearner.mockReturnValue({ + data: { + enterpriseCustomer: mockEnterpriseCustomer, + }, + }); + + if (maintenanceMessage) { + mergeConfig({ + IS_MAINTENANCE_ALERT_ENABLED: isSystemMaintenanceAlertOpen, + MAINTENANCE_ALERT_MESSAGE: maintenanceMessage, + }); + } + if (maintenanceStartTimestamp) { + mergeConfig({ + MAINTENANCE_ALERT_START_TIMESTAMP: maintenanceStartTimestamp ?? '', + }); + } + + renderWithRouterProvider({ + path: '/:enterpriseSlug', + element: , + children: [ + { + path: '', + element:
, + }, + ], + }, { + initialEntries: ['/test-enterprise'], + }); + expect(screen.getByTestId('site-header')).toBeInTheDocument(); + expect(screen.getByTestId('enterprise-banner')).toBeInTheDocument(); + expect(screen.getByTestId('child-route')).toBeInTheDocument(); + expect(screen.getByTestId('site-footer')).toBeInTheDocument(); + + if (isSystemMaintenanceAlertOpen) { + expect(screen.getByText(maintenanceMessage)).toBeInTheDocument(); + } else if (maintenanceMessage) { + expect(screen.queryByText(maintenanceMessage)).not.toBeInTheDocument(); + } + }); +}); diff --git a/src/components/app/routes/createAppRouter.test.jsx b/src/components/app/routes/createAppRouter.test.jsx new file mode 100644 index 0000000000..8006542409 --- /dev/null +++ b/src/components/app/routes/createAppRouter.test.jsx @@ -0,0 +1,124 @@ +import { + act, render, screen, waitFor, +} from '@testing-library/react'; +import { Outlet, RouterProvider } from 'react-router-dom'; +import '@testing-library/jest-dom/extend-expect'; + +import createAppRouter from './createAppRouter'; +import { queryClient } from '../../../utils/tests'; +import { + makeRootLoader, + makeDashboardLoader, + makeCourseLoader, +} from './loaders'; +import Root from '../Root'; +import Layout from '../Layout'; + +jest.mock('./loaders', () => ({ + ...jest.requireActual('./loaders'), + makeRootLoader: jest.fn(), + makeDashboardLoader: jest.fn(), + makeCourseLoader: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/react', () => ({ + ...jest.requireActual('@edx/frontend-platform/react'), + PageWrap: jest.fn(({ children }) => children), +})); +jest.mock('../Root', () => jest.fn()); +jest.mock('../Layout', () => jest.fn()); + +jest.mock('./DashboardRoute', () => jest.fn(() =>
)); +jest.mock('./SearchRoute', () => jest.fn(() =>
)); +jest.mock('./CourseRoute', () => jest.fn(() =>
)); +jest.mock('../../NotFoundPage', () => jest.fn(() =>
)); + +Root.mockImplementation(() => ( +
+ +
+)); +Layout.mockImplementation(() => ( +
+ +
+)); + +const mockQueryClient = queryClient(); + +describe('createAppRouter', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + currentRoutePath: '/fake/page/does/not/exist', + expectedRouteTestId: 'not-found', + expectedRouteLoaders: [], + }, + { + currentRoutePath: '/', + expectedRouteTestId: 'dashboard', + expectedRouteLoaders: [makeDashboardLoader], + }, + { + currentRoutePath: '/test-enterprise', + expectedRouteTestId: 'dashboard', + expectedRouteLoaders: [makeDashboardLoader], + }, + { + currentRoutePath: '/test-enterprise/search', + expectedRouteTestId: 'search', + expectedRouteLoaders: [], + }, + { + currentRoutePath: '/test-enterprise/course/edX+DemoX', + expectedRouteTestId: 'course', + expectedRouteLoaders: [makeCourseLoader], + }, + { + currentRoutePath: '/test-enterprise/executive-education/course/edX+DemoX', + expectedRouteTestId: 'course', + expectedRouteLoaders: [makeCourseLoader], + }, + { + currentRoutePath: '/test-enterprise/executive-education/course/edX+DemoX/enroll', + expectedRouteTestId: 'course', + expectedRouteLoaders: [makeCourseLoader], + }, + { + currentRoutePath: '/test-enterprise/executive-education/course/edX+DemoX/enroll/complete', + expectedRouteTestId: 'course', + expectedRouteLoaders: [makeCourseLoader], + }, + ])('renders expected route components for given route path (%s)', async ({ + currentRoutePath, + expectedRouteTestId, + expectedRouteLoaders, + }) => { + const router = createAppRouter(mockQueryClient); + render(); + await waitFor(() => { + expect(screen.getByTestId('root')).toBeInTheDocument(); + expect(screen.getByTestId('layout')).toBeInTheDocument(); + expect(makeRootLoader).toHaveBeenCalledTimes(1); + expect(makeRootLoader).toHaveBeenCalledWith(mockQueryClient); + }); + + act(() => { + router.navigate(currentRoutePath); + }); + + await waitFor(() => { + expect(screen.getByTestId(expectedRouteTestId)).toBeInTheDocument(); + }); + + if (expectedRouteLoaders?.length > 0) { + expectedRouteLoaders.forEach((expectedLoader) => { + expect(expectedLoader).toHaveBeenCalledTimes(1); + expect(expectedLoader).toHaveBeenCalledWith(mockQueryClient); + }); + } + }); +}); diff --git a/src/utils/tests.jsx b/src/utils/tests.jsx index fe0fde6c42..b499dd3b99 100644 --- a/src/utils/tests.jsx +++ b/src/utils/tests.jsx @@ -10,7 +10,7 @@ import { queryCacheOnErrorHandler } from './common'; /** * TODO * @param {*} children - * @param {*} routes + * @param {*} options * @returns */ export function renderWithRouterProvider( @@ -18,15 +18,18 @@ export function renderWithRouterProvider( { routes = [], initialEntries, - }, + customRouter, + } = {}, ) { const options = isValidElement(children) ? { element: children, path: '/' } : children; - const router = createMemoryRouter([{ ...options }, ...routes], { + + const router = customRouter ?? createMemoryRouter([{ ...options }, ...routes], { initialEntries: ['/', ...(initialEntries ?? [options.path])], initialIndex: 1, }); + return render(); } From cffd0b406641948e6b09c02d168124494f5f949d Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Tue, 5 Mar 2024 12:03:44 -0500 Subject: [PATCH 07/16] feat: refactor notices provider to useQuery (#992) * feat: refactor notices provider to useQuery * chore: merge fix * chore: cleanup * fix: redirects without momentary render * fix: debugging * chore: Testing and cleanup * chore: PR feedback --- src/components/app/Root.jsx | 5 +- .../data/queries/ensureEnterpriseAppData.js | 15 +++- .../app/routes/data/queries/index.js | 1 + .../app/routes/data/queries/notices.js | 12 +++ .../queries/subsidies/browseAndRequest.js | 16 ++-- src/components/app/routes/data/services.js | 25 ++++++ .../app/routes/data/tests/services.test.js | 76 +++++++++++++++++++ .../app/routes/data/{ => tests}/utils.test.js | 2 +- .../notices-provider/NoticesProvider.jsx | 32 -------- .../notices-provider/NoticesProvider.test.jsx | 53 ------------- src/components/notices-provider/api.js | 25 ------ src/components/notices-provider/index.js | 3 - src/utils/queryKeyFactory.js | 7 +- 13 files changed, 143 insertions(+), 129 deletions(-) create mode 100644 src/components/app/routes/data/queries/notices.js create mode 100644 src/components/app/routes/data/tests/services.test.js rename src/components/app/routes/data/{ => tests}/utils.test.js (97%) delete mode 100644 src/components/notices-provider/NoticesProvider.jsx delete mode 100644 src/components/notices-provider/NoticesProvider.test.jsx delete mode 100644 src/components/notices-provider/api.js delete mode 100644 src/components/notices-provider/index.js diff --git a/src/components/app/Root.jsx b/src/components/app/Root.jsx index 9ec140b6f9..c4a93ee66b 100644 --- a/src/components/app/Root.jsx +++ b/src/components/app/Root.jsx @@ -9,7 +9,6 @@ import { Hyperlink } from '@openedx/paragon'; import DelayedFallbackContainer from '../DelayedFallback/DelayedFallbackContainer'; import { Toasts, ToastsProvider } from '../Toasts'; -import NoticesProvider from '../notices-provider'; import { ErrorPage } from '../error-page'; import { useNProgressLoader } from './data'; @@ -47,7 +46,7 @@ const Root = () => { // User is authenticated, so render the child routes (rest of the app). return ( - + <> }> @@ -55,7 +54,7 @@ const Root = () => { - + ); }; diff --git a/src/components/app/routes/data/queries/ensureEnterpriseAppData.js b/src/components/app/routes/data/queries/ensureEnterpriseAppData.js index 8f1cabba2b..724d0cfab9 100644 --- a/src/components/app/routes/data/queries/ensureEnterpriseAppData.js +++ b/src/components/app/routes/data/queries/ensureEnterpriseAppData.js @@ -1,4 +1,5 @@ import dayjs from 'dayjs'; +import { getConfig } from '@edx/frontend-platform'; import { activateLicense, requestAutoAppliedUserLicense } from '../services'; import { activateOrAutoApplySubscriptionLicense } from '../utils'; @@ -12,6 +13,7 @@ import { querySubscriptions, queryBrowseAndRequestConfiguration, } from './subsidies'; +import queryNotices from './notices'; /** * TODO @@ -26,7 +28,7 @@ export default async function ensureEnterpriseAppData({ requestUrl, }) { const subscriptionsQuery = querySubscriptions(enterpriseCustomer.uuid); - const enterpriseAppData = await Promise.all([ + const enterpriseAppDataQueries = [ // Enterprise Customer User Subsidies queryClient.ensureQueryData(subscriptionsQuery).then(async (subscriptionsData) => { // Auto-activate the user's subscription license, if applicable. @@ -88,7 +90,14 @@ export default async function ensureEnterpriseAppData({ queryClient.ensureQueryData( queryContentHighlightsConfiguration(enterpriseCustomer.uuid), ), - ]); - + ]; + if (getConfig().ENABLE_NOTICES) { + enterpriseAppDataQueries.push( + queryClient.ensureQueryData( + queryNotices(), + ), + ); + } + const enterpriseAppData = await Promise.all(enterpriseAppDataQueries); return enterpriseAppData; } diff --git a/src/components/app/routes/data/queries/index.js b/src/components/app/routes/data/queries/index.js index 19c4eaa208..602835fb75 100644 --- a/src/components/app/routes/data/queries/index.js +++ b/src/components/app/routes/data/queries/index.js @@ -6,6 +6,7 @@ export { default as queryCourseMetadata } from './courseMetadata'; export { default as queryEnterpriseCourseEnrollments } from './enterpriseCourseEnrollments'; export { default as queryUserEntitlements } from './userEntitlements'; export { default as ensureEnterpriseAppData } from './ensureEnterpriseAppData'; +export { default as queryNotices } from './notices'; export { queryEnterpriseLearner, diff --git a/src/components/app/routes/data/queries/notices.js b/src/components/app/routes/data/queries/notices.js new file mode 100644 index 0000000000..564e8c75fa --- /dev/null +++ b/src/components/app/routes/data/queries/notices.js @@ -0,0 +1,12 @@ +import { queries } from '../../../../../utils/queryKeyFactory'; + +/** + * Helper function to assist querying with useQuery package + * + * @returns {Types.QueryObject} - The query object for notices. + * @property {[string]} QueryObject.queryKey - The query key for the object + * @property {func} QueryObject.queryFn - The asynchronous API request "fetchNotices" + */ +export default function queryNotices() { + return queries.user.notices; +} diff --git a/src/components/app/routes/data/queries/subsidies/browseAndRequest.js b/src/components/app/routes/data/queries/subsidies/browseAndRequest.js index 846b16ad5e..e24e1cec35 100644 --- a/src/components/app/routes/data/queries/subsidies/browseAndRequest.js +++ b/src/components/app/routes/data/queries/subsidies/browseAndRequest.js @@ -2,14 +2,13 @@ import { queries } from '../../../../../../utils/queryKeyFactory'; import { SUBSIDY_REQUEST_STATE } from '../../../../../enterprise-subsidy-requests'; /** - * Helper function to assist querying with useQuery package - * queries - * .enterprise - * .enterpriseCustomer(enterpriseUuid) - * ._ctx.subsidies - * ._ctx.browseAndRequest(userEmail) - * ._ctx.configuration - * @returns + * Helper function to assist querying with useQuery package. + * + * @param {string} enterpriseUuid - The UUID of the enterprise. + * @param {string} userEmail - The email of the user. + * @returns {QueryObject} - The query object for the enterprise configuration. + * @property {[string]} QueryObject.queryKey - The query key for the object + * @property {func} QueryObject.queryFn - The asynchronous API request "fetchBrowseAndRequestConfiguration" */ export function queryBrowseAndRequestConfiguration(enterpriseUuid) { return queries @@ -43,6 +42,7 @@ export function queryLicenseRequests(enterpriseUuid, userEmail, state = SUBSIDY_ /** * Helper function to assist querying with useQuery package + * * queries * .enterprise * .enterpriseCustomer(enterpriseUuid) diff --git a/src/components/app/routes/data/services.js b/src/components/app/routes/data/services.js index 738a81078a..75bd17bf73 100644 --- a/src/components/app/routes/data/services.js +++ b/src/components/app/routes/data/services.js @@ -1,5 +1,6 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { logError, logInfo } from '@edx/frontend-platform/logging'; import { ENTERPRISE_OFFER_STATUS, ENTERPRISE_OFFER_USAGE_TYPE, @@ -384,6 +385,30 @@ export async function requestAutoAppliedUserLicense(customerAgreementId) { return camelCaseObject(response.data); } +// Notices +export const fetchNotices = async () => { + const url = `${getConfig().LMS_BASE_URL}/notices/api/v1/unacknowledged`; + try { + const { data } = await getAuthenticatedHttpClient().get(url); + if (data?.results.length > 0) { + const { results } = data; + window.location.assign(`${results[0]}?next=${window.location.href}`); + throw new Error('Redirecting to notice'); + } + return data; + } catch (error) { + // we will just swallow error, as that probably means the notices app is not installed. + // Notices are not necessary for the rest of dashboard to function. + const httpErrorStatus = getErrorResponseStatusCode(error); + if (httpErrorStatus === 404) { + logInfo(`${error}. This probably happened because the notices plugin is not installed on platform.`); + } else { + logError(error); + } + } + return null; +}; + /** * Helper function to `updateActiveEnterpriseCustomerUser` to make the POST API * request, updating the active enterprise customer for the learner. diff --git a/src/components/app/routes/data/tests/services.test.js b/src/components/app/routes/data/tests/services.test.js new file mode 100644 index 0000000000..e4e5af751f --- /dev/null +++ b/src/components/app/routes/data/tests/services.test.js @@ -0,0 +1,76 @@ +/* eslint-disable react/jsx-filename-extension */ +import { render, waitFor, screen } from '@testing-library/react'; +import { useState } from 'react'; +import userEvent from '@testing-library/user-event'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { Button } from '@openedx/paragon'; +import { fetchNotices } from '../services'; + +const APP_CONFIG = { + USE_API_CACHE: true, + DISCOVERY_API_BASE_URL: 'http://localhost:18381', + LMS_BASE_URL: 'http://localhost:18000', +}; +jest.mock('@edx/frontend-platform/config', () => ({ + ...jest.requireActual('@edx/frontend-platform/config'), + getConfig: jest.fn(() => APP_CONFIG), +})); + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedUser: jest.fn(() => ({ id: 12345 })), + getAuthenticatedHttpClient: jest.fn(), +})); + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +describe('fetchNotices', () => { + const NOTICES_ENDPOINT = `${APP_CONFIG.LMS_BASE_URL }/notices/api/v1/unacknowledged`; + const ComponentWithNotices = () => { + const [output, setOuput] = useState(null); + const onClickHandler = async () => { + const apiOutput = await fetchNotices(); + if (apiOutput?.results.length > 0) { + setOuput(apiOutput.results[0]); + return; + } + setOuput('No Results'); + }; + return ( + + ); + }; + + // Preserves original window location, and swaps it back after test is completed + const currentLocation = window.location; + beforeAll(() => { + delete window.location; + window.location = { ...currentLocation, assign: jest.fn() }; + }); + afterAll(() => { + window.location = currentLocation; + }); + it('returns empty data results and does not assign the window location', async () => { + axiosMock.onGet(NOTICES_ENDPOINT).reply(200, { results: [] }); + render(); + userEvent.click(screen.getByTestId('fetchNotices')); + await waitFor(() => expect(window.location.assign).not.toHaveBeenCalled()); + }); + it('returns logInfo on 404', async () => { + axiosMock.onGet(NOTICES_ENDPOINT).reply(404, {}); + render(); + userEvent.click(screen.getByTestId('fetchNotices')); + await waitFor(() => expect(window.location.assign).not.toHaveBeenCalled()); + }); + it('assigns the window location on successful API response', async () => { + const currentHref = window.location.href; + axiosMock.onGet(NOTICES_ENDPOINT).reply(200, { results: [APP_CONFIG.LMS_BASE_URL] }); + render(); + userEvent.click(screen.getByTestId('fetchNotices')); + await waitFor(() => expect(window.location.assign).toHaveBeenCalledWith( + `${APP_CONFIG.LMS_BASE_URL }?next=${currentHref}`, + )); + }); +}); diff --git a/src/components/app/routes/data/utils.test.js b/src/components/app/routes/data/tests/utils.test.js similarity index 97% rename from src/components/app/routes/data/utils.test.js rename to src/components/app/routes/data/tests/utils.test.js index 811e9acc10..078f6e0ea9 100644 --- a/src/components/app/routes/data/utils.test.js +++ b/src/components/app/routes/data/tests/utils.test.js @@ -1,4 +1,4 @@ -import { transformEnterpriseCustomer } from './utils'; +import { transformEnterpriseCustomer } from '../utils'; const mockEnterpriseFeatures = { 'example-feature': true, diff --git a/src/components/notices-provider/NoticesProvider.jsx b/src/components/notices-provider/NoticesProvider.jsx deleted file mode 100644 index 9632287337..0000000000 --- a/src/components/notices-provider/NoticesProvider.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect } from 'react'; -import { getConfig } from '@edx/frontend-platform'; -import PropTypes from 'prop-types'; -import { getNotices } from './api'; -/** - * This component uses the platform-plugin-notices plugin to function. - * If the user has an unacknowledged notice, they will be rerouted off - * enterprise dashboard and onto a full-screen notice page. If the plugin is not - * installed, or there are no notices, we just passthrough this component. - */ -const NoticesProvider = ({ children }) => { - useEffect(() => { - async function getData() { - if (getConfig().ENABLE_NOTICES) { - const data = await getNotices(); - if (data?.results?.length > 0) { - const { results } = data; - window.location.replace(`${results[0]}?next=${window.location.href}`); - } - } - } - getData(); - }, []); - - return children; -}; - -NoticesProvider.propTypes = { - children: PropTypes.node.isRequired, -}; - -export default NoticesProvider; diff --git a/src/components/notices-provider/NoticesProvider.test.jsx b/src/components/notices-provider/NoticesProvider.test.jsx deleted file mode 100644 index 1518f960d6..0000000000 --- a/src/components/notices-provider/NoticesProvider.test.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; - -import { getConfig } from '@edx/frontend-platform'; -import { render, act } from '@testing-library/react'; -import NoticesProvider from './NoticesProvider'; -import { getNotices } from './api'; - -jest.mock('./api', () => ({ - getNotices: jest.fn(), -})); - -jest.mock('@edx/frontend-platform', () => ({ - getConfig: jest.fn(), -})); - -describe('NoticesProvider', () => { - function buildAndRender() { - render( - -
- , - ); - } - beforeAll(async () => { - jest.resetModules(); - }); - - it('does not call api if ENABLE_NOTICES is false', () => { - getConfig.mockImplementation(() => ({ ENABLE_NOTICES: false })); - buildAndRender(); - expect(getNotices).toHaveBeenCalledTimes(0); - }); - - it('redirects user on notice returned from API', async () => { - const redirectUrl = 'http://example.com/test_route'; - getConfig.mockImplementation(() => ({ ENABLE_NOTICES: true })); - getNotices.mockImplementation(() => ({ results: [redirectUrl] })); - delete window.location; - window.location = { replace: jest.fn() }; - await act(async () => buildAndRender()); - expect(window.location.replace).toHaveBeenCalledWith(`${redirectUrl}?next=${window.location.href}`); - }); - - it('does not redirect on no data', async () => { - getNotices.mockImplementation(() => ({})); - getConfig.mockImplementation(() => ({ ENABLE_NOTICES: true })); - delete window.location; - window.location = { replace: jest.fn() }; - await act(async () => buildAndRender()); - expect(window.location.replace).toHaveBeenCalledTimes(0); - expect(window.location.toString() === 'http://localhost/'); - }); -}); diff --git a/src/components/notices-provider/api.js b/src/components/notices-provider/api.js deleted file mode 100644 index de25078b7a..0000000000 --- a/src/components/notices-provider/api.js +++ /dev/null @@ -1,25 +0,0 @@ -import { getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import { logError, logInfo } from '@edx/frontend-platform/logging'; -import { getErrorResponseStatusCode } from '../../utils/common'; - -export const getNotices = async () => { - const authenticatedUser = getAuthenticatedUser(); - const url = new URL(`${getConfig().LMS_BASE_URL}/notices/api/v1/unacknowledged`); - if (authenticatedUser) { - try { - const { data } = await getAuthenticatedHttpClient().get(url.href, {}); - return data; - } catch (error) { - // we will just swallow error, as that probably means the notices app is not installed. - // Notices are not necessary for the rest of dashboard to function. - const httpErrorStatus = getErrorResponseStatusCode(error); - if (httpErrorStatus === 404) { - logInfo(`${error}. This probably happened because the notices plugin is not installed on platform.`); - } else { - logError(error); - } - } - } - return null; -}; diff --git a/src/components/notices-provider/index.js b/src/components/notices-provider/index.js deleted file mode 100644 index 22c90a1328..0000000000 --- a/src/components/notices-provider/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import NoticesProvider from './NoticesProvider'; - -export default NoticesProvider; diff --git a/src/utils/queryKeyFactory.js b/src/utils/queryKeyFactory.js index 9b963202d0..5909e1e15e 100644 --- a/src/utils/queryKeyFactory.js +++ b/src/utils/queryKeyFactory.js @@ -13,6 +13,7 @@ import { fetchEnterpriseOffers, fetchEnterpriseCuration, fetchCouponCodeRequests, + fetchNotices, } from '../components/app/routes/data/services'; import { SUBSIDY_REQUEST_STATE } from '../components/enterprise-subsidy-requests'; @@ -103,11 +104,15 @@ export const enterprise = createQueryKeys('enterprise', { }), }); -export const user = createQueryKeys('user', { +const user = createQueryKeys('user', { entitlements: { queryKey: null, queryFn: async () => fetchUserEntitlements(), }, + notices: { + queryKey: null, + queryFn: async () => fetchNotices(), + }, }); export const queries = mergeQueryKeys(enterprise, user); From e17c7da2f00e2290d70466b370b812a10d91cff4 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Tue, 5 Mar 2024 22:03:26 -0500 Subject: [PATCH 08/16] chore: quality, resolve lingering conflict --- src/components/app/routes/data/services.js | 24 ---------------------- 1 file changed, 24 deletions(-) diff --git a/src/components/app/routes/data/services.js b/src/components/app/routes/data/services.js index 6d7d6bef6e..75bd17bf73 100644 --- a/src/components/app/routes/data/services.js +++ b/src/components/app/routes/data/services.js @@ -373,30 +373,6 @@ export async function activateLicense(activationKey) { return getAuthenticatedHttpClient().post(url); } -// Notices -export const fetchNotices = async () => { - const url = `${getConfig().LMS_BASE_URL}/notices/api/v1/unacknowledged`; - try { - const { data } = await getAuthenticatedHttpClient().get(url); - if (data?.results.length > 0) { - const { results } = data; - window.location.assign(`${results[0]}?next=${window.location.href}`); - throw new Error('Redirecting to notice'); - } - return data; - } catch (error) { - // we will just swallow error, as that probably means the notices app is not installed. - // Notices are not necessary for the rest of dashboard to function. - const httpErrorStatus = getErrorResponseStatusCode(error); - if (httpErrorStatus === 404) { - logInfo(`${error}. This probably happened because the notices plugin is not installed on platform.`); - } else { - logError(error); - } - } - return null; -}; - /** * Attempts to auto-apply a license for the authenticated user and the specified customer agreement. * From 7b7b7459b13ad9a1598be4fcdf40bc27c783f209 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Wed, 6 Mar 2024 08:39:07 -0500 Subject: [PATCH 09/16] chore: quality --- src/components/app/Root.test.jsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/components/app/Root.test.jsx b/src/components/app/Root.test.jsx index eba516c5e0..f83681266c 100644 --- a/src/components/app/Root.test.jsx +++ b/src/components/app/Root.test.jsx @@ -24,7 +24,7 @@ jest.mock('@edx/frontend-platform/auth', () => ({ getLoginRedirectUrl: jest.fn().mockReturnValue('http://test-login-redirect-url'), })); -const baseAppContextValue = { +const defaultAppContextValue = { config: {}, authenticatedUser: { userId: 3, @@ -32,18 +32,8 @@ const baseAppContextValue = { }, }; -const hydratedUserAppContextValue = { - ...baseAppContextValue, - authenticatedUser: { - ...baseAppContextValue.authenticatedUser, - profileImage: { - url: 'http://test-profile-image-url', - }, - }, -}; - const unauthenticatedAppContextValue = { - ...baseAppContextValue, + ...defaultAppContextValue, authenticatedUser: null, }; @@ -81,12 +71,12 @@ describe('Root tests', () => { const { container } = renderWithRouterProvider({ path: '/:enterpriseSlug', element: ( - +
), }, { - initialEntries: ['/test-enterprise?logout=true'], + initialEntries: ['/test-enterprise'], }); expect(container).toBeEmptyDOMElement(); }); From d3b100562804494c34e8208439b13209b93a58f9 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Wed, 6 Mar 2024 08:58:24 -0500 Subject: [PATCH 10/16] chore: resolve test conflict --- src/components/app/Root.test.jsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/components/app/Root.test.jsx b/src/components/app/Root.test.jsx index f83681266c..22347b07d1 100644 --- a/src/components/app/Root.test.jsx +++ b/src/components/app/Root.test.jsx @@ -67,20 +67,6 @@ describe('Root tests', () => { expect(screen.queryByTestId('hidden-children')).not.toBeInTheDocument(); }); - test('page renders nothing loader when user is authenticated but not hydrated', () => { - const { container } = renderWithRouterProvider({ - path: '/:enterpriseSlug', - element: ( - -
- - ), - }, { - initialEntries: ['/test-enterprise'], - }); - expect(container).toBeEmptyDOMElement(); - }); - test.each([ { isAppDataHydrated: true }, { isAppDataHydrated: false }, From eec773e51138567105594493dadbf3bbc03cb67e Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Wed, 6 Mar 2024 12:43:41 -0500 Subject: [PATCH 11/16] refactor: avoid circular imports and util fns to util fns --- .../hooks/useBrowseAndRequestConfiguration.js | 2 +- .../useContentHighlightsConfiguration.js | 2 +- .../app/data/hooks/useCourseMetadata.js | 2 +- .../hooks/useCourseRedemptionEligibility.js | 2 +- .../hooks/useEnterpriseCourseEnrollments.js | 2 +- .../useEnterpriseCustomerUserSubsidies.js | 2 +- .../app/data/hooks/useEnterpriseLearner.js | 2 +- .../data/hooks/useIsAssignmentsOnlyLearner.js | 3 +- .../app/data/hooks/useNProgressLoader.js | 1 + src/components/app/data/hooks/useNotices.js | 2 +- .../app/data/hooks/useNotices.test.jsx | 6 +- .../app/data/hooks/useUserEntitlements.js | 2 +- src/components/app/data/index.js | 2 + .../app/{routes => }/data/queries/index.js | 15 +- src/components/app/data/queries/queries.js | 100 ++++ .../app/data/queries}/queryKeyFactory.js | 11 +- .../queries/subsidies/browseAndRequest.js | 4 +- .../data/queries/subsidies/couponCodes.js | 2 +- .../queries/subsidies/enterpriseOffers.js | 2 +- .../data/queries/subsidies/index.js | 0 .../data/queries/subsidies/policies.js | 2 +- .../data/queries/subsidies/subscriptions.js | 2 +- .../app/{routes => }/data/services.js | 263 +++++++++-- .../data/tests => data}/services.test.js | 2 +- src/components/app/data/utils.js | 172 ++++++- src/components/app/routes/data/index.js | 2 - .../routes/data/queries/canRedeemCourse.js | 20 - .../routes/data/queries/contentHighlights.js | 18 - .../app/routes/data/queries/courseMetadata.js | 18 - .../data/queries/ensureEnterpriseAppData.js | 103 ---- .../queries/enterpriseCourseEnrollments.js | 16 - .../routes/data/queries/enterpriseLearner.js | 12 - .../app/routes/data/queries/notices.js | 12 - .../routes/data/queries/userEntitlements.js | 10 - src/components/app/routes/data/utils.js | 445 +++++------------- .../app/routes/data/{tests => }/utils.test.js | 2 +- .../app/routes/loaders/courseLoader.js | 5 +- .../app/routes/loaders/dashboardLoader.js | 7 +- .../app/routes/loaders/rootLoader.js | 15 +- .../loaders/tests/courseLoader.test.jsx | 7 +- .../loaders/tests/dashboardLoader.test.jsx | 5 +- .../routes/loaders/tests/rootLoader.test.jsx | 7 +- .../enterprise-user-subsidy/data/constants.js | 1 + 43 files changed, 661 insertions(+), 649 deletions(-) rename src/components/app/{routes => }/data/queries/index.js (75%) create mode 100644 src/components/app/data/queries/queries.js rename src/{utils => components/app/data/queries}/queryKeyFactory.js (93%) rename src/components/app/{routes => }/data/queries/subsidies/browseAndRequest.js (92%) rename src/components/app/{routes => }/data/queries/subsidies/couponCodes.js (84%) rename src/components/app/{routes => }/data/queries/subsidies/enterpriseOffers.js (85%) rename src/components/app/{routes => }/data/queries/subsidies/index.js (100%) rename src/components/app/{routes => }/data/queries/subsidies/policies.js (87%) rename src/components/app/{routes => }/data/queries/subsidies/subscriptions.js (84%) rename src/components/app/{routes => }/data/services.js (68%) rename src/components/app/{routes/data/tests => data}/services.test.js (97%) delete mode 100644 src/components/app/routes/data/queries/canRedeemCourse.js delete mode 100644 src/components/app/routes/data/queries/contentHighlights.js delete mode 100644 src/components/app/routes/data/queries/courseMetadata.js delete mode 100644 src/components/app/routes/data/queries/ensureEnterpriseAppData.js delete mode 100644 src/components/app/routes/data/queries/enterpriseCourseEnrollments.js delete mode 100644 src/components/app/routes/data/queries/enterpriseLearner.js delete mode 100644 src/components/app/routes/data/queries/notices.js delete mode 100644 src/components/app/routes/data/queries/userEntitlements.js rename src/components/app/routes/data/{tests => }/utils.test.js (97%) diff --git a/src/components/app/data/hooks/useBrowseAndRequestConfiguration.js b/src/components/app/data/hooks/useBrowseAndRequestConfiguration.js index b20784afca..d48d912076 100644 --- a/src/components/app/data/hooks/useBrowseAndRequestConfiguration.js +++ b/src/components/app/data/hooks/useBrowseAndRequestConfiguration.js @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import useEnterpriseLearner from './useEnterpriseLearner'; -import { queryBrowseAndRequestConfiguration } from '../../routes/data'; +import { queryBrowseAndRequestConfiguration } from '../queries'; /** * Retrieves the course metadata for the given enterprise customer and course key. diff --git a/src/components/app/data/hooks/useContentHighlightsConfiguration.js b/src/components/app/data/hooks/useContentHighlightsConfiguration.js index 1801e01beb..09e222ed90 100644 --- a/src/components/app/data/hooks/useContentHighlightsConfiguration.js +++ b/src/components/app/data/hooks/useContentHighlightsConfiguration.js @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import useEnterpriseLearner from './useEnterpriseLearner'; -import { queryContentHighlightsConfiguration } from '../../routes/data/queries'; +import { queryContentHighlightsConfiguration } from '../queries'; /** * Retrieves the content highlights configuration for the active enterprise customer user. diff --git a/src/components/app/data/hooks/useCourseMetadata.js b/src/components/app/data/hooks/useCourseMetadata.js index 1dc4085afa..cb700f88b4 100644 --- a/src/components/app/data/hooks/useCourseMetadata.js +++ b/src/components/app/data/hooks/useCourseMetadata.js @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { useParams } from 'react-router-dom'; import useEnterpriseLearner from './useEnterpriseLearner'; -import { queryCourseMetadata } from '../../routes/data/queries'; +import { queryCourseMetadata } from '../queries'; /** * Retrieves the course metadata for the given enterprise customer and course key. diff --git a/src/components/app/data/hooks/useCourseRedemptionEligibility.js b/src/components/app/data/hooks/useCourseRedemptionEligibility.js index 1469fd65ca..903528d37c 100644 --- a/src/components/app/data/hooks/useCourseRedemptionEligibility.js +++ b/src/components/app/data/hooks/useCourseRedemptionEligibility.js @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import useCourseMetadata from './useCourseMetadata'; import useEnterpriseLearner from './useEnterpriseLearner'; -import { queryCanRedeem } from '../../routes/data/queries'; +import { queryCanRedeem } from '../queries'; /** * Retrieves the course redemption eligibility for the given enterprise customer and course key. diff --git a/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js b/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js index 1bc6750603..e3c632d744 100644 --- a/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js +++ b/src/components/app/data/hooks/useEnterpriseCourseEnrollments.js @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import useEnterpriseLearner from './useEnterpriseLearner'; -import { queryEnterpriseCourseEnrollments } from '../../routes/data/queries'; +import { queryEnterpriseCourseEnrollments } from '../queries'; /** * Retrieves the enterprise course enrollments for the active enterprise customer user. diff --git a/src/components/app/data/hooks/useEnterpriseCustomerUserSubsidies.js b/src/components/app/data/hooks/useEnterpriseCustomerUserSubsidies.js index a49e5bff37..091786a9dd 100644 --- a/src/components/app/data/hooks/useEnterpriseCustomerUserSubsidies.js +++ b/src/components/app/data/hooks/useEnterpriseCustomerUserSubsidies.js @@ -11,7 +11,7 @@ import { queryBrowseAndRequestConfiguration, queryLicenseRequests, queryCouponCodeRequests, -} from '../../routes/data/queries'; +} from '../queries'; /** * Retrieves the subsidies present for the active enterprise customer user. * @returns {Types.UseQueryResult}} The query results for the enterprise customer user subsidies. diff --git a/src/components/app/data/hooks/useEnterpriseLearner.js b/src/components/app/data/hooks/useEnterpriseLearner.js index 7584f4d177..835afd7b1e 100644 --- a/src/components/app/data/hooks/useEnterpriseLearner.js +++ b/src/components/app/data/hooks/useEnterpriseLearner.js @@ -2,7 +2,7 @@ import { AppContext } from '@edx/frontend-platform/react'; import { useQuery } from '@tanstack/react-query'; import { useContext } from 'react'; import { useParams } from 'react-router-dom'; -import { queryEnterpriseLearner } from '../../routes/data/queries'; +import { queryEnterpriseLearner } from '../queries'; /** * Retrieves the enterprise learner data for the authenticated user. diff --git a/src/components/app/data/hooks/useIsAssignmentsOnlyLearner.js b/src/components/app/data/hooks/useIsAssignmentsOnlyLearner.js index ddd87608e7..3b3085526b 100644 --- a/src/components/app/data/hooks/useIsAssignmentsOnlyLearner.js +++ b/src/components/app/data/hooks/useIsAssignmentsOnlyLearner.js @@ -8,7 +8,8 @@ export default function useIsAssignmentsOnlyLearner() { subscriptionPlan: subsidies.subscriptions.subscriptionLicenses[0]?.subscriptionPlan, // assumes 1 license (if any) subscriptionLicense: subsidies.subscriptions.subscriptionLicenses[0], // assumes 1 license (if any) licenseRequests: subsidies.browseAndRequest.licenseRequests.results, - // TODO: can we remove `couponCodesCount`? + // TODO: can we remove `couponCodesCount` eventually? We should be able to get at this + // from `couponCodeRequests` directly based on its length. couponCodesCount: subsidies.browseAndRequest.couponCodeRequests.results.length, couponCodeRequests: subsidies.browseAndRequest.couponCodeRequests.results, redeemableLearnerCreditPolicies: subsidies.redeemablePolicies, diff --git a/src/components/app/data/hooks/useNProgressLoader.js b/src/components/app/data/hooks/useNProgressLoader.js index 642913c7c1..c832df171d 100644 --- a/src/components/app/data/hooks/useNProgressLoader.js +++ b/src/components/app/data/hooks/useNProgressLoader.js @@ -2,6 +2,7 @@ import { useContext, useEffect } from 'react'; import { useFetchers, useNavigation } from 'react-router-dom'; import nprogress from 'accessible-nprogress'; import { AppContext } from '@edx/frontend-platform/react'; + import useNotices from './useNotices'; // Determines amount of time that must elapse before the diff --git a/src/components/app/data/hooks/useNotices.js b/src/components/app/data/hooks/useNotices.js index cc7e31b31b..c555bffa66 100644 --- a/src/components/app/data/hooks/useNotices.js +++ b/src/components/app/data/hooks/useNotices.js @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { queryNotices } from '../../routes/data'; +import { queryNotices } from '../queries'; /** * Responsible for returning the redirect URL for any notice(s) present diff --git a/src/components/app/data/hooks/useNotices.test.jsx b/src/components/app/data/hooks/useNotices.test.jsx index 8ca2240636..c4f9ca01ce 100644 --- a/src/components/app/data/hooks/useNotices.test.jsx +++ b/src/components/app/data/hooks/useNotices.test.jsx @@ -3,10 +3,10 @@ import { renderHook } from '@testing-library/react-hooks'; import { queryClient } from '../../../../utils/tests'; import useNotices from './useNotices'; -import { fetchNotices } from '../../routes/data/services'; +import { fetchNotices } from '../services'; -jest.mock('../../routes/data/services', () => ({ - ...jest.requireActual('../../routes/data/services'), +jest.mock('../services', () => ({ + ...jest.requireActual('../services'), fetchNotices: jest.fn().mockResolvedValue(null), })); diff --git a/src/components/app/data/hooks/useUserEntitlements.js b/src/components/app/data/hooks/useUserEntitlements.js index ea3f0dc0be..a50e378a33 100644 --- a/src/components/app/data/hooks/useUserEntitlements.js +++ b/src/components/app/data/hooks/useUserEntitlements.js @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { queryUserEntitlements } from '../../routes/data/queries'; +import { queryUserEntitlements } from '../queries'; /** * Retrieves the user entitlements. diff --git a/src/components/app/data/index.js b/src/components/app/data/index.js index b81326eca3..797dd57349 100644 --- a/src/components/app/data/index.js +++ b/src/components/app/data/index.js @@ -1,3 +1,5 @@ export * from './hooks'; export * from './utils'; export * from './constants'; +export * from './services'; +export * from './queries'; diff --git a/src/components/app/routes/data/queries/index.js b/src/components/app/data/queries/index.js similarity index 75% rename from src/components/app/routes/data/queries/index.js rename to src/components/app/data/queries/index.js index 602835fb75..c510e55eec 100644 --- a/src/components/app/routes/data/queries/index.js +++ b/src/components/app/data/queries/index.js @@ -1,18 +1,7 @@ -import queryEnterpriseLearner from './enterpriseLearner'; - -export { default as queryCanRedeem } from './canRedeemCourse'; -export { default as queryContentHighlightsConfiguration } from './contentHighlights'; -export { default as queryCourseMetadata } from './courseMetadata'; -export { default as queryEnterpriseCourseEnrollments } from './enterpriseCourseEnrollments'; -export { default as queryUserEntitlements } from './userEntitlements'; -export { default as ensureEnterpriseAppData } from './ensureEnterpriseAppData'; -export { default as queryNotices } from './notices'; - -export { - queryEnterpriseLearner, -}; +import { queryEnterpriseLearner } from './queries'; export * from './subsidies'; +export * from './queries'; /** * Extracts the appropriate enterprise ID for the current user and enterprise slug. diff --git a/src/components/app/data/queries/queries.js b/src/components/app/data/queries/queries.js new file mode 100644 index 0000000000..d89ace1b2d --- /dev/null +++ b/src/components/app/data/queries/queries.js @@ -0,0 +1,100 @@ +import { getAvailableCourseRuns } from '../../../course/data/utils'; +import queries from './queryKeyFactory'; + +/** + * Helper function to assist querying with useQuery package + * queries.user.entitlements + * @returns + */ +export function queryUserEntitlements() { + return queries.user.entitlements; +} + +/** + * Helper function to assist querying with useQuery package + * + * @property {[string]} QueryObject.queryKey - The query key for the object + * @property {func} QueryObject.queryFn - The asynchronous API request "fetchNotices" + * @returns {Types.QueryObject} - The query object for notices. + */ +export function queryNotices() { + return queries.user.notices; +} + +/** + * Helper function to assist querying with useQuery package + * queries + * .enterprise + * .enterpriseLearner(username, enterpriseSlug) + * @returns {Types.QueryObject} + */ +export function queryEnterpriseLearner(username, enterpriseSlug) { + return queries.enterprise.enterpriseLearner(username, enterpriseSlug); +} + +/** + * Helper function to assist querying with useQuery package + * queries + * .enterprise + * .enterpriseCustomer(enterpriseUuid) + * ._ctx.enrollments + * @returns + */ +export function queryEnterpriseCourseEnrollments(enterpriseUuid) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.enrollments; +} + +/** + * Helper function to assist querying with useQuery package + * queries + * .enterprise + * .enterpriseCustomer(enterpriseUuid) + * ._ctx.course + * ._ctx.contentMetadata(courseKey) + * @returns + */ +export function queryCourseMetadata(enterpriseUuid, courseKey) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.course + ._ctx.contentMetadata(courseKey); +} + +/** + * Helper function to assist querying with useQuery package + * queries + * .enterprise + * .enterpriseCustomer(enterpriseUuid) + * ._ctx.contentHighlights + * ._ctx.configuration + * @returns + */ +export function queryContentHighlightsConfiguration(enterpriseUuid) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.contentHighlights + ._ctx.configuration; +} + +/** + * Helper function to assist querying with useQuery package + * queries + * .enterprise + * .enterpriseCustomer(enterpriseUuid) + * ._ctx.course + * ._ctx.canRedeem(availableCourseRunKeys) + * @returns + */ +export function queryCanRedeem(enterpriseUuid, courseMetadata) { + const availableCourseRunKeys = getAvailableCourseRuns(courseMetadata).map(courseRun => courseRun.key); + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.course + ._ctx.canRedeem(availableCourseRunKeys); +} diff --git a/src/utils/queryKeyFactory.js b/src/components/app/data/queries/queryKeyFactory.js similarity index 93% rename from src/utils/queryKeyFactory.js rename to src/components/app/data/queries/queryKeyFactory.js index 5909e1e15e..5191670f19 100644 --- a/src/utils/queryKeyFactory.js +++ b/src/components/app/data/queries/queryKeyFactory.js @@ -1,4 +1,5 @@ import { createQueryKeys, mergeQueryKeys } from '@lukemorales/query-key-factory'; + import { fetchCourseMetadata, fetchUserEntitlements, @@ -14,11 +15,10 @@ import { fetchEnterpriseCuration, fetchCouponCodeRequests, fetchNotices, -} from '../components/app/routes/data/services'; - -import { SUBSIDY_REQUEST_STATE } from '../components/enterprise-subsidy-requests'; +} from '../services'; +import { SUBSIDY_REQUEST_STATE } from '../../../enterprise-subsidy-requests'; -export const enterprise = createQueryKeys('enterprise', { +const enterprise = createQueryKeys('enterprise', { enterpriseCustomer: (enterpriseUuid) => ({ queryKey: [enterpriseUuid], contextQueries: { @@ -115,4 +115,5 @@ const user = createQueryKeys('user', { }, }); -export const queries = mergeQueryKeys(enterprise, user); +const queries = mergeQueryKeys(enterprise, user); +export default queries; diff --git a/src/components/app/routes/data/queries/subsidies/browseAndRequest.js b/src/components/app/data/queries/subsidies/browseAndRequest.js similarity index 92% rename from src/components/app/routes/data/queries/subsidies/browseAndRequest.js rename to src/components/app/data/queries/subsidies/browseAndRequest.js index e24e1cec35..97e783c454 100644 --- a/src/components/app/routes/data/queries/subsidies/browseAndRequest.js +++ b/src/components/app/data/queries/subsidies/browseAndRequest.js @@ -1,5 +1,5 @@ -import { queries } from '../../../../../../utils/queryKeyFactory'; -import { SUBSIDY_REQUEST_STATE } from '../../../../../enterprise-subsidy-requests'; +import queries from '../queryKeyFactory'; +import { SUBSIDY_REQUEST_STATE } from '../../../../enterprise-subsidy-requests'; /** * Helper function to assist querying with useQuery package. diff --git a/src/components/app/routes/data/queries/subsidies/couponCodes.js b/src/components/app/data/queries/subsidies/couponCodes.js similarity index 84% rename from src/components/app/routes/data/queries/subsidies/couponCodes.js rename to src/components/app/data/queries/subsidies/couponCodes.js index a2cd214463..5e7c76873c 100644 --- a/src/components/app/routes/data/queries/subsidies/couponCodes.js +++ b/src/components/app/data/queries/subsidies/couponCodes.js @@ -1,4 +1,4 @@ -import { queries } from '../../../../../../utils/queryKeyFactory'; +import queries from '../queryKeyFactory'; /** * Helper function to assist querying with useQuery package diff --git a/src/components/app/routes/data/queries/subsidies/enterpriseOffers.js b/src/components/app/data/queries/subsidies/enterpriseOffers.js similarity index 85% rename from src/components/app/routes/data/queries/subsidies/enterpriseOffers.js rename to src/components/app/data/queries/subsidies/enterpriseOffers.js index d5201d09d5..0716e81ecd 100644 --- a/src/components/app/routes/data/queries/subsidies/enterpriseOffers.js +++ b/src/components/app/data/queries/subsidies/enterpriseOffers.js @@ -1,4 +1,4 @@ -import { queries } from '../../../../../../utils/queryKeyFactory'; +import queries from '../queryKeyFactory'; /** * Helper function to assist querying with useQuery package diff --git a/src/components/app/routes/data/queries/subsidies/index.js b/src/components/app/data/queries/subsidies/index.js similarity index 100% rename from src/components/app/routes/data/queries/subsidies/index.js rename to src/components/app/data/queries/subsidies/index.js diff --git a/src/components/app/routes/data/queries/subsidies/policies.js b/src/components/app/data/queries/subsidies/policies.js similarity index 87% rename from src/components/app/routes/data/queries/subsidies/policies.js rename to src/components/app/data/queries/subsidies/policies.js index 42e3b88a96..5593a9422b 100644 --- a/src/components/app/routes/data/queries/subsidies/policies.js +++ b/src/components/app/data/queries/subsidies/policies.js @@ -1,4 +1,4 @@ -import { queries } from '../../../../../../utils/queryKeyFactory'; +import queries from '../queryKeyFactory'; /** * Helper function to assist querying with useQuery package diff --git a/src/components/app/routes/data/queries/subsidies/subscriptions.js b/src/components/app/data/queries/subsidies/subscriptions.js similarity index 84% rename from src/components/app/routes/data/queries/subsidies/subscriptions.js rename to src/components/app/data/queries/subsidies/subscriptions.js index f9c5165f76..85893db372 100644 --- a/src/components/app/routes/data/queries/subsidies/subscriptions.js +++ b/src/components/app/data/queries/subsidies/subscriptions.js @@ -1,4 +1,4 @@ -import { queries } from '../../../../../../utils/queryKeyFactory'; +import queries from '../queryKeyFactory'; /** * Helper function to assist querying with useQuery package diff --git a/src/components/app/routes/data/services.js b/src/components/app/data/services.js similarity index 68% rename from src/components/app/routes/data/services.js rename to src/components/app/data/services.js index 69dcab0afe..0f54da0309 100644 --- a/src/components/app/routes/data/services.js +++ b/src/components/app/data/services.js @@ -1,20 +1,33 @@ +import dayjs from 'dayjs'; +import { generatePath, matchPath, redirect } from 'react-router-dom'; import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { logError, logInfo } from '@edx/frontend-platform/logging'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; + import { ENTERPRISE_OFFER_STATUS, ENTERPRISE_OFFER_USAGE_TYPE, -} from '../../../enterprise-user-subsidy/enterprise-offers/data/constants'; -import { getErrorResponseStatusCode } from '../../../../utils/common'; -import { SUBSIDY_REQUEST_STATE } from '../../../enterprise-subsidy-requests'; +} from '../../enterprise-user-subsidy/enterprise-offers/data/constants'; +import { getErrorResponseStatusCode } from '../../../utils/common'; +import { SUBSIDY_REQUEST_STATE } from '../../enterprise-subsidy-requests'; import { determineEnterpriseCustomerUserForDisplay, getAssignmentsByState, transformEnterpriseCustomer, transformRedeemablePoliciesData, } from './utils'; +import { LICENSE_STATUS } from '../../enterprise-user-subsidy/data/constants'; +import { features } from '../../../config'; + +// import { queryEnterpriseLearner } from './queries'; // Enterprise Course Enrollments + +/** + * TODO + * @returns + */ export async function fetchUserEntitlements() { const url = `${getConfig().LMS_BASE_URL}/api/entitlements/v1/entitlements/`; const response = await getAuthenticatedHttpClient().get(url); @@ -22,6 +35,21 @@ export async function fetchUserEntitlements() { } // Enterprise Learner + +/** + * Helper function to `updateActiveEnterpriseCustomerUser` to make the POST API + * request, updating the active enterprise customer for the learner. + * @param {Object} params - The parameters object. + * @param {Object} params.enterpriseCustomer - The enterprise customer that should be made active. + * @returns {Promise} - A promise that resolves when the active enterprise customer is updated. + */ +export async function updateUserActiveEnterprise({ enterpriseCustomer }) { + const url = `${getConfig().LMS_BASE_URL}/enterprise/select/active/`; + const formData = new FormData(); + formData.append('enterprise', enterpriseCustomer.uuid); + return getAuthenticatedHttpClient().post(url, formData); +} + /** * Recursive function to fetch all linked enterprise customer users, traversing paginated results. * @param {string} url Request URL @@ -61,16 +89,25 @@ export async function fetchEnterpriseLearnerData(username, enterpriseSlug, optio }); const url = `${enterpriseLearnerUrl}?${queryParams.toString()}`; const { - results: linkedEnterpriseCustomersUsers, + results: enterpriseCustomersUsers, enterpriseFeatures, } = await fetchData(url); - const activeLinkedEnterpriseCustomerUser = linkedEnterpriseCustomersUsers.find(enterprise => enterprise.active); + + // Transform enterprise customer user results + const transformedEnterpriseCustomersUsers = enterpriseCustomersUsers.map( + enterpriseCustomerUser => ({ + ...enterpriseCustomerUser, + enterpriseCustomer: transformEnterpriseCustomer(enterpriseCustomerUser.enterpriseCustomer), + }), + ); + + const activeLinkedEnterpriseCustomerUser = transformedEnterpriseCustomersUsers.find(enterprise => enterprise.active); const activeEnterpriseCustomer = activeLinkedEnterpriseCustomerUser?.enterpriseCustomer; const activeEnterpriseCustomerUserRoleAssignments = activeLinkedEnterpriseCustomerUser?.roleAssignments; // Find enterprise customer metadata for the currently viewed // enterprise slug in the page route params. - const foundEnterpriseCustomerUserForCurrentSlug = linkedEnterpriseCustomersUsers.find( + const foundEnterpriseCustomerUserForCurrentSlug = transformedEnterpriseCustomersUsers.find( enterpriseCustomerUser => enterpriseCustomerUser.enterpriseCustomer.slug === enterpriseSlug, ); @@ -84,11 +121,11 @@ export async function fetchEnterpriseLearnerData(username, enterpriseSlug, optio foundEnterpriseCustomerUserForCurrentSlug, }); return { - enterpriseCustomer: transformEnterpriseCustomer(enterpriseCustomer, enterpriseFeatures), + enterpriseCustomer, enterpriseCustomerUserRoleAssignments: roleAssignments, - activeEnterpriseCustomer: transformEnterpriseCustomer(activeEnterpriseCustomer, enterpriseFeatures), + activeEnterpriseCustomer, activeEnterpriseCustomerUserRoleAssignments, - allLinkedEnterpriseCustomerUsers: linkedEnterpriseCustomersUsers, + allLinkedEnterpriseCustomerUsers: transformedEnterpriseCustomersUsers, enterpriseFeatures, }; } @@ -340,27 +377,6 @@ export async function fetchRedeemablePolicies(enterpriseUUID, userID) { } // Subscriptions -/** - * TODO - * @returns - * @param enterpriseUUID - */ -export async function fetchSubscriptions(enterpriseUUID) { - const queryParams = new URLSearchParams({ - enterprise_customer_uuid: enterpriseUUID, - include_revoked: true, - }); - const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/learner-licenses/?${queryParams.toString()}`; - const response = await getAuthenticatedHttpClient().get(url); - const responseData = camelCaseObject(response.data); - // Extracts customer agreement and removes it from the original response object - const { customerAgreement } = responseData; - const subscriptionsData = { - subscriptionLicenses: responseData.results, - customerAgreement, - }; - return subscriptionsData; -} /** * TODO @@ -373,6 +389,49 @@ export async function activateLicense(activationKey) { return getAuthenticatedHttpClient().post(url); } +/** + * TODO + * @param {*} param0 + * @returns + */ +export async function activateSubscriptionLicense({ + enterpriseCustomer, + subscriptionLicenseToActivate, + requestUrl, +}) { + const licenseActivationRouteMatch = matchPath('/:enterpriseSlug/licenses/:activationKey/activate', requestUrl.pathname); + const dashboardRedirectPath = generatePath('/:enterpriseSlug', { enterpriseSlug: enterpriseCustomer.slug }); + try { + // Activate the user's assigned subscription license. + await activateLicense(subscriptionLicenseToActivate.activationKey); + const autoActivatedSubscriptionLicense = { + ...subscriptionLicenseToActivate, + status: 'activated', + activationDate: dayjs().toISOString(), + }; + sendEnterpriseTrackEvent( + enterpriseCustomer.uuid, + 'edx.ui.enterprise.learner_portal.license-activation.license-activated', + { + // `autoActivated` is true if the user is on a page route *other* than the license activation route. + autoActivated: !licenseActivationRouteMatch, + }, + ); + // If user is on the license activation route, redirect to the dashboard. + if (licenseActivationRouteMatch) { + throw redirect(dashboardRedirectPath); + } + // Otherwise, return the now-activated subscription license. + return autoActivatedSubscriptionLicense; + } catch (error) { + logError(error); + if (licenseActivationRouteMatch) { + throw redirect(dashboardRedirectPath); + } + return null; + } +} + /** * Attempts to auto-apply a license for the authenticated user and the specified customer agreement. * @@ -385,6 +444,136 @@ export async function requestAutoAppliedUserLicense(customerAgreementId) { return camelCaseObject(response.data); } +/** + * TODO + * @param {*} param0 + */ +export async function getAutoAppliedSubscriptionLicense({ + enterpriseCustomer, + customerAgreement, +}) { + // If the feature flag for auto-applied licenses is not enabled, return early. + if (!features.ENABLE_AUTO_APPLIED_LICENSES) { + return null; + } + + const hasSubscriptionForAutoAppliedLicenses = !!customerAgreement.subscriptionForAutoAppliedLicenses; + const hasIdentityProvider = enterpriseCustomer.identityProvider; + + // If customer agreement has no configured subscription plan for auto-applied + // licenses, or the enterprise customer does not have an identity provider, + // return early. + if (!hasSubscriptionForAutoAppliedLicenses || !hasIdentityProvider) { + return null; + } + + try { + return requestAutoAppliedUserLicense(customerAgreement.uuid); + } catch (error) { + logError(error); + return null; + } +} + +/** + * TODO + * @param {*} param0 + * @returns + */ +export async function activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer, + subscriptionsData, + requestUrl, +}) { + const { + customerAgreement, + licensesByStatus, + } = subscriptionsData; + if (!customerAgreement || customerAgreement.netDaysUntilExpiration <= 0) { + return null; + } + + // Check if learner already has activated license. If so, return early. + const hasActivatedSubscriptionLicense = licensesByStatus[LICENSE_STATUS.ACTIVATED].length > 0; + if (hasActivatedSubscriptionLicense) { + return null; + } + + // Otherwise, check if there is an assigned subscription + // license to activate OR if the user should request an + // auto-applied subscription license. + const subscriptionLicenseToActivate = licensesByStatus[LICENSE_STATUS.ASSIGNED][0]; + if (subscriptionLicenseToActivate) { + return activateSubscriptionLicense({ + enterpriseCustomer, + subscriptionLicenseToActivate, + requestUrl, + }); + } + + const hasRevokedSubscriptionLicense = licensesByStatus[LICENSE_STATUS.REVOKED].length > 0; + if (!hasRevokedSubscriptionLicense) { + return getAutoAppliedSubscriptionLicense({ + enterpriseCustomer, + customerAgreement, + }); + } + + return null; +} + +/** + * TODO + * @returns + * @param enterpriseUUID + */ +export async function fetchSubscriptions(enterpriseUUID) { + const queryParams = new URLSearchParams({ + enterprise_customer_uuid: enterpriseUUID, + include_revoked: true, + }); + const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/learner-licenses/?${queryParams.toString()}`; + const response = await getAuthenticatedHttpClient().get(url); + const { + customerAgreement, + results: subscriptionLicenses, + } = camelCaseObject(response.data); + const licensesByStatus = { + [LICENSE_STATUS.ACTIVATED]: [], + [LICENSE_STATUS.ASSIGNED]: [], + [LICENSE_STATUS.REVOKED]: [], + }; + const subscriptionsData = { + subscriptionLicenses, + customerAgreement, + subscriptionLicense: null, + licensesByStatus, + }; + /** + * Ordering of these status keys (i.e., activated, assigned, revoked) is important as the first + * license found when iterating through each status key in this order will be selected as the + * applicable license for use by the rest of the application. + * + * 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. + */ + subscriptionLicenses.forEach((license) => { + const { subscriptionPlan, status } = license; + const { isActive, daysUntilExpiration } = subscriptionPlan; + const isCurrent = daysUntilExpiration > 0; + const isUnassignedLicense = status === LICENSE_STATUS.UNASSIGNED; + if (isUnassignedLicense || !isCurrent || !isActive) { + return; + } + licensesByStatus[license.status].push(license); + }); + const applicableSubscriptionLicense = Object.values(licensesByStatus).flat()[0]; + subscriptionsData.subscriptionLicense = applicableSubscriptionLicense; + subscriptionsData.licensesByStatus = licensesByStatus; + + return subscriptionsData; +} + // Notices export const fetchNotices = async () => { const url = `${getConfig().LMS_BASE_URL}/notices/api/v1/unacknowledged`; @@ -407,17 +596,3 @@ export const fetchNotices = async () => { return null; } }; - -/** - * Helper function to `updateActiveEnterpriseCustomerUser` to make the POST API - * request, updating the active enterprise customer for the learner. - * @param {Object} params - The parameters object. - * @param {Object} params.enterpriseCustomer - The enterprise customer that should be made active. - * @returns {Promise} - A promise that resolves when the active enterprise customer is updated. - */ -export async function updateUserActiveEnterprise({ enterpriseCustomer }) { - const url = `${getConfig().LMS_BASE_URL}/enterprise/select/active/`; - const formData = new FormData(); - formData.append('enterprise', enterpriseCustomer.uuid); - return getAuthenticatedHttpClient().post(url, formData); -} diff --git a/src/components/app/routes/data/tests/services.test.js b/src/components/app/data/services.test.js similarity index 97% rename from src/components/app/routes/data/tests/services.test.js rename to src/components/app/data/services.test.js index 2e57529d38..4876c8e18a 100644 --- a/src/components/app/routes/data/tests/services.test.js +++ b/src/components/app/data/services.test.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from 'axios'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { logError, logInfo } from '@edx/frontend-platform/logging'; -import { fetchNotices } from '../services'; +import { fetchNotices } from './services'; const APP_CONFIG = { DISCOVERY_API_BASE_URL: 'http://localhost:18381', diff --git a/src/components/app/data/utils.js b/src/components/app/data/utils.js index c643670935..bc0beb830c 100644 --- a/src/components/app/data/utils.js +++ b/src/components/app/data/utils.js @@ -1,7 +1,9 @@ import dayjs from 'dayjs'; +import { logError } from '@edx/frontend-platform/logging'; -import { POLICY_TYPES } from '../../enterprise-user-subsidy/enterprise-offers/data/constants'; +import { ASSIGNMENT_TYPES, POLICY_TYPES } from '../../enterprise-user-subsidy/enterprise-offers/data/constants'; import { LICENSE_STATUS } from '../../enterprise-user-subsidy/data/constants'; +import { getBrandColorsFromCSSVariables } from '../../../utils/common'; /** * Check if system maintenance alert is open, based on configuration. @@ -80,3 +82,171 @@ export function determineLearnerHasContentAssignmentsOnly({ && !hasAutoAppliedLearnerCreditPolicies ); } + +/** + * Helper function to determine which linked enterprise customer user record + * should be used for display in the UI. + * @param {*} param0 + * @returns + */ +export function determineEnterpriseCustomerUserForDisplay({ + activeEnterpriseCustomer, + activeEnterpriseCustomerUserRoleAssignments, + enterpriseSlug, + foundEnterpriseCustomerUserForCurrentSlug, +}) { + const activeEnterpriseCustomerUser = { + enterpriseCustomer: activeEnterpriseCustomer, + roleAssignments: activeEnterpriseCustomerUserRoleAssignments, + }; + if (!enterpriseSlug) { + return activeEnterpriseCustomerUser; + } + if (enterpriseSlug !== activeEnterpriseCustomer.slug && foundEnterpriseCustomerUserForCurrentSlug) { + return { + enterpriseCustomer: foundEnterpriseCustomerUserForCurrentSlug.enterpriseCustomer, + roleAssignments: foundEnterpriseCustomerUserForCurrentSlug.roleAssignments, + }; + } + return activeEnterpriseCustomerUser; +} + +/** + * Takes a flattened array of assignments and returns an object containing + * lists of assignments for each assignment state. + * + * @param {Array} assignments - List of content assignments. + * @returns {{ +* assignments: Array, +* hasAssignments: Boolean, +* allocatedAssignments: Array, +* hasAllocatedAssignments: Boolean, +* canceledAssignments: Array, +* hasCanceledAssignments: Boolean, +* acceptedAssignments: Array, +* hasAcceptedAssignments: Boolean, +* }} +*/ +export function getAssignmentsByState(assignments = []) { + const allocatedAssignments = []; + const acceptedAssignments = []; + const canceledAssignments = []; + const expiredAssignments = []; + const erroredAssignments = []; + const assignmentsForDisplay = []; + + assignments.forEach((assignment) => { + switch (assignment.state) { + case ASSIGNMENT_TYPES.ALLOCATED: + allocatedAssignments.push(assignment); + break; + case ASSIGNMENT_TYPES.ACCEPTED: + acceptedAssignments.push(assignment); + break; + case ASSIGNMENT_TYPES.CANCELED: + canceledAssignments.push(assignment); + break; + case ASSIGNMENT_TYPES.EXPIRED: + expiredAssignments.push(assignment); + break; + case ASSIGNMENT_TYPES.ERRORED: + erroredAssignments.push(assignment); + break; + default: + logError(`[getAssignmentsByState] Unsupported state ${assignment.state} for assignment ${assignment.uuid}`); + break; + } + }); + + const hasAssignments = assignments.length > 0; + const hasAllocatedAssignments = allocatedAssignments.length > 0; + const hasAcceptedAssignments = acceptedAssignments.length > 0; + const hasCanceledAssignments = canceledAssignments.length > 0; + const hasExpiredAssignments = expiredAssignments.length > 0; + const hasErroredAssignments = erroredAssignments.length > 0; + + // Concatenate all assignments for display (includes allocated and canceled assignments) + assignmentsForDisplay.push(...allocatedAssignments); + assignmentsForDisplay.push(...canceledAssignments); + assignmentsForDisplay.push(...expiredAssignments); + const hasAssignmentsForDisplay = assignmentsForDisplay.length > 0; + + return { + assignments, + hasAssignments, + allocatedAssignments, + hasAllocatedAssignments, + acceptedAssignments, + hasAcceptedAssignments, + canceledAssignments, + hasCanceledAssignments, + expiredAssignments, + hasExpiredAssignments, + erroredAssignments, + hasErroredAssignments, + assignmentsForDisplay, + hasAssignmentsForDisplay, + }; +} + +/** + * Transform enterprise customer metadata for use by consuming UI components. + * @param {Object} enterpriseCustomer + * @param {Object} enterpriseFeatures + * @returns + */ +export function transformEnterpriseCustomer(enterpriseCustomer, enterpriseFeatures) { + // If the learner portal is not enabled for the displayed enterprise customer, return null. This + // results in the enterprise learner portal not being accessible for the user, showing a 404 page. + if (!enterpriseCustomer.enableLearnerPortal) { + return null; + } + + // Otherwise, learner portal is enabled, so transform the enterprise customer data. + const disableSearch = !!( + !enterpriseCustomer.enableIntegratedCustomerLearnerPortalSearch + && enterpriseCustomer.identityProvider + ); + const showIntegrationWarning = !!(!disableSearch && enterpriseCustomer.identityProvider); + const brandColors = getBrandColorsFromCSSVariables(); + const defaultPrimaryColor = brandColors.primary; + const defaultSecondaryColor = brandColors.info100; + const defaultTertiaryColor = brandColors.info500; + const { + primaryColor, + secondaryColor, + tertiaryColor, + } = enterpriseCustomer.brandingConfiguration || {}; + + return { + ...enterpriseCustomer, + brandingConfiguration: { + ...enterpriseCustomer.brandingConfiguration, + primaryColor: primaryColor || defaultPrimaryColor, + secondaryColor: secondaryColor || defaultSecondaryColor, + tertiaryColor: tertiaryColor || defaultTertiaryColor, + }, + disableSearch, + showIntegrationWarning, + enterpriseFeatures, + }; +} + +/** + * Transforms the redeemable policies data by attaching the subsidy expiration date + * to each assignment within the policies, if available. + * @param {object[]} [policies] - Array of policy objects containing learner assignments. + * @returns {object} - Returns modified policies data with subsidy expiration dates attached to assignments. + */ +export function transformRedeemablePoliciesData(policies = []) { + return policies.map((policy) => { + const assignmentsWithSubsidyExpiration = policy.learnerContentAssignments?.map(assignment => ({ + ...assignment, + subsidyExpirationDate: policy.subsidyExpirationDate, + })); + return { + ...policy, + learnerContentAssignments: assignmentsWithSubsidyExpiration, + }; + }); +} diff --git a/src/components/app/routes/data/index.js b/src/components/app/routes/data/index.js index 405d3a2bf0..04bca77e0d 100644 --- a/src/components/app/routes/data/index.js +++ b/src/components/app/routes/data/index.js @@ -1,3 +1 @@ -export * from './services'; export * from './utils'; -export * from './queries'; diff --git a/src/components/app/routes/data/queries/canRedeemCourse.js b/src/components/app/routes/data/queries/canRedeemCourse.js deleted file mode 100644 index 35d69028d9..0000000000 --- a/src/components/app/routes/data/queries/canRedeemCourse.js +++ /dev/null @@ -1,20 +0,0 @@ -import { getAvailableCourseRuns } from '../../../../course/data/utils'; -import { queries } from '../../../../../utils/queryKeyFactory'; - -/** - * Helper function to assist querying with useQuery package - * queries - * .enterprise - * .enterpriseCustomer(enterpriseUuid) - * ._ctx.course - * ._ctx.canRedeem(availableCourseRunKeys) - * @returns - */ -export default function queryCanRedeem(enterpriseUuid, courseMetadata) { - const availableCourseRunKeys = getAvailableCourseRuns(courseMetadata).map(courseRun => courseRun.key); - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.course - ._ctx.canRedeem(availableCourseRunKeys); -} diff --git a/src/components/app/routes/data/queries/contentHighlights.js b/src/components/app/routes/data/queries/contentHighlights.js deleted file mode 100644 index d22d0f73ac..0000000000 --- a/src/components/app/routes/data/queries/contentHighlights.js +++ /dev/null @@ -1,18 +0,0 @@ -import { queries } from '../../../../../utils/queryKeyFactory'; - -/** - * Helper function to assist querying with useQuery package - * queries - * .enterprise - * .enterpriseCustomer(enterpriseUuid) - * ._ctx.contentHighlights - * ._ctx.configuration - * @returns - */ -export default function queryContentHighlightsConfiguration(enterpriseUuid) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.contentHighlights - ._ctx.configuration; -} diff --git a/src/components/app/routes/data/queries/courseMetadata.js b/src/components/app/routes/data/queries/courseMetadata.js deleted file mode 100644 index 08976aa82e..0000000000 --- a/src/components/app/routes/data/queries/courseMetadata.js +++ /dev/null @@ -1,18 +0,0 @@ -import { queries } from '../../../../../utils/queryKeyFactory'; - -/** - * Helper function to assist querying with useQuery package - * queries - * .enterprise - * .enterpriseCustomer(enterpriseUuid) - * ._ctx.course - * ._ctx.contentMetadata(courseKey) - * @returns - */ -export default function queryCourseMetadata(enterpriseUuid, courseKey) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.course - ._ctx.contentMetadata(courseKey); -} diff --git a/src/components/app/routes/data/queries/ensureEnterpriseAppData.js b/src/components/app/routes/data/queries/ensureEnterpriseAppData.js deleted file mode 100644 index 724d0cfab9..0000000000 --- a/src/components/app/routes/data/queries/ensureEnterpriseAppData.js +++ /dev/null @@ -1,103 +0,0 @@ -import dayjs from 'dayjs'; -import { getConfig } from '@edx/frontend-platform'; - -import { activateLicense, requestAutoAppliedUserLicense } from '../services'; -import { activateOrAutoApplySubscriptionLicense } from '../utils'; -import queryContentHighlightsConfiguration from './contentHighlights'; -import { - queryCouponCodeRequests, - queryCouponCodes, - queryEnterpriseLearnerOffers, - queryLicenseRequests, - queryRedeemablePolicies, - querySubscriptions, - queryBrowseAndRequestConfiguration, -} from './subsidies'; -import queryNotices from './notices'; - -/** - * TODO - * @param {*} param0 - * @returns - */ -export default async function ensureEnterpriseAppData({ - enterpriseCustomer, - userId, - userEmail, - 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. - await activateOrAutoApplySubscriptionLicense({ - enterpriseCustomer, - requestUrl, - subscriptionsData, - async activateAllocatedSubscriptionLicense(subscriptionLicenseToActivate) { - await activateLicense(subscriptionLicenseToActivate.activationKey); - const autoActivatedSubscriptionLicense = { - ...subscriptionLicenseToActivate, - status: 'activated', - activationDate: dayjs().toISOString(), - }; - // Optimistically update the query cache with the auto-activated subscription license. - queryClient.setQueryData(subscriptionsQuery.queryKey, { - ...subscriptionsData, - subscriptionLicenses: subscriptionsData.subscriptionLicenses.map((license) => { - if (license.uuid === autoActivatedSubscriptionLicense.uuid) { - return autoActivatedSubscriptionLicense; - } - return license; - }), - }); - }, - async requestAutoAppliedSubscriptionLicense(customerAgreement) { - const autoAppliedSubscriptionLicense = await requestAutoAppliedUserLicense(customerAgreement.uuid); - // Optimistically update the query cache with the auto-applied subscription license. - queryClient.setQueryData(subscriptionsQuery.queryKey, { - ...subscriptionsData, - subscriptionLicenses: [autoAppliedSubscriptionLicense], - }); - }, - }); - return subscriptionsData; - }), - queryClient.ensureQueryData( - queryRedeemablePolicies({ - enterpriseUuid: enterpriseCustomer.uuid, - lmsUserId: userId, - }), - ), - queryClient.ensureQueryData( - queryCouponCodes(enterpriseCustomer.uuid), - ), - queryClient.ensureQueryData( - queryEnterpriseLearnerOffers(enterpriseCustomer.uuid), - ), - queryClient.ensureQueryData( - queryBrowseAndRequestConfiguration(enterpriseCustomer.uuid), - ), - queryClient.ensureQueryData( - queryLicenseRequests(enterpriseCustomer.uuid, userEmail), - ), - queryClient.ensureQueryData( - queryCouponCodeRequests(enterpriseCustomer.uuid, userEmail), - ), - // Content Highlights - queryClient.ensureQueryData( - queryContentHighlightsConfiguration(enterpriseCustomer.uuid), - ), - ]; - if (getConfig().ENABLE_NOTICES) { - enterpriseAppDataQueries.push( - queryClient.ensureQueryData( - queryNotices(), - ), - ); - } - const enterpriseAppData = await Promise.all(enterpriseAppDataQueries); - return enterpriseAppData; -} diff --git a/src/components/app/routes/data/queries/enterpriseCourseEnrollments.js b/src/components/app/routes/data/queries/enterpriseCourseEnrollments.js deleted file mode 100644 index 5f060890f9..0000000000 --- a/src/components/app/routes/data/queries/enterpriseCourseEnrollments.js +++ /dev/null @@ -1,16 +0,0 @@ -import { queries } from '../../../../../utils/queryKeyFactory'; - -/** - * Helper function to assist querying with useQuery package - * queries - * .enterprise - * .enterpriseCustomer(enterpriseUuid) - * ._ctx.enrollments - * @returns - */ -export default function queryEnterpriseCourseEnrollments(enterpriseUuid) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.enrollments; -} diff --git a/src/components/app/routes/data/queries/enterpriseLearner.js b/src/components/app/routes/data/queries/enterpriseLearner.js deleted file mode 100644 index e111f591a2..0000000000 --- a/src/components/app/routes/data/queries/enterpriseLearner.js +++ /dev/null @@ -1,12 +0,0 @@ -import { queries } from '../../../../../utils/queryKeyFactory'; - -/** - * Helper function to assist querying with useQuery package - * queries - * .enterprise - * .enterpriseLearner(username, enterpriseSlug) - * @returns {Types.} - */ -export default function queryEnterpriseLearner(username, enterpriseSlug) { - return queries.enterprise.enterpriseLearner(username, enterpriseSlug); -} diff --git a/src/components/app/routes/data/queries/notices.js b/src/components/app/routes/data/queries/notices.js deleted file mode 100644 index 564e8c75fa..0000000000 --- a/src/components/app/routes/data/queries/notices.js +++ /dev/null @@ -1,12 +0,0 @@ -import { queries } from '../../../../../utils/queryKeyFactory'; - -/** - * Helper function to assist querying with useQuery package - * - * @returns {Types.QueryObject} - The query object for notices. - * @property {[string]} QueryObject.queryKey - The query key for the object - * @property {func} QueryObject.queryFn - The asynchronous API request "fetchNotices" - */ -export default function queryNotices() { - return queries.user.notices; -} diff --git a/src/components/app/routes/data/queries/userEntitlements.js b/src/components/app/routes/data/queries/userEntitlements.js deleted file mode 100644 index b59d24d450..0000000000 --- a/src/components/app/routes/data/queries/userEntitlements.js +++ /dev/null @@ -1,10 +0,0 @@ -import { queries } from '../../../../../utils/queryKeyFactory'; - -/** - * Helper function to assist querying with useQuery package - * queries.user.entitlements - * @returns - */ -export default function queryUserEntitlements() { - return queries.user.entitlements; -} diff --git a/src/components/app/routes/data/utils.js b/src/components/app/routes/data/utils.js index 76a4cd0d4a..66361b7721 100644 --- a/src/components/app/routes/data/utils.js +++ b/src/components/app/routes/data/utils.js @@ -9,16 +9,115 @@ import { import { configure as configureLogging, getLoggingService, - logError, NewRelicLoggingService, } from '@edx/frontend-platform/logging'; -import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { getProxyLoginUrl } from '@edx/frontend-enterprise-logistration'; import Cookies from 'universal-cookie'; -import { getBrandColorsFromCSSVariables } from '../../../../utils/common'; -import { ASSIGNMENT_TYPES } from '../../../enterprise-user-subsidy/enterprise-offers/data/constants'; -import { features } from '../../../../config'; +import { + activateOrAutoApplySubscriptionLicense, + queryBrowseAndRequestConfiguration, + queryContentHighlightsConfiguration, + queryCouponCodeRequests, + queryCouponCodes, + queryEnterpriseLearner, + queryEnterpriseLearnerOffers, + queryLicenseRequests, + queryNotices, + queryRedeemablePolicies, + querySubscriptions, + updateUserActiveEnterprise, +} from '../../data'; + +/** + * TODO + * @param {*} param0 + * @returns + */ +export async function ensureEnterpriseAppData({ + enterpriseCustomer, + userId, + userEmail, + queryClient, + requestUrl, +}) { + const subscriptionsQuery = querySubscriptions(enterpriseCustomer.uuid); + const enterpriseAppDataQueries = [ + // Enterprise Customer User Subsidies + // eslint-disable-next-line arrow-body-style + queryClient.ensureQueryData(subscriptionsQuery).then(async (subscriptionsData) => { + // Auto-activate the user's subscription license, if applicable. + const activatedOrAutoAppliedLicense = await activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer, + subscriptionsData, + requestUrl, + }); + if (activatedOrAutoAppliedLicense) { + const { licensesByStatus } = subscriptionsData; + const updatedLicensesByStatus = { ...licensesByStatus }; + Object.entries(licensesByStatus).forEach(([status, licenses]) => { + const hasActivatedOrAutoAppliedLicense = licenses.some( + (license) => license.uuid === activatedOrAutoAppliedLicense.uuid, + ); + const isCurrentStatusMatchingLicenseStatus = status === activatedOrAutoAppliedLicense.status; + if (hasActivatedOrAutoAppliedLicense) { + updatedLicensesByStatus[status] = isCurrentStatusMatchingLicenseStatus + ? licenses.filter((license) => license.uuid !== activatedOrAutoAppliedLicense.uuid) + : [...licenses, activatedOrAutoAppliedLicense]; + } + }); + // Optimistically update the query cache with the auto-activated subscription license. + queryClient.setQueryData(subscriptionsQuery.queryKey, { + ...subscriptionsData, + licensesByStatus: updatedLicensesByStatus, + subscriptionLicense: activatedOrAutoAppliedLicense, + subscriptionLicenses: subscriptionsData.subscriptionLicenses.map((license) => { + if (license.uuid === activatedOrAutoAppliedLicense.uuid) { + return activatedOrAutoAppliedLicense; + } + return license; + }), + }); + } + + return subscriptionsData; + }), + queryClient.ensureQueryData( + queryRedeemablePolicies({ + enterpriseUuid: enterpriseCustomer.uuid, + lmsUserId: userId, + }), + ), + queryClient.ensureQueryData( + queryCouponCodes(enterpriseCustomer.uuid), + ), + queryClient.ensureQueryData( + queryEnterpriseLearnerOffers(enterpriseCustomer.uuid), + ), + queryClient.ensureQueryData( + queryBrowseAndRequestConfiguration(enterpriseCustomer.uuid), + ), + queryClient.ensureQueryData( + queryLicenseRequests(enterpriseCustomer.uuid, userEmail), + ), + queryClient.ensureQueryData( + queryCouponCodeRequests(enterpriseCustomer.uuid, userEmail), + ), + // Content Highlights + queryClient.ensureQueryData( + queryContentHighlightsConfiguration(enterpriseCustomer.uuid), + ), + ]; + if (getConfig().ENABLE_NOTICES) { + enterpriseAppDataQueries.push( + queryClient.ensureQueryData( + queryNotices(), + ), + ); + } + const enterpriseAppData = await Promise.all(enterpriseAppDataQueries); + return enterpriseAppData; +} /** * Determines whether the user is visiting the dashboard for the first time. @@ -137,49 +236,6 @@ export async function ensureAuthenticatedUser(requestUrl, params) { return authenticatedUser; } -/** - * Transform enterprise customer metadata for use by consuming UI components. - * @param {Object} enterpriseCustomer - * @param {Object} enterpriseFeatures - * @returns - */ -export function transformEnterpriseCustomer(enterpriseCustomer, enterpriseFeatures) { - // If the learner portal is not enabled for the displayed enterprise customer, return null. This - // results in the enterprise learner portal not being accessible for the user, showing a 404 page. - if (!enterpriseCustomer.enableLearnerPortal) { - return null; - } - - // Otherwise, learner portal is enabled, so transform the enterprise customer data. - const disableSearch = !!( - !enterpriseCustomer.enableIntegratedCustomerLearnerPortalSearch - && enterpriseCustomer.identityProvider - ); - const showIntegrationWarning = !!(!disableSearch && enterpriseCustomer.identityProvider); - const brandColors = getBrandColorsFromCSSVariables(); - const defaultPrimaryColor = brandColors.primary; - const defaultSecondaryColor = brandColors.info100; - const defaultTertiaryColor = brandColors.info500; - const { - primaryColor, - secondaryColor, - tertiaryColor, - } = enterpriseCustomer.brandingConfiguration || {}; - - return { - ...enterpriseCustomer, - brandingConfiguration: { - ...enterpriseCustomer.brandingConfiguration, - primaryColor: primaryColor || defaultPrimaryColor, - secondaryColor: secondaryColor || defaultSecondaryColor, - tertiaryColor: tertiaryColor || defaultTertiaryColor, - }, - disableSearch, - showIntegrationWarning, - enterpriseFeatures, - }; -} - /** * TODO * @param {*} enterpriseSlug @@ -187,8 +243,7 @@ export function transformEnterpriseCustomer(enterpriseCustomer, enterpriseFeatur * @param {*} allLinkedEnterpriseCustomerUsers * @param {*} requestUrl * @param {*} queryClient - * @param {*} updateActiveEnterpriseCustomerUser - * @param {*} enterpriseFeatures + * @param {*} updateUserActiveEnterprise * @returns */ export async function ensureActiveEnterpriseCustomerUser({ @@ -197,8 +252,8 @@ export async function ensureActiveEnterpriseCustomerUser({ allLinkedEnterpriseCustomerUsers, requestUrl, queryClient, - updateActiveEnterpriseCustomerUser, - enterpriseFeatures, + username, + // updateUserActiveEnterprise, }) { // If the enterprise slug in the URL matches the active enterprise customer's slug, return early. if (enterpriseSlug === activeEnterpriseCustomer.slug) { @@ -221,12 +276,12 @@ export async function ensureActiveEnterpriseCustomerUser({ enterpriseCustomer: nextActiveEnterpriseCustomer, roleAssignments: nextActiveEnterpriseCustomerRoleAssignments, } = foundEnterpriseCustomerUserForSlug; - const transformedNextActiveEnterpriseCustomer = transformEnterpriseCustomer( - nextActiveEnterpriseCustomer, - enterpriseFeatures, - ); - // Perform POST API request to update the active enterprise customer user. - const nextEnterpriseLearnerQuery = await updateActiveEnterpriseCustomerUser(nextActiveEnterpriseCustomer); + // Makes the POST API request to update the active enterprise customer + // for the learner in the backend for future sessions. + await updateUserActiveEnterprise({ + enterpriseCustomer: nextActiveEnterpriseCustomer, + }); + const nextEnterpriseLearnerQuery = queryEnterpriseLearner(username, nextActiveEnterpriseCustomer.slug); const updatedLinkedEnterpriseCustomerUsers = allLinkedEnterpriseCustomerUsers.map( ecu => ({ ...ecu, @@ -240,283 +295,15 @@ export async function ensureActiveEnterpriseCustomerUser({ // difference is that the query key now contains the new enterprise slug, so we can proactively set the query // cache for with the enterprise learner data we already have before resolving the loader. queryClient.setQueryData(nextEnterpriseLearnerQuery.queryKey, { - enterpriseCustomer: transformedNextActiveEnterpriseCustomer, + enterpriseCustomer: nextActiveEnterpriseCustomer, enterpriseCustomerUserRoleAssignments: nextActiveEnterpriseCustomerRoleAssignments, - activeEnterpriseCustomer: transformedNextActiveEnterpriseCustomer, + activeEnterpriseCustomer: nextActiveEnterpriseCustomer, activeEnterpriseCustomerUserRoleAssignments: nextActiveEnterpriseCustomerRoleAssignments, allLinkedEnterpriseCustomerUsers: updatedLinkedEnterpriseCustomerUsers, }); return { - enterpriseCustomer: transformedNextActiveEnterpriseCustomer, + enterpriseCustomer: nextActiveEnterpriseCustomer, updatedLinkedEnterpriseCustomerUsers, }; } - -/** - * Helper function to determine which linked enterprise customer user record - * should be used for display in the UI. - * @param {*} param0 - * @returns - */ -export function determineEnterpriseCustomerUserForDisplay({ - activeEnterpriseCustomer, - activeEnterpriseCustomerUserRoleAssignments, - enterpriseSlug, - foundEnterpriseCustomerUserForCurrentSlug, -}) { - const activeEnterpriseCustomerUser = { - enterpriseCustomer: activeEnterpriseCustomer, - roleAssignments: activeEnterpriseCustomerUserRoleAssignments, - }; - if (!enterpriseSlug) { - return activeEnterpriseCustomerUser; - } - if (enterpriseSlug !== activeEnterpriseCustomer.slug && foundEnterpriseCustomerUserForCurrentSlug) { - return { - enterpriseCustomer: foundEnterpriseCustomerUserForCurrentSlug.enterpriseCustomer, - roleAssignments: foundEnterpriseCustomerUserForCurrentSlug.roleAssignments, - }; - } - return activeEnterpriseCustomerUser; -} - -/** - * Transforms the redeemable policies data by attaching the subsidy expiration date - * to each assignment within the policies, if available. - * @param {object[]} [policies] - Array of policy objects containing learner assignments. - * @returns {object} - Returns modified policies data with subsidy expiration dates attached to assignments. - */ -export function transformRedeemablePoliciesData(policies = []) { - return policies.map((policy) => { - const assignmentsWithSubsidyExpiration = policy.learnerContentAssignments?.map(assignment => ({ - ...assignment, - subsidyExpirationDate: policy.subsidyExpirationDate, - })); - return { - ...policy, - learnerContentAssignments: assignmentsWithSubsidyExpiration, - }; - }); -} - -/** - * Takes a flattened array of assignments and returns an object containing - * lists of assignments for each assignment state. - * - * @param {Array} assignments - List of content assignments. - * @returns {{ -* assignments: Array, -* hasAssignments: Boolean, -* allocatedAssignments: Array, -* hasAllocatedAssignments: Boolean, -* canceledAssignments: Array, -* hasCanceledAssignments: Boolean, -* acceptedAssignments: Array, -* hasAcceptedAssignments: Boolean, -* }} -*/ -export function getAssignmentsByState(assignments = []) { - const allocatedAssignments = []; - const acceptedAssignments = []; - const canceledAssignments = []; - const expiredAssignments = []; - const erroredAssignments = []; - const assignmentsForDisplay = []; - - assignments.forEach((assignment) => { - switch (assignment.state) { - case ASSIGNMENT_TYPES.ALLOCATED: - allocatedAssignments.push(assignment); - break; - case ASSIGNMENT_TYPES.ACCEPTED: - acceptedAssignments.push(assignment); - break; - case ASSIGNMENT_TYPES.CANCELED: - canceledAssignments.push(assignment); - break; - case ASSIGNMENT_TYPES.EXPIRED: - expiredAssignments.push(assignment); - break; - case ASSIGNMENT_TYPES.ERRORED: - erroredAssignments.push(assignment); - break; - default: - logError(`[getAssignmentsByState] Unsupported state ${assignment.state} for assignment ${assignment.uuid}`); - break; - } - }); - - const hasAssignments = assignments.length > 0; - const hasAllocatedAssignments = allocatedAssignments.length > 0; - const hasAcceptedAssignments = acceptedAssignments.length > 0; - const hasCanceledAssignments = canceledAssignments.length > 0; - const hasExpiredAssignments = expiredAssignments.length > 0; - const hasErroredAssignments = erroredAssignments.length > 0; - - // Concatenate all assignments for display (includes allocated and canceled assignments) - assignmentsForDisplay.push(...allocatedAssignments); - assignmentsForDisplay.push(...canceledAssignments); - assignmentsForDisplay.push(...expiredAssignments); - const hasAssignmentsForDisplay = assignmentsForDisplay.length > 0; - - return { - assignments, - hasAssignments, - allocatedAssignments, - hasAllocatedAssignments, - acceptedAssignments, - hasAcceptedAssignments, - canceledAssignments, - hasCanceledAssignments, - expiredAssignments, - hasExpiredAssignments, - erroredAssignments, - hasErroredAssignments, - assignmentsForDisplay, - hasAssignmentsForDisplay, - }; -} - -export function redirectToDashboardAfterLicenseActivation({ - shouldRedirect, - enterpriseCustomer, -}) { - // Redirect to the enterprise learner portal dashboard page when user - // is on the license activation page. Otherwise, let the user stay on - // the current page route. - if (shouldRedirect) { - throw redirect(generatePath('/:enterpriseSlug', { enterpriseSlug: enterpriseCustomer.slug })); - } -} - -/** - * TODO - * @param {*} param0 - * @returns - */ -export async function activateSubscriptionLicense({ - enterpriseCustomer, - subscriptionLicenseToActivate, - requestUrl, - activateAllocatedSubscriptionLicense, -}) { - // Activate the user's assigned subscription license. - const licenseActivationRouteMatch = matchPath('/:enterpriseSlug/licenses/:activationKey/activate', requestUrl.pathname); - try { - await activateAllocatedSubscriptionLicense(subscriptionLicenseToActivate); - } catch (error) { - logError(error); - redirectToDashboardAfterLicenseActivation({ - enterpriseCustomer, - shouldRedirect: licenseActivationRouteMatch, - }); - return; - } - sendEnterpriseTrackEvent( - enterpriseCustomer.uuid, - 'edx.ui.enterprise.learner_portal.license-activation.license-activated', - { - // `autoActivated` is true if the user is on a page route *other* than the license activation route. - autoActivated: !licenseActivationRouteMatch, - }, - ); - redirectToDashboardAfterLicenseActivation({ - enterpriseCustomer, - shouldRedirect: licenseActivationRouteMatch, - }); -} - -/** - * TODO - * @param {*} param0 - */ -export async function getAutoAppliedSubscriptionLicense({ - subscriptionsData, - enterpriseCustomer, - requestAutoAppliedSubscriptionLicense, -}) { - // If the feature flag for auto-applied licenses is not enabled, return early. - if (!features.ENABLE_AUTO_APPLIED_LICENSES) { - return; - } - - const { customerAgreement } = subscriptionsData; - const hasSubscriptionForAutoAppliedLicenses = ( - !!customerAgreement.subscriptionForAutoAppliedLicenses - && customerAgreement.netDaysUntilExpiration > 0 - ); - const hasIdentityProvider = enterpriseCustomer.identityProvider; - - // If customer agreement has no configured subscription plan for auto-applied - // licenses, or the enterprise customer does not have an identity provider, - // return early. - if (!hasSubscriptionForAutoAppliedLicenses || !hasIdentityProvider) { - return; - } - - try { - await requestAutoAppliedSubscriptionLicense(customerAgreement); - } catch (error) { - logError(error); - } -} - -/** - * TODO - * @param {*} enterpriseCustomer - * @param {*} subscriptionsData - * @param {*} queryClient - * @param {*} subscriptionsQuery - * @param {*} requestUrl - * @returns - */ -export async function activateOrAutoApplySubscriptionLicense({ - enterpriseCustomer, - subscriptionsData, - requestUrl, - activateAllocatedSubscriptionLicense, - requestAutoAppliedSubscriptionLicense, -}) { - const { - customerAgreement, - subscriptionLicenses, - } = subscriptionsData; - if (!customerAgreement || customerAgreement.netDaysUntilExpiration <= 0) { - return; - } - - // Filter subscription licenses to only be those associated with - // subscription plans that are active and current. - const currentSubscriptionLicenses = subscriptionLicenses.filter((license) => { - const { subscriptionPlan } = license; - const { isActive, daysUntilExpiration } = subscriptionPlan; - const isCurrent = daysUntilExpiration > 0; - return isActive && isCurrent; - }); - - // Check if learner already has activated license. If so, return early. - const activatedSubscriptionLicense = currentSubscriptionLicenses.find((license) => license.status === 'activated'); - if (activatedSubscriptionLicense) { - return; - } - - // Otherwise, check if there is an assigned subscription - // license to activate OR if the user should request an - // auto-applied subscription license. - const subscriptionLicenseToActivate = subscriptionLicenses.find((license) => license.status === 'assigned'); - if (subscriptionLicenseToActivate) { - await activateSubscriptionLicense({ - enterpriseCustomer, - subscriptionLicenseToActivate, - requestUrl, - activateAllocatedSubscriptionLicense, - }); - } else { - await getAutoAppliedSubscriptionLicense({ - enterpriseCustomer, - subscriptionsData, - requestAutoAppliedSubscriptionLicense, - }); - } -} diff --git a/src/components/app/routes/data/tests/utils.test.js b/src/components/app/routes/data/utils.test.js similarity index 97% rename from src/components/app/routes/data/tests/utils.test.js rename to src/components/app/routes/data/utils.test.js index 078f6e0ea9..71a6a9c515 100644 --- a/src/components/app/routes/data/tests/utils.test.js +++ b/src/components/app/routes/data/utils.test.js @@ -1,4 +1,4 @@ -import { transformEnterpriseCustomer } from '../utils'; +import { transformEnterpriseCustomer } from '../../data'; const mockEnterpriseFeatures = { 'example-feature': true, diff --git a/src/components/app/routes/loaders/courseLoader.js b/src/components/app/routes/loaders/courseLoader.js index d96b451607..beca2c8092 100644 --- a/src/components/app/routes/loaders/courseLoader.js +++ b/src/components/app/routes/loaders/courseLoader.js @@ -3,8 +3,9 @@ import { queryCanRedeem, queryCourseMetadata, queryEnterpriseCourseEnrollments, -} from '../data/queries'; -import { ensureAuthenticatedUser, extractEnterpriseId } from '../data'; + extractEnterpriseId, +} from '../../data'; +import { ensureAuthenticatedUser } from '../data'; /** * Course loader for the course related page routes. diff --git a/src/components/app/routes/loaders/dashboardLoader.js b/src/components/app/routes/loaders/dashboardLoader.js index f8846e4c9b..6f5028230c 100644 --- a/src/components/app/routes/loaders/dashboardLoader.js +++ b/src/components/app/routes/loaders/dashboardLoader.js @@ -1,8 +1,5 @@ -import { - ensureAuthenticatedUser, - extractEnterpriseId, - queryEnterpriseCourseEnrollments, -} from '../data'; +import { ensureAuthenticatedUser } from '../data'; +import { extractEnterpriseId, queryEnterpriseCourseEnrollments } from '../../data'; /** * Returns a loader function responsible for loading the dashboard related data. diff --git a/src/components/app/routes/loaders/rootLoader.js b/src/components/app/routes/loaders/rootLoader.js index 7b608770f8..bbab7824ad 100644 --- a/src/components/app/routes/loaders/rootLoader.js +++ b/src/components/app/routes/loaders/rootLoader.js @@ -1,11 +1,10 @@ +import { queryEnterpriseLearner } from '../../data'; import { ensureAuthenticatedUser, + ensureEnterpriseAppData, redirectToRemoveTrailingSlash, redirectToSearchPageForNewUser, - ensureEnterpriseAppData, - queryEnterpriseLearner, ensureActiveEnterpriseCustomerUser, - updateUserActiveEnterprise, } from '../data'; /** @@ -33,7 +32,6 @@ export default function makeRootLoader(queryClient) { activeEnterpriseCustomer, allLinkedEnterpriseCustomerUsers, } = enterpriseLearnerData; - const { enterpriseFeatures } = enterpriseLearnerData; // User has no active, linked enterprise customer; return early. if (!activeEnterpriseCustomer) { @@ -49,15 +47,6 @@ export default function makeRootLoader(queryClient) { queryClient, username, requestUrl, - enterpriseFeatures, - async updateActiveEnterpriseCustomerUser(nextActiveEnterpriseCustomer) { - // Makes the POST API request to update the active enterprise customer - // for the learner in the backend for future sessions. - await updateUserActiveEnterprise({ - enterpriseCustomer: nextActiveEnterpriseCustomer, - }); - return queryEnterpriseLearner(username, nextActiveEnterpriseCustomer.slug); - }, }); // If the active enterprise customer user was updated, override the previous active // enterprise customer user data with the new active enterprise customer user data diff --git a/src/components/app/routes/loaders/tests/courseLoader.test.jsx b/src/components/app/routes/loaders/tests/courseLoader.test.jsx index e17baef435..2825a59408 100644 --- a/src/components/app/routes/loaders/tests/courseLoader.test.jsx +++ b/src/components/app/routes/loaders/tests/courseLoader.test.jsx @@ -4,17 +4,20 @@ import '@testing-library/jest-dom/extend-expect'; import { renderWithRouterProvider } from '../../../../../utils/tests'; import makeCourseLoader from '../courseLoader'; -import { extractEnterpriseId } from '../../data'; import { + extractEnterpriseId, queryCanRedeem, queryCourseMetadata, queryEnterpriseCourseEnrollments, queryUserEntitlements, -} from '../../data/queries'; +} from '../../../data'; jest.mock('../../data', () => ({ ...jest.requireActual('../../data'), ensureAuthenticatedUser: jest.fn().mockResolvedValue({ userId: 3 }), +})); +jest.mock('../../../data', () => ({ + ...jest.requireActual('../../../data'), extractEnterpriseId: jest.fn(), })); diff --git a/src/components/app/routes/loaders/tests/dashboardLoader.test.jsx b/src/components/app/routes/loaders/tests/dashboardLoader.test.jsx index f3ba7ea13b..6b6566d9e7 100644 --- a/src/components/app/routes/loaders/tests/dashboardLoader.test.jsx +++ b/src/components/app/routes/loaders/tests/dashboardLoader.test.jsx @@ -3,11 +3,14 @@ import '@testing-library/jest-dom/extend-expect'; import { renderWithRouterProvider } from '../../../../../utils/tests'; import makeDashboardLoader from '../dashboardLoader'; -import { extractEnterpriseId, queryEnterpriseCourseEnrollments } from '../../data'; +import { extractEnterpriseId, queryEnterpriseCourseEnrollments } from '../../../data'; jest.mock('../../data', () => ({ ...jest.requireActual('../../data'), ensureAuthenticatedUser: jest.fn().mockResolvedValue({ userId: 3 }), +})); +jest.mock('../../../data', () => ({ + ...jest.requireActual('../../../data'), extractEnterpriseId: jest.fn(), })); diff --git a/src/components/app/routes/loaders/tests/rootLoader.test.jsx b/src/components/app/routes/loaders/tests/rootLoader.test.jsx index 166aef84b2..378218ac8c 100644 --- a/src/components/app/routes/loaders/tests/rootLoader.test.jsx +++ b/src/components/app/routes/loaders/tests/rootLoader.test.jsx @@ -6,8 +6,8 @@ import '@testing-library/jest-dom/extend-expect'; import { renderWithRouterProvider } from '../../../../../utils/tests'; import makeRootLoader from '../rootLoader'; +import { ensureAuthenticatedUser } from '../../data'; import { - ensureAuthenticatedUser, extractEnterpriseId, queryBrowseAndRequestConfiguration, queryContentHighlightsConfiguration, @@ -18,11 +18,14 @@ import { queryLicenseRequests, queryRedeemablePolicies, querySubscriptions, -} from '../../data'; +} from '../../../data'; jest.mock('../../data', () => ({ ...jest.requireActual('../../data'), ensureAuthenticatedUser: jest.fn(), +})); +jest.mock('../../../data', () => ({ + ...jest.requireActual('../../../data'), extractEnterpriseId: jest.fn(), })); diff --git a/src/components/enterprise-user-subsidy/data/constants.js b/src/components/enterprise-user-subsidy/data/constants.js index 8b1364a862..b03a3b2f99 100644 --- a/src/components/enterprise-user-subsidy/data/constants.js +++ b/src/components/enterprise-user-subsidy/data/constants.js @@ -2,6 +2,7 @@ export const LICENSE_STATUS = { ACTIVATED: 'activated', ASSIGNED: 'assigned', REVOKED: 'revoked', + UNASSIGNED: 'unassigned', }; export const LOADING_SCREEN_READER_TEXT = 'loading your edX benefits from your organization'; From c83dc08bd8d2feebfb07ca5ad914c878e62a2b18 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Wed, 6 Mar 2024 16:07:13 -0500 Subject: [PATCH 12/16] chore: move all queries to queries.js --- src/components/app/data/queries/index.js | 1 - src/components/app/data/queries/queries.js | 132 ++++++++++++++++++ .../queries/subsidies/browseAndRequest.js | 63 --------- .../app/data/queries/subsidies/couponCodes.js | 18 --- .../queries/subsidies/enterpriseOffers.js | 18 --- .../app/data/queries/subsidies/index.js | 6 - .../app/data/queries/subsidies/policies.js | 20 --- .../data/queries/subsidies/subscriptions.js | 18 --- src/components/app/data/services.js | 84 +++++------ 9 files changed, 175 insertions(+), 185 deletions(-) delete mode 100644 src/components/app/data/queries/subsidies/browseAndRequest.js delete mode 100644 src/components/app/data/queries/subsidies/couponCodes.js delete mode 100644 src/components/app/data/queries/subsidies/enterpriseOffers.js delete mode 100644 src/components/app/data/queries/subsidies/index.js delete mode 100644 src/components/app/data/queries/subsidies/policies.js delete mode 100644 src/components/app/data/queries/subsidies/subscriptions.js diff --git a/src/components/app/data/queries/index.js b/src/components/app/data/queries/index.js index c510e55eec..1e67643087 100644 --- a/src/components/app/data/queries/index.js +++ b/src/components/app/data/queries/index.js @@ -1,6 +1,5 @@ import { queryEnterpriseLearner } from './queries'; -export * from './subsidies'; export * from './queries'; /** diff --git a/src/components/app/data/queries/queries.js b/src/components/app/data/queries/queries.js index d89ace1b2d..5e668c2f4a 100644 --- a/src/components/app/data/queries/queries.js +++ b/src/components/app/data/queries/queries.js @@ -1,4 +1,5 @@ import { getAvailableCourseRuns } from '../../../course/data/utils'; +import { SUBSIDY_REQUEST_STATE } from '../../../enterprise-subsidy-requests'; import queries from './queryKeyFactory'; /** @@ -98,3 +99,134 @@ export function queryCanRedeem(enterpriseUuid, courseMetadata) { ._ctx.course ._ctx.canRedeem(availableCourseRunKeys); } + +/** + * Helper function to assist querying with useQuery package + * queries + * .enterprise + * .enterpriseCustomer(enterpriseUuid) + * ._ctx.subsidies + * ._ctx.subscriptions + * @returns + */ +export function querySubscriptions(enterpriseUuid) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.subsidies + ._ctx.subscriptions; +} + +/** + * Helper function to assist querying with useQuery package + * queries + * .enterprise + * .enterpriseCustomer(enterpriseUuid) + * ._ctx.subsidies + * ._ctx.policy + * ._ctx.redeemablePolicies(lmsUserId) + * @returns + */ +export function queryRedeemablePolicies({ enterpriseUuid, lmsUserId }) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.subsidies + ._ctx.policy + ._ctx.redeemablePolicies(lmsUserId); +} + +/** + * Helper function to assist querying with useQuery package + * queries + * .enterprise + * .enterpriseCustomer(enterpriseUuid) + * ._ctx.subsidies + * ._ctx.enterpriseOffers + * @returns + */ +export function queryEnterpriseLearnerOffers(enterpriseUuid) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.subsidies + ._ctx.enterpriseOffers; +} + +/** + * Helper function to assist querying with useQuery package + * queries + * .enterprise + * .enterpriseCustomer(enterpriseUuid) + * ._ctx.subsidies + * ._ctx.couponCodes + * @returns + */ +export function queryCouponCodes(enterpriseUuid) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.subsidies + ._ctx.couponCodes; +} + +/** + * Helper function to assist querying with useQuery package. + * + * @param {string} enterpriseUuid - The UUID of the enterprise. + * @param {string} userEmail - The email of the user. + * @returns {QueryObject} - The query object for the enterprise configuration. + * @property {[string]} QueryObject.queryKey - The query key for the object + * @property {func} QueryObject.queryFn - The asynchronous API request "fetchBrowseAndRequestConfiguration" + */ +export function queryBrowseAndRequestConfiguration(enterpriseUuid) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.subsidies + ._ctx.browseAndRequest + ._ctx.configuration; +} + +/** + * Helper function to assist querying with useQuery package + * queries + * .enterprise + * .enterpriseCustomer(enterpriseUuid) + * ._ctx.subsidies + * ._ctx.browseAndRequest(userEmail) + * ._ctx.requests(state) + * ._ctx.licenseRequests + * @returns + */ +export function queryLicenseRequests(enterpriseUuid, userEmail, state = SUBSIDY_REQUEST_STATE.REQUESTED) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.subsidies + ._ctx.browseAndRequest + ._ctx.requests(userEmail, state) + ._ctx.licenseRequests; +} + +/** + * Helper function to assist querying with useQuery package + * + * queries + * .enterprise + * .enterpriseCustomer(enterpriseUuid) + * ._ctx.subsidies + * ._ctx.browseAndRequest(userEmail) + * ._ctx.requests(state) + * ._ctx.couponCodeRequests + * @returns + */ +export function queryCouponCodeRequests(enterpriseUuid, userEmail, state = SUBSIDY_REQUEST_STATE.REQUESTED) { + return queries + .enterprise + .enterpriseCustomer(enterpriseUuid) + ._ctx.subsidies + ._ctx.browseAndRequest + ._ctx.requests(userEmail, state) + ._ctx.couponCodeRequests; +} diff --git a/src/components/app/data/queries/subsidies/browseAndRequest.js b/src/components/app/data/queries/subsidies/browseAndRequest.js deleted file mode 100644 index 97e783c454..0000000000 --- a/src/components/app/data/queries/subsidies/browseAndRequest.js +++ /dev/null @@ -1,63 +0,0 @@ -import queries from '../queryKeyFactory'; -import { SUBSIDY_REQUEST_STATE } from '../../../../enterprise-subsidy-requests'; - -/** - * Helper function to assist querying with useQuery package. - * - * @param {string} enterpriseUuid - The UUID of the enterprise. - * @param {string} userEmail - The email of the user. - * @returns {QueryObject} - The query object for the enterprise configuration. - * @property {[string]} QueryObject.queryKey - The query key for the object - * @property {func} QueryObject.queryFn - The asynchronous API request "fetchBrowseAndRequestConfiguration" - */ -export function queryBrowseAndRequestConfiguration(enterpriseUuid) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.subsidies - ._ctx.browseAndRequest - ._ctx.configuration; -} - -/** - * Helper function to assist querying with useQuery package - * queries - * .enterprise - * .enterpriseCustomer(enterpriseUuid) - * ._ctx.subsidies - * ._ctx.browseAndRequest(userEmail) - * ._ctx.requests(state) - * ._ctx.licenseRequests - * @returns - */ -export function queryLicenseRequests(enterpriseUuid, userEmail, state = SUBSIDY_REQUEST_STATE.REQUESTED) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.subsidies - ._ctx.browseAndRequest - ._ctx.requests(userEmail, state) - ._ctx.licenseRequests; -} - -/** - * Helper function to assist querying with useQuery package - * - * queries - * .enterprise - * .enterpriseCustomer(enterpriseUuid) - * ._ctx.subsidies - * ._ctx.browseAndRequest(userEmail) - * ._ctx.requests(state) - * ._ctx.couponCodeRequests - * @returns - */ -export function queryCouponCodeRequests(enterpriseUuid, userEmail, state = SUBSIDY_REQUEST_STATE.REQUESTED) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.subsidies - ._ctx.browseAndRequest - ._ctx.requests(userEmail, state) - ._ctx.couponCodeRequests; -} diff --git a/src/components/app/data/queries/subsidies/couponCodes.js b/src/components/app/data/queries/subsidies/couponCodes.js deleted file mode 100644 index 5e7c76873c..0000000000 --- a/src/components/app/data/queries/subsidies/couponCodes.js +++ /dev/null @@ -1,18 +0,0 @@ -import queries from '../queryKeyFactory'; - -/** - * Helper function to assist querying with useQuery package - * queries - * .enterprise - * .enterpriseCustomer(enterpriseUuid) - * ._ctx.subsidies - * ._ctx.couponCodes - * @returns - */ -export default function queryCouponCodes(enterpriseUuid) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.subsidies - ._ctx.couponCodes; -} diff --git a/src/components/app/data/queries/subsidies/enterpriseOffers.js b/src/components/app/data/queries/subsidies/enterpriseOffers.js deleted file mode 100644 index 0716e81ecd..0000000000 --- a/src/components/app/data/queries/subsidies/enterpriseOffers.js +++ /dev/null @@ -1,18 +0,0 @@ -import queries from '../queryKeyFactory'; - -/** - * Helper function to assist querying with useQuery package - * queries - * .enterprise - * .enterpriseCustomer(enterpriseUuid) - * ._ctx.subsidies - * ._ctx.enterpriseOffers - * @returns - */ -export default function queryEnterpriseLearnerOffers(enterpriseUuid) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.subsidies - ._ctx.enterpriseOffers; -} diff --git a/src/components/app/data/queries/subsidies/index.js b/src/components/app/data/queries/subsidies/index.js deleted file mode 100644 index 1559111d3d..0000000000 --- a/src/components/app/data/queries/subsidies/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default as queryCouponCodes } from './couponCodes'; -export { default as queryEnterpriseLearnerOffers } from './enterpriseOffers'; -export { default as queryRedeemablePolicies } from './policies'; -export { default as querySubscriptions } from './subscriptions'; - -export * from './browseAndRequest'; diff --git a/src/components/app/data/queries/subsidies/policies.js b/src/components/app/data/queries/subsidies/policies.js deleted file mode 100644 index 5593a9422b..0000000000 --- a/src/components/app/data/queries/subsidies/policies.js +++ /dev/null @@ -1,20 +0,0 @@ -import queries from '../queryKeyFactory'; - -/** - * Helper function to assist querying with useQuery package - * queries - * .enterprise - * .enterpriseCustomer(enterpriseUuid) - * ._ctx.subsidies - * ._ctx.policy - * ._ctx.redeemablePolicies(lmsUserId) - * @returns - */ -export default function queryRedeemablePolicies({ enterpriseUuid, lmsUserId }) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.subsidies - ._ctx.policy - ._ctx.redeemablePolicies(lmsUserId); -} diff --git a/src/components/app/data/queries/subsidies/subscriptions.js b/src/components/app/data/queries/subsidies/subscriptions.js deleted file mode 100644 index 85893db372..0000000000 --- a/src/components/app/data/queries/subsidies/subscriptions.js +++ /dev/null @@ -1,18 +0,0 @@ -import queries from '../queryKeyFactory'; - -/** - * Helper function to assist querying with useQuery package - * queries - * .enterprise - * .enterpriseCustomer(enterpriseUuid) - * ._ctx.subsidies - * ._ctx.subscriptions - * @returns - */ -export default function querySubscriptions(enterpriseUuid) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.subsidies - ._ctx.subscriptions; -} diff --git a/src/components/app/data/services.js b/src/components/app/data/services.js index 0f54da0309..a9012ff96c 100644 --- a/src/components/app/data/services.js +++ b/src/components/app/data/services.js @@ -20,8 +20,6 @@ import { import { LICENSE_STATUS } from '../../enterprise-user-subsidy/data/constants'; import { features } from '../../../config'; -// import { queryEnterpriseLearner } from './queries'; - // Enterprise Course Enrollments /** @@ -51,22 +49,22 @@ export async function updateUserActiveEnterprise({ enterpriseCustomer }) { } /** - * Recursive function to fetch all linked enterprise customer users, traversing paginated results. + * Recursive function to fetch all results, traversing a paginated API response. * @param {string} url Request URL - * @param {Array} [linkedEnterprises] Array of linked enterprise customer users - * @returns Array of all linked enterprise customer users for authenticated user. + * @param {Array} [results] Array of results. + * @returns Array of all results for authenticated user. */ -async function fetchData(url, linkedEnterprises = []) { +async function fetchPaginatedData(url, results = []) { const response = await getAuthenticatedHttpClient().get(url); const responseData = camelCaseObject(response.data); - const linkedEnterprisesCopy = [...linkedEnterprises]; - linkedEnterprisesCopy.push(...responseData.results); + const resultsCopy = [...results]; + resultsCopy.push(...responseData.results); if (responseData.next) { - return fetchData(responseData.next, linkedEnterprisesCopy); + return fetchPaginatedData(responseData.next, resultsCopy); } return { - results: linkedEnterprisesCopy, - enterpriseFeatures: responseData.enterpriseFeatures, + results: resultsCopy, + response: responseData, }; } @@ -90,8 +88,9 @@ export async function fetchEnterpriseLearnerData(username, enterpriseSlug, optio const url = `${enterpriseLearnerUrl}?${queryParams.toString()}`; const { results: enterpriseCustomersUsers, - enterpriseFeatures, - } = await fetchData(url); + response: enterpriseCustomerUsersResponse, + } = await fetchPaginatedData(url); + const { enterpriseFeatures } = enterpriseCustomerUsersResponse; // Transform enterprise customer user results const transformedEnterpriseCustomersUsers = enterpriseCustomersUsers.map( @@ -131,6 +130,7 @@ export async function fetchEnterpriseLearnerData(username, enterpriseSlug, optio } // Course Enrollments + /** * TODO * @param {*} enterpriseId @@ -147,7 +147,8 @@ export async function fetchEnterpriseCourseEnrollments(enterpriseId, options = { return camelCaseObject(response.data); } -// Course Metadata +// Course + /** * TODO * @param {*} param0 @@ -171,7 +172,34 @@ export async function fetchCourseMetadata(enterpriseId, courseKey, options = {}) } } +/** + * Service method to determine whether the authenticated user can redeem the specified course run(s). + * + * @param {object} args + * @param {array} courseRunKeys List of course run keys. + * @returns Promise for get request from the authenticated http client. + */ +export async function fetchCanRedeem(enterpriseId, courseRunKeys) { + const queryParams = new URLSearchParams(); + courseRunKeys.forEach((courseRunKey) => { + queryParams.append('content_key', courseRunKey); + }); + const url = `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/policy-redemption/enterprise-customer/${enterpriseId}/can-redeem/`; + const urlWithParams = `${url}?${queryParams.toString()}`; + try { + const response = await getAuthenticatedHttpClient().get(urlWithParams); + return camelCaseObject(response.data); + } catch (error) { + const errorResponseStatusCode = getErrorResponseStatusCode(error); + if (errorResponseStatusCode === 404) { + return []; + } + throw error; + } +} + // Content Highlights + /** * Content Highlights Configuration * @param {*} enterpriseUUID @@ -198,36 +226,10 @@ export async function fetchEnterpriseCuration(enterpriseUUID, options = {}) { } } -// Can Redeem -/** - * Service method to determine whether the authenticated user can redeem the specified course run(s). - * - * @param {object} args - * @param {array} courseRunKeys List of course run keys. - * @returns Promise for get request from the authenticated http client. - */ -export async function fetchCanRedeem(enterpriseId, courseRunKeys) { - const queryParams = new URLSearchParams(); - courseRunKeys.forEach((courseRunKey) => { - queryParams.append('content_key', courseRunKey); - }); - const url = `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/policy-redemption/enterprise-customer/${enterpriseId}/can-redeem/`; - const urlWithParams = `${url}?${queryParams.toString()}`; - try { - const response = await getAuthenticatedHttpClient().get(urlWithParams); - return camelCaseObject(response.data); - } catch (error) { - const errorResponseStatusCode = getErrorResponseStatusCode(error); - if (errorResponseStatusCode === 404) { - return []; - } - throw error; - } -} - // Subsidies // Browse and Request + /** * TODO * @param {*} enterpriseUUID From 65e1f7849bc60dfc4a353caab298a8696aff3086 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Thu, 7 Mar 2024 01:40:00 -0500 Subject: [PATCH 13/16] chore: tests --- .../app/data/queries/extractEnterpriseId.js | 46 ++ src/components/app/data/queries/index.js | 46 +- src/components/app/data/services.js | 600 ------------------ src/components/app/data/services.test.js | 55 -- .../app/data/services/contentHighlights.js | 30 + .../data/services/contentHighlights.test.js | 57 ++ src/components/app/data/services/course.js | 52 ++ .../app/data/services/course.test.js | 75 +++ .../data/services/enterpriseCustomerUser.js | 96 +++ .../services/enterpriseCustomerUser.test.js | 116 ++++ src/components/app/data/services/index.js | 6 + .../services/subsidies/browseAndRequest.js | 70 ++ .../subsidies/browseAndRequest.test.js | 76 +++ .../data/services/subsidies/couponCodes.js | 56 ++ .../services/subsidies/couponCodes.test.js | 82 +++ .../app/data/services/subsidies/index.js | 55 ++ .../app/data/services/subsidies/index.test.js | 102 +++ .../data/services/subsidies/subscriptions.js | 206 ++++++ .../services/subsidies/subscriptions.test.js | 322 ++++++++++ src/components/app/data/services/user.js | 44 ++ src/components/app/data/services/user.test.js | 84 +++ src/components/app/data/services/utils.js | 22 + .../app/data/services/utils.test.js | 85 +++ src/components/app/data/utils.js | 6 +- src/components/app/routes/data/utils.test.js | 9 +- 25 files changed, 1688 insertions(+), 710 deletions(-) create mode 100644 src/components/app/data/queries/extractEnterpriseId.js delete mode 100644 src/components/app/data/services.js delete mode 100644 src/components/app/data/services.test.js create mode 100644 src/components/app/data/services/contentHighlights.js create mode 100644 src/components/app/data/services/contentHighlights.test.js create mode 100644 src/components/app/data/services/course.js create mode 100644 src/components/app/data/services/course.test.js create mode 100644 src/components/app/data/services/enterpriseCustomerUser.js create mode 100644 src/components/app/data/services/enterpriseCustomerUser.test.js create mode 100644 src/components/app/data/services/index.js create mode 100644 src/components/app/data/services/subsidies/browseAndRequest.js create mode 100644 src/components/app/data/services/subsidies/browseAndRequest.test.js create mode 100644 src/components/app/data/services/subsidies/couponCodes.js create mode 100644 src/components/app/data/services/subsidies/couponCodes.test.js create mode 100644 src/components/app/data/services/subsidies/index.js create mode 100644 src/components/app/data/services/subsidies/index.test.js create mode 100644 src/components/app/data/services/subsidies/subscriptions.js create mode 100644 src/components/app/data/services/subsidies/subscriptions.test.js create mode 100644 src/components/app/data/services/user.js create mode 100644 src/components/app/data/services/user.test.js create mode 100644 src/components/app/data/services/utils.js create mode 100644 src/components/app/data/services/utils.test.js diff --git a/src/components/app/data/queries/extractEnterpriseId.js b/src/components/app/data/queries/extractEnterpriseId.js new file mode 100644 index 0000000000..b88b07e697 --- /dev/null +++ b/src/components/app/data/queries/extractEnterpriseId.js @@ -0,0 +1,46 @@ +import { queryEnterpriseLearner } from './queries'; + +/** + * Extracts the appropriate enterprise ID for the current user and enterprise slug. + * @param {Object} params - The parameters object. + * @param {Object} params.queryClient - The query client. + * @param {Object} params.authenticatedUser - The authenticated user. + * @param {string} params.enterpriseSlug - The enterprise slug. + * @returns {Promise} - The enterprise ID to use for subsquent queries in route loaders. + */ +async function extractEnterpriseId({ + queryClient, + authenticatedUser, + enterpriseSlug, +}) { + // Retrieve linked enterprise customers for the current user from query cache, or + // fetch from the server if not available. + const linkedEnterpriseCustomersQuery = queryEnterpriseLearner(authenticatedUser.username, enterpriseSlug); + const enterpriseLearnerData = await queryClient.ensureQueryData(linkedEnterpriseCustomersQuery); + const { + activeEnterpriseCustomer, + allLinkedEnterpriseCustomerUsers, + } = enterpriseLearnerData; + + // If there is no slug provided (i.e., on the root page route `/`), use + // the currently active enterprise customer. + if (!enterpriseSlug) { + return activeEnterpriseCustomer.uuid; + } + + // Otherwise, there is a slug provided for a specific enterprise customer. If the + // enterprise customer for the given slug is associated to one linked to the learner, + // return the enterprise ID for that enterprise customer. + const foundEnterpriseIdForSlug = allLinkedEnterpriseCustomerUsers.find( + (enterpriseCustomerUser) => enterpriseCustomerUser.enterpriseCustomer.slug === enterpriseSlug, + )?.enterpriseCustomer.uuid; + + if (foundEnterpriseIdForSlug) { + return foundEnterpriseIdForSlug; + } + + // If no enterprise customer is found for the given user/slug, throw an error. + throw new Error(`Could not find enterprise customer for user ${authenticatedUser.userId} and slug ${enterpriseSlug}`); +} + +export default extractEnterpriseId; diff --git a/src/components/app/data/queries/index.js b/src/components/app/data/queries/index.js index 1e67643087..f25d0683a2 100644 --- a/src/components/app/data/queries/index.js +++ b/src/components/app/data/queries/index.js @@ -1,46 +1,4 @@ -import { queryEnterpriseLearner } from './queries'; +export { default as extractEnterpriseId } from './extractEnterpriseId'; +export { default as queries } from './queryKeyFactory'; export * from './queries'; - -/** - * Extracts the appropriate enterprise ID for the current user and enterprise slug. - * @param {Object} params - The parameters object. - * @param {Object} params.queryClient - The query client. - * @param {Object} params.authenticatedUser - The authenticated user. - * @param {string} params.enterpriseSlug - The enterprise slug. - * @returns {Promise} - The enterprise ID to use for subsquent queries in route loaders. - */ -export async function extractEnterpriseId({ - queryClient, - authenticatedUser, - enterpriseSlug, -}) { - // Retrieve linked enterprise customers for the current user from query cache, or - // fetch from the server if not available. - const linkedEnterpriseCustomersQuery = queryEnterpriseLearner(authenticatedUser.username, enterpriseSlug); - const enterpriseLearnerData = await queryClient.ensureQueryData(linkedEnterpriseCustomersQuery); - const { - activeEnterpriseCustomer, - allLinkedEnterpriseCustomerUsers, - } = enterpriseLearnerData; - - // If there is no slug provided (i.e., on the root page route `/`), use - // the currently active enterprise customer. - if (!enterpriseSlug) { - return activeEnterpriseCustomer.uuid; - } - - // Otherwise, there is a slug provided for a specific enterprise customer. If the - // enterprise customer for the given slug is associated to one linked to the learner, - // return the enterprise ID for that enterprise customer. - const foundEnterpriseIdForSlug = allLinkedEnterpriseCustomerUsers.find( - (enterpriseCustomerUser) => enterpriseCustomerUser.enterpriseCustomer.slug === enterpriseSlug, - )?.enterpriseCustomer.uuid; - - if (foundEnterpriseIdForSlug) { - return foundEnterpriseIdForSlug; - } - - // If no enterprise customer is found for the given user/slug, throw an error. - throw new Error(`Could not find enterprise customer for user ${authenticatedUser.userId} and slug ${enterpriseSlug}`); -} diff --git a/src/components/app/data/services.js b/src/components/app/data/services.js deleted file mode 100644 index a9012ff96c..0000000000 --- a/src/components/app/data/services.js +++ /dev/null @@ -1,600 +0,0 @@ -import dayjs from 'dayjs'; -import { generatePath, matchPath, redirect } from 'react-router-dom'; -import { camelCaseObject, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { logError, logInfo } from '@edx/frontend-platform/logging'; -import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; - -import { - ENTERPRISE_OFFER_STATUS, - ENTERPRISE_OFFER_USAGE_TYPE, -} from '../../enterprise-user-subsidy/enterprise-offers/data/constants'; -import { getErrorResponseStatusCode } from '../../../utils/common'; -import { SUBSIDY_REQUEST_STATE } from '../../enterprise-subsidy-requests'; -import { - determineEnterpriseCustomerUserForDisplay, - getAssignmentsByState, - transformEnterpriseCustomer, - transformRedeemablePoliciesData, -} from './utils'; -import { LICENSE_STATUS } from '../../enterprise-user-subsidy/data/constants'; -import { features } from '../../../config'; - -// Enterprise Course Enrollments - -/** - * TODO - * @returns - */ -export async function fetchUserEntitlements() { - const url = `${getConfig().LMS_BASE_URL}/api/entitlements/v1/entitlements/`; - const response = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(response.data); -} - -// Enterprise Learner - -/** - * Helper function to `updateActiveEnterpriseCustomerUser` to make the POST API - * request, updating the active enterprise customer for the learner. - * @param {Object} params - The parameters object. - * @param {Object} params.enterpriseCustomer - The enterprise customer that should be made active. - * @returns {Promise} - A promise that resolves when the active enterprise customer is updated. - */ -export async function updateUserActiveEnterprise({ enterpriseCustomer }) { - const url = `${getConfig().LMS_BASE_URL}/enterprise/select/active/`; - const formData = new FormData(); - formData.append('enterprise', enterpriseCustomer.uuid); - return getAuthenticatedHttpClient().post(url, formData); -} - -/** - * Recursive function to fetch all results, traversing a paginated API response. - * @param {string} url Request URL - * @param {Array} [results] Array of results. - * @returns Array of all results for authenticated user. - */ -async function fetchPaginatedData(url, results = []) { - const response = await getAuthenticatedHttpClient().get(url); - const responseData = camelCaseObject(response.data); - const resultsCopy = [...results]; - resultsCopy.push(...responseData.results); - if (responseData.next) { - return fetchPaginatedData(responseData.next, resultsCopy); - } - return { - results: resultsCopy, - response: responseData, - }; -} - -/** - * Fetches the enterprise learner data for the authenticated user, including all - * linked enterprise customer users. - * - * @param {string} username The username of the authenticated user. - * @param {string} enterpriseSlug The slug of the enterprise customer to display. - * @param {Object} [options] Additional query options. - * @returns - */ -export async function fetchEnterpriseLearnerData(username, enterpriseSlug, options = {}) { - const config = getConfig(); - const enterpriseLearnerUrl = `${config.LMS_BASE_URL}/enterprise/api/v1/enterprise-learner/`; - const queryParams = new URLSearchParams({ - username, - ...options, - page: 1, - }); - const url = `${enterpriseLearnerUrl}?${queryParams.toString()}`; - const { - results: enterpriseCustomersUsers, - response: enterpriseCustomerUsersResponse, - } = await fetchPaginatedData(url); - const { enterpriseFeatures } = enterpriseCustomerUsersResponse; - - // Transform enterprise customer user results - const transformedEnterpriseCustomersUsers = enterpriseCustomersUsers.map( - enterpriseCustomerUser => ({ - ...enterpriseCustomerUser, - enterpriseCustomer: transformEnterpriseCustomer(enterpriseCustomerUser.enterpriseCustomer), - }), - ); - - const activeLinkedEnterpriseCustomerUser = transformedEnterpriseCustomersUsers.find(enterprise => enterprise.active); - const activeEnterpriseCustomer = activeLinkedEnterpriseCustomerUser?.enterpriseCustomer; - const activeEnterpriseCustomerUserRoleAssignments = activeLinkedEnterpriseCustomerUser?.roleAssignments; - - // Find enterprise customer metadata for the currently viewed - // enterprise slug in the page route params. - const foundEnterpriseCustomerUserForCurrentSlug = transformedEnterpriseCustomersUsers.find( - enterpriseCustomerUser => enterpriseCustomerUser.enterpriseCustomer.slug === enterpriseSlug, - ); - - const { - enterpriseCustomer, - roleAssignments, - } = determineEnterpriseCustomerUserForDisplay({ - activeEnterpriseCustomer, - activeEnterpriseCustomerUserRoleAssignments, - enterpriseSlug, - foundEnterpriseCustomerUserForCurrentSlug, - }); - return { - enterpriseCustomer, - enterpriseCustomerUserRoleAssignments: roleAssignments, - activeEnterpriseCustomer, - activeEnterpriseCustomerUserRoleAssignments, - allLinkedEnterpriseCustomerUsers: transformedEnterpriseCustomersUsers, - enterpriseFeatures, - }; -} - -// Course Enrollments - -/** - * TODO - * @param {*} enterpriseId - * @param {*} options - * @returns - */ -export async function fetchEnterpriseCourseEnrollments(enterpriseId, options = {}) { - const queryParams = new URLSearchParams({ - enterprise_id: enterpriseId, - ...options, - }); - const url = `${getConfig().LMS_BASE_URL}/enterprise_learner_portal/api/v1/enterprise_course_enrollments/?${queryParams.toString()}`; - const response = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(response.data); -} - -// Course - -/** - * TODO - * @param {*} param0 - * @returns - */ -export async function fetchCourseMetadata(enterpriseId, courseKey, options = {}) { - const contentMetadataUrl = `${getConfig().ENTERPRISE_CATALOG_API_BASE_URL}/api/v1/enterprise-customer/${enterpriseId}/content-metadata/${courseKey}/`; - const queryParams = new URLSearchParams({ - ...options, - }); - const url = `${contentMetadataUrl}?${queryParams.toString()}`; - try { - const response = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(response.data); - } catch (error) { - const errorResponseStatusCode = getErrorResponseStatusCode(error); - if (errorResponseStatusCode === 404) { - return null; - } - throw error; - } -} - -/** - * Service method to determine whether the authenticated user can redeem the specified course run(s). - * - * @param {object} args - * @param {array} courseRunKeys List of course run keys. - * @returns Promise for get request from the authenticated http client. - */ -export async function fetchCanRedeem(enterpriseId, courseRunKeys) { - const queryParams = new URLSearchParams(); - courseRunKeys.forEach((courseRunKey) => { - queryParams.append('content_key', courseRunKey); - }); - const url = `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/policy-redemption/enterprise-customer/${enterpriseId}/can-redeem/`; - const urlWithParams = `${url}?${queryParams.toString()}`; - try { - const response = await getAuthenticatedHttpClient().get(urlWithParams); - return camelCaseObject(response.data); - } catch (error) { - const errorResponseStatusCode = getErrorResponseStatusCode(error); - if (errorResponseStatusCode === 404) { - return []; - } - throw error; - } -} - -// Content Highlights - -/** - * Content Highlights Configuration - * @param {*} enterpriseUUID - * @returns - */ -export async function fetchEnterpriseCuration(enterpriseUUID, options = {}) { - const queryParams = new URLSearchParams({ - enterprise_customer: enterpriseUUID, - ...options, - }); - const url = `${getConfig().ENTERPRISE_CATALOG_API_BASE_URL}/api/v1/enterprise-curations/?${queryParams.toString()}`; - - try { - const response = await getAuthenticatedHttpClient().get(url); - const data = camelCaseObject(response.data); - // Return first result, given that there should only be one result, if any. - return data.results[0] ?? null; - } catch (error) { - const errorResponseStatusCode = getErrorResponseStatusCode(error); - if (errorResponseStatusCode === 404) { - return null; - } - throw error; - } -} - -// Subsidies - -// Browse and Request - -/** - * TODO - * @param {*} enterpriseUUID - * @returns - */ -export async function fetchBrowseAndRequestConfiguration(enterpriseUUID) { - const url = `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/customer-configurations/${enterpriseUUID}/`; - try { - const response = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(response.data); - } catch (error) { - const errorResponseStatusCode = getErrorResponseStatusCode(error); - if (errorResponseStatusCode === 404) { - return null; - } - throw error; - } -} - -/** - * TODO - * @param {*} enterpriseUUID - * @param {*} userEmail - * @param {*} state - * @returns - */ -export async function fetchLicenseRequests( - enterpriseUUID, - userEmail, - state = SUBSIDY_REQUEST_STATE.REQUESTED, -) { - const queryParams = new URLSearchParams({ - enterprise_customer_uuid: enterpriseUUID, - user__email: userEmail, - state, - }); - const config = getConfig(); - const url = `${config.ENTERPRISE_ACCESS_BASE_URL}/api/v1/license-requests/?${queryParams.toString()}`; - const response = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(response.data); -} - -/** - * TODO - * @param {*} enterpriseUUID - * @param {*} userEmail - * @param {*} state - * @returns - */ -export async function fetchCouponCodeRequests( - enterpriseUUID, - userEmail, - state = SUBSIDY_REQUEST_STATE.REQUESTED, -) { - const queryParams = new URLSearchParams({ - enterprise_customer_uuid: enterpriseUUID, - user__email: userEmail, - state, - }); - const config = getConfig(); - const url = `${config.ENTERPRISE_ACCESS_BASE_URL}/api/v1/coupon-code-requests/?${queryParams.toString()}`; - const response = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(response.data); -} - -// Coupon Codes -async function fetchCouponCodeAssignments(enterpriseId, options = {}) { - const queryParams = new URLSearchParams({ - enterprise_uuid: enterpriseId, - full_discount_only: 'True', // Must be a string because the API does a string compare not a true JSON boolean compare. - is_active: 'True', - ...options, - }); - const config = getConfig(); - const url = `${config.ECOMMERCE_BASE_URL}/api/v2/enterprise/offer_assignment_summary/?${queryParams.toString()}`; - const response = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(response.data); -} - -async function fetchCouponsOverview(enterpriseId, options = {}) { - const queryParams = new URLSearchParams({ - page: 1, - page_size: 100, - ...options, - }); - const config = getConfig(); - const url = `${config.ECOMMERCE_BASE_URL}/api/v2/enterprise/coupons/${enterpriseId}/overview/?${queryParams.toString()}`; - const response = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(response.data); -} - -/** - * TODO - * @param {*} param0 - * @returns - */ -export async function fetchCouponCodes(enterpriseUuid) { - const results = await Promise.all([ - fetchCouponsOverview(enterpriseUuid), - fetchCouponCodeAssignments(enterpriseUuid), - ]); - return { - couponsOverview: results[0], - couponCodeAssignments: results[1], - }; -} - -// Enterprise Offers -export async function fetchEnterpriseOffers(enterpriseId, options = {}) { - const queryParams = new URLSearchParams({ - usage_type: ENTERPRISE_OFFER_USAGE_TYPE.PERCENTAGE, - discount_value: 100, - status: ENTERPRISE_OFFER_STATUS.OPEN, - page_size: 100, - ...options, - }); - const config = getConfig(); - const url = `${config.ECOMMERCE_BASE_URL}/api/v2/enterprise/${enterpriseId}/enterprise-learner-offers/?${queryParams.toString()}`; - const response = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(response.data); -} - -// Policies -/** - * TODO - * @param {*} enterpriseUUID - * @param {*} userID - * @returns - */ -export async function fetchRedeemablePolicies(enterpriseUUID, userID) { - const queryParams = new URLSearchParams({ - enterprise_customer_uuid: enterpriseUUID, - lms_user_id: userID, - }); - const config = getConfig(); - const url = `${config.ENTERPRISE_ACCESS_BASE_URL}/api/v1/policy-redemption/credits_available/?${queryParams.toString()}`; - const response = await getAuthenticatedHttpClient().get(url); - const responseData = camelCaseObject(response.data); - const redeemablePolicies = transformRedeemablePoliciesData(responseData); - const learnerContentAssignments = getAssignmentsByState( - redeemablePolicies?.flatMap(item => item.learnerContentAssignments || []), - ); - return { - redeemablePolicies, - learnerContentAssignments, - }; -} - -// Subscriptions - -/** - * TODO - * @param {*} activationKey - * @returns - */ -export async function activateLicense(activationKey) { - const queryParams = new URLSearchParams({ activation_key: activationKey }); - const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/license-activation/?${queryParams.toString()}`; - return getAuthenticatedHttpClient().post(url); -} - -/** - * TODO - * @param {*} param0 - * @returns - */ -export async function activateSubscriptionLicense({ - enterpriseCustomer, - subscriptionLicenseToActivate, - requestUrl, -}) { - const licenseActivationRouteMatch = matchPath('/:enterpriseSlug/licenses/:activationKey/activate', requestUrl.pathname); - const dashboardRedirectPath = generatePath('/:enterpriseSlug', { enterpriseSlug: enterpriseCustomer.slug }); - try { - // Activate the user's assigned subscription license. - await activateLicense(subscriptionLicenseToActivate.activationKey); - const autoActivatedSubscriptionLicense = { - ...subscriptionLicenseToActivate, - status: 'activated', - activationDate: dayjs().toISOString(), - }; - sendEnterpriseTrackEvent( - enterpriseCustomer.uuid, - 'edx.ui.enterprise.learner_portal.license-activation.license-activated', - { - // `autoActivated` is true if the user is on a page route *other* than the license activation route. - autoActivated: !licenseActivationRouteMatch, - }, - ); - // If user is on the license activation route, redirect to the dashboard. - if (licenseActivationRouteMatch) { - throw redirect(dashboardRedirectPath); - } - // Otherwise, return the now-activated subscription license. - return autoActivatedSubscriptionLicense; - } catch (error) { - logError(error); - if (licenseActivationRouteMatch) { - throw redirect(dashboardRedirectPath); - } - return null; - } -} - -/** - * Attempts to auto-apply a license for the authenticated user and the specified customer agreement. - * - * @param {string} customerAgreementId The UUID of the customer agreement. - * @returns An object representing the auto-applied license or null if no license was auto-applied. - */ -export async function requestAutoAppliedUserLicense(customerAgreementId) { - const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/customer-agreement/${customerAgreementId}/auto-apply/`; - const response = await getAuthenticatedHttpClient().post(url); - return camelCaseObject(response.data); -} - -/** - * TODO - * @param {*} param0 - */ -export async function getAutoAppliedSubscriptionLicense({ - enterpriseCustomer, - customerAgreement, -}) { - // If the feature flag for auto-applied licenses is not enabled, return early. - if (!features.ENABLE_AUTO_APPLIED_LICENSES) { - return null; - } - - const hasSubscriptionForAutoAppliedLicenses = !!customerAgreement.subscriptionForAutoAppliedLicenses; - const hasIdentityProvider = enterpriseCustomer.identityProvider; - - // If customer agreement has no configured subscription plan for auto-applied - // licenses, or the enterprise customer does not have an identity provider, - // return early. - if (!hasSubscriptionForAutoAppliedLicenses || !hasIdentityProvider) { - return null; - } - - try { - return requestAutoAppliedUserLicense(customerAgreement.uuid); - } catch (error) { - logError(error); - return null; - } -} - -/** - * TODO - * @param {*} param0 - * @returns - */ -export async function activateOrAutoApplySubscriptionLicense({ - enterpriseCustomer, - subscriptionsData, - requestUrl, -}) { - const { - customerAgreement, - licensesByStatus, - } = subscriptionsData; - if (!customerAgreement || customerAgreement.netDaysUntilExpiration <= 0) { - return null; - } - - // Check if learner already has activated license. If so, return early. - const hasActivatedSubscriptionLicense = licensesByStatus[LICENSE_STATUS.ACTIVATED].length > 0; - if (hasActivatedSubscriptionLicense) { - return null; - } - - // Otherwise, check if there is an assigned subscription - // license to activate OR if the user should request an - // auto-applied subscription license. - const subscriptionLicenseToActivate = licensesByStatus[LICENSE_STATUS.ASSIGNED][0]; - if (subscriptionLicenseToActivate) { - return activateSubscriptionLicense({ - enterpriseCustomer, - subscriptionLicenseToActivate, - requestUrl, - }); - } - - const hasRevokedSubscriptionLicense = licensesByStatus[LICENSE_STATUS.REVOKED].length > 0; - if (!hasRevokedSubscriptionLicense) { - return getAutoAppliedSubscriptionLicense({ - enterpriseCustomer, - customerAgreement, - }); - } - - return null; -} - -/** - * TODO - * @returns - * @param enterpriseUUID - */ -export async function fetchSubscriptions(enterpriseUUID) { - const queryParams = new URLSearchParams({ - enterprise_customer_uuid: enterpriseUUID, - include_revoked: true, - }); - const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/learner-licenses/?${queryParams.toString()}`; - const response = await getAuthenticatedHttpClient().get(url); - const { - customerAgreement, - results: subscriptionLicenses, - } = camelCaseObject(response.data); - const licensesByStatus = { - [LICENSE_STATUS.ACTIVATED]: [], - [LICENSE_STATUS.ASSIGNED]: [], - [LICENSE_STATUS.REVOKED]: [], - }; - const subscriptionsData = { - subscriptionLicenses, - customerAgreement, - subscriptionLicense: null, - licensesByStatus, - }; - /** - * Ordering of these status keys (i.e., activated, assigned, revoked) is important as the first - * license found when iterating through each status key in this order will be selected as the - * applicable license for use by the rest of the application. - * - * 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. - */ - subscriptionLicenses.forEach((license) => { - const { subscriptionPlan, status } = license; - const { isActive, daysUntilExpiration } = subscriptionPlan; - const isCurrent = daysUntilExpiration > 0; - const isUnassignedLicense = status === LICENSE_STATUS.UNASSIGNED; - if (isUnassignedLicense || !isCurrent || !isActive) { - return; - } - licensesByStatus[license.status].push(license); - }); - const applicableSubscriptionLicense = Object.values(licensesByStatus).flat()[0]; - subscriptionsData.subscriptionLicense = applicableSubscriptionLicense; - subscriptionsData.licensesByStatus = licensesByStatus; - - return subscriptionsData; -} - -// Notices -export const fetchNotices = async () => { - const url = `${getConfig().LMS_BASE_URL}/notices/api/v1/unacknowledged`; - try { - const response = await getAuthenticatedHttpClient().get(url); - const results = response?.data.results || []; - if (results.length === 0 || !results[0]) { - return null; - } - return `${results[0]}?next=${window.location.href}`; - } catch (error) { - // we will just swallow error, as that probably means the notices app is not installed. - // Notices are not necessary for the rest of dashboard to function. - const httpErrorStatus = getErrorResponseStatusCode(error); - if (httpErrorStatus === 404) { - logInfo(`${error}. This probably happened because the notices plugin is not installed on platform.`); - } else { - logError(error); - } - return null; - } -}; diff --git a/src/components/app/data/services.test.js b/src/components/app/data/services.test.js deleted file mode 100644 index 4876c8e18a..0000000000 --- a/src/components/app/data/services.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import axios from 'axios'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { logError, logInfo } from '@edx/frontend-platform/logging'; -import { fetchNotices } from './services'; - -const APP_CONFIG = { - DISCOVERY_API_BASE_URL: 'http://localhost:18381', - LMS_BASE_URL: 'http://localhost:18000', -}; -jest.mock('@edx/frontend-platform/config', () => ({ - ...jest.requireActual('@edx/frontend-platform/config'), - getConfig: jest.fn(() => APP_CONFIG), -})); -jest.mock('@edx/frontend-platform/config', () => ({ - ...jest.requireActual('@edx/frontend-platform/config'), - logError: jest.fn(), - logInfo: jest.fn(), -})); - -jest.mock('@edx/frontend-platform/auth', () => ({ - getAuthenticatedUser: jest.fn(() => ({ id: 12345 })), - getAuthenticatedHttpClient: jest.fn(), -})); - -const axiosMock = new MockAdapter(axios); -getAuthenticatedHttpClient.mockReturnValue(axios); - -const NOTICES_ENDPOINT = `${APP_CONFIG.LMS_BASE_URL }/notices/api/v1/unacknowledged`; - -describe('fetchNotices', () => { - it('returns empty data results', async () => { - axiosMock.onGet(NOTICES_ENDPOINT).reply(200, { results: [] }); - const noticeRedirectUrl = await fetchNotices(); - expect(noticeRedirectUrl).toBe(null); - }); - it('returns notice redirect url', async () => { - const exampleNoticeUrl = 'https://example.com'; - axiosMock.onGet(NOTICES_ENDPOINT).reply(200, { results: [exampleNoticeUrl] }); - const noticeRedirectUrl = await fetchNotices(); - expect(noticeRedirectUrl).toBe(`${exampleNoticeUrl}?next=${window.location.href}`); - }); - it('calls logInfo on 404', async () => { - axiosMock.onGet(NOTICES_ENDPOINT).reply(404, {}); - const noticeRedirectUrl = await fetchNotices(); - expect(noticeRedirectUrl).toBe(null); - expect(logInfo).toHaveBeenCalledTimes(1); - }); - it('calls logError on 500', async () => { - axiosMock.onGet(NOTICES_ENDPOINT).reply(500, {}); - const noticeRedirectUrl = await fetchNotices(); - expect(noticeRedirectUrl).toBe(null); - expect(logError).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/app/data/services/contentHighlights.js b/src/components/app/data/services/contentHighlights.js new file mode 100644 index 0000000000..277128fe0f --- /dev/null +++ b/src/components/app/data/services/contentHighlights.js @@ -0,0 +1,30 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { getErrorResponseStatusCode } from '../../../../utils/common'; + +/** + * Content Highlights Configuration + * @param {*} enterpriseUUID + * @returns + */ +export async function fetchEnterpriseCuration(enterpriseUUID, options = {}) { + const queryParams = new URLSearchParams({ + enterprise_customer: enterpriseUUID, + ...options, + }); + const url = `${getConfig().ENTERPRISE_CATALOG_API_BASE_URL}/api/v1/enterprise-curations/?${queryParams.toString()}`; + + try { + const response = await getAuthenticatedHttpClient().get(url); + const data = camelCaseObject(response.data); + // Return first result, given that there should only be one result, if any. + return data.results[0] ?? null; + } catch (error) { + const errorResponseStatusCode = getErrorResponseStatusCode(error); + if (errorResponseStatusCode === 404) { + return null; + } + throw error; + } +} diff --git a/src/components/app/data/services/contentHighlights.test.js b/src/components/app/data/services/contentHighlights.test.js new file mode 100644 index 0000000000..11ed62d19b --- /dev/null +++ b/src/components/app/data/services/contentHighlights.test.js @@ -0,0 +1,57 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { fetchEnterpriseCuration } from './contentHighlights'; + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +const mockEnterpriseId = 'test-enterprise-uuid'; +const APP_CONFIG = { + ENTERPRISE_CATALOG_API_BASE_URL: 'http://localhost:18160', +}; +jest.mock('@edx/frontend-platform', () => ({ + ...jest.requireActual('@edx/frontend-platform'), + getConfig: jest.fn(() => APP_CONFIG), +})); + +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedHttpClient: jest.fn(), +})); + +describe('fetchEnterpriseCuration', () => { + const queryParams = new URLSearchParams({ + enterprise_customer: mockEnterpriseId, + }); + const HIGHLIGHTS_CONFIG_URL = `${APP_CONFIG.ENTERPRISE_CATALOG_API_BASE_URL}/api/v1/enterprise-curations/?${queryParams.toString()}`; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + enterpriseCuration: null, + }, + { + enterpriseCuration: { uuid: 'test-highlights-curation-uuid' }, + }, + ])('returns content highlights configuration (%s)', async ({ enterpriseCuration }) => { + const mockResponse = enterpriseCuration ? { results: [enterpriseCuration] } : { results: [] }; + axiosMock.onGet(HIGHLIGHTS_CONFIG_URL).reply(200, mockResponse); + const result = await fetchEnterpriseCuration(mockEnterpriseId); + if (enterpriseCuration) { + expect(result).toEqual(enterpriseCuration); + } else { + expect(result).toEqual(null); + } + }); + + it('catches 404 error and returns null', async () => { + axiosMock.onGet(HIGHLIGHTS_CONFIG_URL).reply(404); + const result = await fetchEnterpriseCuration(mockEnterpriseId); + expect(result).toBeNull(); + }); +}); diff --git a/src/components/app/data/services/course.js b/src/components/app/data/services/course.js new file mode 100644 index 0000000000..79fda4d3ce --- /dev/null +++ b/src/components/app/data/services/course.js @@ -0,0 +1,52 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getErrorResponseStatusCode } from '../../../../utils/common'; + +/** + * TODO + * @param {*} param0 + * @returns + */ +export async function fetchCourseMetadata(enterpriseId, courseKey, options = {}) { + const contentMetadataUrl = `${getConfig().ENTERPRISE_CATALOG_API_BASE_URL}/api/v1/enterprise-customer/${enterpriseId}/content-metadata/${courseKey}/`; + const queryParams = new URLSearchParams({ + ...options, + }); + const url = `${contentMetadataUrl}?${queryParams.toString()}`; + try { + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data); + } catch (error) { + const errorResponseStatusCode = getErrorResponseStatusCode(error); + if (errorResponseStatusCode === 404) { + return null; + } + throw error; + } +} + +/** + * Service method to determine whether the authenticated user can redeem the specified course run(s). + * + * @param {object} args + * @param {array} courseRunKeys List of course run keys. + * @returns Promise for get request from the authenticated http client. + */ +export async function fetchCanRedeem(enterpriseId, courseRunKeys) { + const queryParams = new URLSearchParams(); + courseRunKeys.forEach((courseRunKey) => { + queryParams.append('content_key', courseRunKey); + }); + const url = `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/policy-redemption/enterprise-customer/${enterpriseId}/can-redeem/`; + const urlWithParams = `${url}?${queryParams.toString()}`; + try { + const response = await getAuthenticatedHttpClient().get(urlWithParams); + return camelCaseObject(response.data); + } catch (error) { + const errorResponseStatusCode = getErrorResponseStatusCode(error); + if (errorResponseStatusCode === 404) { + return []; + } + throw error; + } +} diff --git a/src/components/app/data/services/course.test.js b/src/components/app/data/services/course.test.js new file mode 100644 index 0000000000..81c2535208 --- /dev/null +++ b/src/components/app/data/services/course.test.js @@ -0,0 +1,75 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { fetchCanRedeem, fetchCourseMetadata } from './course'; + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +const mockEnterpriseId = 'test-enterprise-uuid'; +const mockCourseKey = 'edX+DemoX'; +const mockCourseKeyTwo = 'edX+DemoZ'; +const APP_CONFIG = { + ENTERPRISE_CATALOG_API_BASE_URL: 'http://localhost:18160', + ENTERPRISE_ACCESS_BASE_URL: 'http://localhost:18270', +}; +jest.mock('@edx/frontend-platform', () => ({ + ...jest.requireActual('@edx/frontend-platform'), + getConfig: jest.fn(() => APP_CONFIG), +})); + +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedHttpClient: jest.fn(), +})); + +describe('fetchCourseMetadata', () => { + const CONTENT_METADATA_URL = `${APP_CONFIG.ENTERPRISE_CATALOG_API_BASE_URL}/api/v1/enterprise-customer/${mockEnterpriseId}/content-metadata/${mockCourseKey}/?`; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns course metadata', async () => { + const courseMetadata = { + key: mockCourseKey, + title: 'edX Demonstration Course', + }; + axiosMock.onGet(CONTENT_METADATA_URL).reply(200, courseMetadata); + const result = await fetchCourseMetadata(mockEnterpriseId, mockCourseKey); + expect(result).toEqual(courseMetadata); + }); + + it('catches 404 error and returns null', async () => { + axiosMock.onGet(CONTENT_METADATA_URL).reply(404); + const result = await fetchCourseMetadata(mockEnterpriseId, mockCourseKey); + expect(result).toBeNull(); + }); +}); + +describe('fetchCanRedeem', () => { + const queryParams = new URLSearchParams(); + queryParams.append('content_key', mockCourseKey); + queryParams.append('content_key', mockCourseKeyTwo); + const CAN_REDEEM_URL = `${APP_CONFIG.ENTERPRISE_ACCESS_BASE_URL}/api/v1/policy-redemption/enterprise-customer/${mockEnterpriseId}/can-redeem/?${queryParams.toString()}`; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns can-redeem response', async () => { + const canRedeemData = { + canRedeem: true, + }; + axiosMock.onGet(CAN_REDEEM_URL).reply(200, canRedeemData); + const result = await fetchCanRedeem(mockEnterpriseId, [mockCourseKey, mockCourseKeyTwo]); + expect(result).toEqual(canRedeemData); + }); + + it('catches 404 error and returns empty array', async () => { + axiosMock.onGet(CAN_REDEEM_URL).reply(404); + const result = await fetchCanRedeem(mockEnterpriseId, [mockCourseKey]); + expect(result).toEqual([]); + }); +}); diff --git a/src/components/app/data/services/enterpriseCustomerUser.js b/src/components/app/data/services/enterpriseCustomerUser.js new file mode 100644 index 0000000000..e58fef3fe5 --- /dev/null +++ b/src/components/app/data/services/enterpriseCustomerUser.js @@ -0,0 +1,96 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { determineEnterpriseCustomerUserForDisplay, transformEnterpriseCustomer } from '../utils'; +import { fetchPaginatedData } from './utils'; + +/** + * Helper function to `updateActiveEnterpriseCustomerUser` to make the POST API + * request, updating the active enterprise customer for the learner. + * @param {Object} params - The parameters object. + * @param {Object} params.enterpriseCustomer - The enterprise customer that should be made active. + * @returns {Promise} - A promise that resolves when the active enterprise customer is updated. + */ +export async function updateUserActiveEnterprise({ enterpriseCustomer }) { + const url = `${getConfig().LMS_BASE_URL}/enterprise/select/active/`; + const formData = new FormData(); + formData.append('enterprise', enterpriseCustomer.uuid); + return getAuthenticatedHttpClient().post(url, formData); +} + +/** + * Fetches the enterprise learner data for the authenticated user, including all + * linked enterprise customer users. + * + * @param {string} username The username of the authenticated user. + * @param {string} enterpriseSlug The slug of the enterprise customer to display. + * @param {Object} [options] Additional query options. + * @returns + */ +export async function fetchEnterpriseLearnerData(username, enterpriseSlug, options = {}) { + const config = getConfig(); + const enterpriseLearnerUrl = `${config.LMS_BASE_URL}/enterprise/api/v1/enterprise-learner/`; + const queryParams = new URLSearchParams({ + username, + ...options, + page: 1, + }); + const url = `${enterpriseLearnerUrl}?${queryParams.toString()}`; + const { + results: enterpriseCustomersUsers, + response: enterpriseCustomerUsersResponse, + } = await fetchPaginatedData(url); + const { enterpriseFeatures } = enterpriseCustomerUsersResponse; + + // Transform enterprise customer user results + const transformedEnterpriseCustomersUsers = enterpriseCustomersUsers.map( + enterpriseCustomerUser => ({ + ...enterpriseCustomerUser, + enterpriseCustomer: transformEnterpriseCustomer(enterpriseCustomerUser.enterpriseCustomer), + }), + ); + + const activeLinkedEnterpriseCustomerUser = transformedEnterpriseCustomersUsers.find(enterprise => enterprise.active); + const activeEnterpriseCustomer = activeLinkedEnterpriseCustomerUser?.enterpriseCustomer; + const activeEnterpriseCustomerUserRoleAssignments = activeLinkedEnterpriseCustomerUser?.roleAssignments; + + // Find enterprise customer metadata for the currently viewed + // enterprise slug in the page route params. + const foundEnterpriseCustomerUserForCurrentSlug = transformedEnterpriseCustomersUsers.find( + enterpriseCustomerUser => enterpriseCustomerUser.enterpriseCustomer?.slug === enterpriseSlug, + ); + + const { + enterpriseCustomer, + roleAssignments, + } = determineEnterpriseCustomerUserForDisplay({ + activeEnterpriseCustomer, + activeEnterpriseCustomerUserRoleAssignments, + enterpriseSlug, + foundEnterpriseCustomerUserForCurrentSlug, + }); + return { + enterpriseCustomer, + enterpriseCustomerUserRoleAssignments: roleAssignments, + activeEnterpriseCustomer, + activeEnterpriseCustomerUserRoleAssignments, + allLinkedEnterpriseCustomerUsers: transformedEnterpriseCustomersUsers, + enterpriseFeatures, + }; +} + +/** + * TODO + * @param {*} enterpriseId + * @param {*} options + * @returns + */ +export async function fetchEnterpriseCourseEnrollments(enterpriseId, options = {}) { + const queryParams = new URLSearchParams({ + enterprise_id: enterpriseId, + ...options, + }); + const url = `${getConfig().LMS_BASE_URL}/enterprise_learner_portal/api/v1/enterprise_course_enrollments/?${queryParams.toString()}`; + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data); +} diff --git a/src/components/app/data/services/enterpriseCustomerUser.test.js b/src/components/app/data/services/enterpriseCustomerUser.test.js new file mode 100644 index 0000000000..d66caa2bc4 --- /dev/null +++ b/src/components/app/data/services/enterpriseCustomerUser.test.js @@ -0,0 +1,116 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { fetchEnterpriseCourseEnrollments, fetchEnterpriseLearnerData, updateUserActiveEnterprise } from './enterpriseCustomerUser'; + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +const mockEnterpriseId = 'test-enterprise-uuid'; +const mockEnterpriseSlug = 'test-enterprise-slug'; +const APP_CONFIG = { + LMS_BASE_URL: 'http://localhost:18000', +}; +jest.mock('@edx/frontend-platform', () => ({ + ...jest.requireActual('@edx/frontend-platform'), + getConfig: jest.fn(() => APP_CONFIG), +})); + +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedHttpClient: jest.fn(), +})); + +describe('updateUserActiveEnterprise', () => { + const updateUserActiveEnterpriseUrl = `${APP_CONFIG.LMS_BASE_URL}/enterprise/select/active/`; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('passes correct POST body', async () => { + const enterpriseCustomer = { uuid: 'uuid' }; + const formData = new FormData(); + formData.append('enterprise', enterpriseCustomer.uuid); + axiosMock.onPost(updateUserActiveEnterpriseUrl).reply(200, {}); + await updateUserActiveEnterprise({ enterpriseCustomer }); + expect(axiosMock.history.post[0].data).toEqual(formData); + }); +}); + +describe('fetchEnterpriseLearnerData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + enableLearnerPortal: true, + }, + { + enableLearnerPortal: false, + }, + ])('returns enterprise learner data', async ({ enableLearnerPortal }) => { + const username = 'test-username'; + const enterpriseLearnerUrl = `${APP_CONFIG.LMS_BASE_URL}/enterprise/api/v1/enterprise-learner/`; + const queryParams = new URLSearchParams({ + username, + page: 1, + }); + const mockEnterpriseCustomer = { + uuid: mockEnterpriseId, + enableLearnerPortal, + slug: mockEnterpriseSlug, + brandingConfiguration: { + logo: 'https://logo.url', + primaryColor: 'red', + secondaryColor: 'white', + tertiaryColor: 'blue', + }, + }; + const url = `${enterpriseLearnerUrl}?${queryParams.toString()}`; + const enterpriseCustomersUsers = [{ + id: 6, + active: true, + enterpriseCustomer: mockEnterpriseCustomer, + roleAssignments: ['enterprise_learner'], + }]; + axiosMock.onGet(url).reply(200, { results: enterpriseCustomersUsers, enterpriseFeatures: { featureA: true } }); + const response = await fetchEnterpriseLearnerData(username, mockEnterpriseSlug); + const expectedTransformedEnterpriseCustomer = { + ...mockEnterpriseCustomer, + disableSearch: false, + showIntegrationWarning: false, + }; + const expectedEnterpriseCustomer = enableLearnerPortal ? expectedTransformedEnterpriseCustomer : null; + expect(response).toEqual({ + enterpriseFeatures: { featureA: true }, + enterpriseCustomer: expectedEnterpriseCustomer, + enterpriseCustomerUserRoleAssignments: ['enterprise_learner'], + activeEnterpriseCustomer: expectedEnterpriseCustomer, + activeEnterpriseCustomerUserRoleAssignments: ['enterprise_learner'], + allLinkedEnterpriseCustomerUsers: enterpriseCustomersUsers.map((ecu) => ({ + ...ecu, + enterpriseCustomer: expectedEnterpriseCustomer, + })), + }); + }); +}); + +describe('fetchEnterpriseCourseEnrollments', () => { + const COURSE_ENROLLMENTS_ENDPOINT = `${APP_CONFIG.LMS_BASE_URL}/enterprise_learner_portal/api/v1/enterprise_course_enrollments/?enterprise_id=${mockEnterpriseId}`; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns course enrollments', async () => { + const courseEnrollments = [{ key: 'edX+DemoX' }]; + axiosMock.onGet(COURSE_ENROLLMENTS_ENDPOINT).reply(200, { results: courseEnrollments }); + const response = await fetchEnterpriseCourseEnrollments(mockEnterpriseId); + expect(response).toEqual({ + results: courseEnrollments, + }); + }); +}); diff --git a/src/components/app/data/services/index.js b/src/components/app/data/services/index.js new file mode 100644 index 0000000000..cfb5000080 --- /dev/null +++ b/src/components/app/data/services/index.js @@ -0,0 +1,6 @@ +export * from './subsidies'; +export * from './user'; +export * from './contentHighlights'; +export * from './course'; +export * from './enterpriseCustomerUser'; +export * from './utils'; diff --git a/src/components/app/data/services/subsidies/browseAndRequest.js b/src/components/app/data/services/subsidies/browseAndRequest.js new file mode 100644 index 0000000000..a9ce12cee3 --- /dev/null +++ b/src/components/app/data/services/subsidies/browseAndRequest.js @@ -0,0 +1,70 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; + +import { SUBSIDY_REQUEST_STATE } from '../../../../enterprise-subsidy-requests'; +import { getErrorResponseStatusCode } from '../../../../../utils/common'; + +/** + * TODO + * @param {*} enterpriseUUID + * @returns + */ +export async function fetchBrowseAndRequestConfiguration(enterpriseUUID) { + const url = `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/customer-configurations/${enterpriseUUID}/`; + try { + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data); + } catch (error) { + const errorResponseStatusCode = getErrorResponseStatusCode(error); + if (errorResponseStatusCode === 404) { + return null; + } + throw error; + } +} + +/** + * TODO + * @param {*} enterpriseUUID + * @param {*} userEmail + * @param {*} state + * @returns + */ +export async function fetchLicenseRequests( + enterpriseUUID, + userEmail, + state = SUBSIDY_REQUEST_STATE.REQUESTED, +) { + const queryParams = new URLSearchParams({ + enterprise_customer_uuid: enterpriseUUID, + user__email: userEmail, + state, + }); + const config = getConfig(); + const url = `${config.ENTERPRISE_ACCESS_BASE_URL}/api/v1/license-requests/?${queryParams.toString()}`; + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data); +} + +/** + * TODO + * @param {*} enterpriseUUID + * @param {*} userEmail + * @param {*} state + * @returns + */ +export async function fetchCouponCodeRequests( + enterpriseUUID, + userEmail, + state = SUBSIDY_REQUEST_STATE.REQUESTED, +) { + const queryParams = new URLSearchParams({ + enterprise_customer_uuid: enterpriseUUID, + user__email: userEmail, + state, + }); + const config = getConfig(); + const url = `${config.ENTERPRISE_ACCESS_BASE_URL}/api/v1/coupon-code-requests/?${queryParams.toString()}`; + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data); +} diff --git a/src/components/app/data/services/subsidies/browseAndRequest.test.js b/src/components/app/data/services/subsidies/browseAndRequest.test.js new file mode 100644 index 0000000000..c3e25a3ffc --- /dev/null +++ b/src/components/app/data/services/subsidies/browseAndRequest.test.js @@ -0,0 +1,76 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { fetchBrowseAndRequestConfiguration, fetchCouponCodeRequests, fetchLicenseRequests } from '.'; +import { SUBSIDY_REQUEST_STATE } from '../../../../enterprise-subsidy-requests'; + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +const mockEnterpriseId = 'test-enterprise-uuid'; +const mockUserEmail = 'edx@example.com'; +const APP_CONFIG = { + ENTERPRISE_ACCESS_BASE_URL: 'http://localhost:18270', +}; +jest.mock('@edx/frontend-platform', () => ({ + ...jest.requireActual('@edx/frontend-platform'), + getConfig: jest.fn(() => APP_CONFIG), +})); +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedHttpClient: jest.fn(), +})); + +describe('fetchBrowseAndRequestConfiguration', () => { + const BNR_CONFIG_URL = `${APP_CONFIG.ENTERPRISE_ACCESS_BASE_URL}/api/v1/customer-configurations/${mockEnterpriseId}/`; + + it('returns browse and request configuration', async () => { + const mockConfig = { + id: 123, + }; + axiosMock.onGet(BNR_CONFIG_URL).reply(200, mockConfig); + const result = await fetchBrowseAndRequestConfiguration(mockEnterpriseId); + expect(result).toEqual(mockConfig); + }); + + it('returns null when 404', async () => { + axiosMock.onGet(BNR_CONFIG_URL).reply(404); + const result = await fetchBrowseAndRequestConfiguration(mockEnterpriseId); + expect(result).toBeNull(); + }); +}); + +describe('fetchLicenseRequests', () => { + it('returns license requests', async () => { + const mockLicenseRequests = { + results: [{ id: 123 }], + }; + const queryParams = new URLSearchParams({ + enterprise_customer_uuid: mockEnterpriseId, + user__email: mockUserEmail, + state: SUBSIDY_REQUEST_STATE.REQUESTED, + }); + const LICENSE_REQUESTS_URL = `${APP_CONFIG.ENTERPRISE_ACCESS_BASE_URL}/api/v1/license-requests/?${queryParams.toString()}`; + axiosMock.onGet(LICENSE_REQUESTS_URL).reply(200, mockLicenseRequests); + const result = await fetchLicenseRequests(mockEnterpriseId, mockUserEmail); + expect(result).toEqual(mockLicenseRequests); + }); +}); + +describe('fetchCouponCodeRequests', () => { + it('returns coupon code requests', async () => { + const mockCouponCodeRequests = { + results: [{ id: 123 }], + }; + const queryParams = new URLSearchParams({ + enterprise_customer_uuid: mockEnterpriseId, + user__email: mockUserEmail, + state: SUBSIDY_REQUEST_STATE.REQUESTED, + }); + const COUPON_CODE_REQUESTS_URL = `${APP_CONFIG.ENTERPRISE_ACCESS_BASE_URL}/api/v1/coupon-code-requests/?${queryParams.toString()}`; + axiosMock.onGet(COUPON_CODE_REQUESTS_URL).reply(200, mockCouponCodeRequests); + const result = await fetchCouponCodeRequests(mockEnterpriseId, mockUserEmail); + expect(result).toEqual(mockCouponCodeRequests); + }); +}); diff --git a/src/components/app/data/services/subsidies/couponCodes.js b/src/components/app/data/services/subsidies/couponCodes.js new file mode 100644 index 0000000000..7422ac48e6 --- /dev/null +++ b/src/components/app/data/services/subsidies/couponCodes.js @@ -0,0 +1,56 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +// Coupon Codes + +/** + * TODO + * @param {*} enterpriseId + * @param {*} options + * @returns + */ +export async function fetchCouponCodeAssignments(enterpriseId, options = {}) { + const queryParams = new URLSearchParams({ + enterprise_uuid: enterpriseId, + full_discount_only: 'True', // Must be a string because the API does a string compare not a true JSON boolean compare. + is_active: 'True', + ...options, + }); + const url = `${getConfig().ECOMMERCE_BASE_URL}/api/v2/enterprise/offer_assignment_summary/?${queryParams.toString()}`; + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data); +} + +/** + * TODO + * @param {*} enterpriseId + * @param {*} options + * @returns + */ +export async function fetchCouponsOverview(enterpriseId, options = {}) { + const queryParams = new URLSearchParams({ + page: 1, + page_size: 100, + ...options, + }); + const config = getConfig(); + const url = `${config.ECOMMERCE_BASE_URL}/api/v2/enterprise/coupons/${enterpriseId}/overview/?${queryParams.toString()}`; + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data); +} + +/** + * TODO + * @param {*} param0 + * @returns + */ +export async function fetchCouponCodes(enterpriseUuid) { + const results = await Promise.all([ + fetchCouponsOverview(enterpriseUuid), + fetchCouponCodeAssignments(enterpriseUuid), + ]); + return { + couponsOverview: results[0], + couponCodeAssignments: results[1], + }; +} diff --git a/src/components/app/data/services/subsidies/couponCodes.test.js b/src/components/app/data/services/subsidies/couponCodes.test.js new file mode 100644 index 0000000000..444eb1d247 --- /dev/null +++ b/src/components/app/data/services/subsidies/couponCodes.test.js @@ -0,0 +1,82 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { fetchCouponCodeAssignments, fetchCouponCodes, fetchCouponsOverview } from '.'; + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +const enterpriseId = 'test-enterprise-uuid'; +const APP_CONFIG = { + ECOMMERCE_BASE_URL: 'http://localhost:18130', +}; +jest.mock('@edx/frontend-platform', () => ({ + ...jest.requireActual('@edx/frontend-platform'), + getConfig: jest.fn(() => APP_CONFIG), +})); +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedHttpClient: jest.fn(), +})); + +function getCouponCodeAssignmentsUrl(enterpriseUuid) { + const queryParams = new URLSearchParams({ + enterprise_uuid: enterpriseUuid, + full_discount_only: 'True', + is_active: 'True', + }); + return `${APP_CONFIG.ECOMMERCE_BASE_URL}/api/v2/enterprise/offer_assignment_summary/?${queryParams.toString()}`; +} + +function getCouponsOverviewUrl(enterpriseUuid) { + const queryParams = new URLSearchParams({ + page: 1, + page_size: 100, + }); + return `${APP_CONFIG.ECOMMERCE_BASE_URL}/api/v2/enterprise/coupons/${enterpriseUuid}/overview/?${queryParams.toString()}`; +} + +describe('fetchCouponCodeAssignments', () => { + it('returns coupon code assignments', async () => { + const COUPON_CODE_ASSIGNMENTS_URL = getCouponCodeAssignmentsUrl(enterpriseId); + const couponCodeAssignments = { + results: [{ id: 123 }], + }; + axiosMock.onGet(COUPON_CODE_ASSIGNMENTS_URL).reply(200, couponCodeAssignments); + const result = await fetchCouponCodeAssignments(enterpriseId); + expect(result).toEqual(couponCodeAssignments); + }); +}); + +describe('fetchCouponsOverview', () => { + it('returns coupons overview', async () => { + const COUPONS_OVERVIEW_URL = getCouponsOverviewUrl(enterpriseId); + const couponsOverview = { + results: [{ id: 123 }], + }; + axiosMock.onGet(COUPONS_OVERVIEW_URL).reply(200, couponsOverview); + const result = await fetchCouponsOverview(enterpriseId); + expect(result).toEqual(couponsOverview); + }); +}); + +describe('fetchCouponCodes', () => { + it('returns coupons related data', async () => { + const COUPON_CODE_ASSIGNMENTS_URL = getCouponCodeAssignmentsUrl(enterpriseId); + const COUPONS_OVERVIEW_URL = getCouponsOverviewUrl(enterpriseId); + const couponCodeAssignments = { + results: [{ id: 123 }], + }; + const couponsOverview = { + results: [{ id: 123 }], + }; + axiosMock.onGet(COUPON_CODE_ASSIGNMENTS_URL).reply(200, couponCodeAssignments); + axiosMock.onGet(COUPONS_OVERVIEW_URL).reply(200, couponsOverview); + const result = await fetchCouponCodes(enterpriseId); + expect(result).toEqual({ + couponCodeAssignments, + couponsOverview, + }); + }); +}); diff --git a/src/components/app/data/services/subsidies/index.js b/src/components/app/data/services/subsidies/index.js new file mode 100644 index 0000000000..63e4dcd11b --- /dev/null +++ b/src/components/app/data/services/subsidies/index.js @@ -0,0 +1,55 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { ENTERPRISE_OFFER_STATUS, ENTERPRISE_OFFER_USAGE_TYPE } from '../../../../enterprise-user-subsidy/enterprise-offers/data/constants'; +import { getAssignmentsByState, transformRedeemablePoliciesData } from '../../utils'; + +// Enterprise Offers + +/** + * TODO + * @param {*} enterpriseId + * @param {*} options + * @returns + */ +export async function fetchEnterpriseOffers(enterpriseId, options = {}) { + const queryParams = new URLSearchParams({ + usage_type: ENTERPRISE_OFFER_USAGE_TYPE.PERCENTAGE, + discount_value: 100, + status: ENTERPRISE_OFFER_STATUS.OPEN, + page_size: 100, + ...options, + }); + const url = `${getConfig().ECOMMERCE_BASE_URL}/api/v2/enterprise/${enterpriseId}/enterprise-learner-offers/?${queryParams.toString()}`; + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data); +} + +// Redeemable Policies + +/** + * TODO + * @param {*} enterpriseUUID + * @param {*} userID + * @returns + */ +export async function fetchRedeemablePolicies(enterpriseUUID, userID) { + const queryParams = new URLSearchParams({ + enterprise_customer_uuid: enterpriseUUID, + lms_user_id: userID, + }); + const url = `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/policy-redemption/credits_available/?${queryParams.toString()}`; + const response = await getAuthenticatedHttpClient().get(url); + const responseData = camelCaseObject(response.data); + const redeemablePolicies = transformRedeemablePoliciesData(responseData); + const learnerContentAssignments = getAssignmentsByState( + redeemablePolicies?.flatMap(item => item.learnerContentAssignments || []), + ); + return { + redeemablePolicies, + learnerContentAssignments, + }; +} + +export * from './browseAndRequest'; +export * from './subscriptions'; +export * from './couponCodes'; diff --git a/src/components/app/data/services/subsidies/index.test.js b/src/components/app/data/services/subsidies/index.test.js new file mode 100644 index 0000000000..a5b4db96ee --- /dev/null +++ b/src/components/app/data/services/subsidies/index.test.js @@ -0,0 +1,102 @@ +import dayjs from 'dayjs'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { fetchEnterpriseOffers, fetchRedeemablePolicies } from '.'; +import { ENTERPRISE_OFFER_STATUS, ENTERPRISE_OFFER_USAGE_TYPE } from '../../../../enterprise-user-subsidy/enterprise-offers/data/constants'; + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +const mockEnterpriseId = 'test-enterprise-uuid'; +const mockContentAssignment = { + uuid: 'test-assignment-uuid', + state: 'allocated', +}; +const APP_CONFIG = { + ECOMMERCE_BASE_URL: 'http://localhost:18130', +}; +jest.mock('@edx/frontend-platform', () => ({ + ...jest.requireActual('@edx/frontend-platform'), + getConfig: jest.fn(() => APP_CONFIG), +})); +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedHttpClient: jest.fn(), +})); + +describe('fetchEnterpriseOffers', () => { + const queryParams = new URLSearchParams({ + usage_type: ENTERPRISE_OFFER_USAGE_TYPE.PERCENTAGE, + discount_value: 100, + status: ENTERPRISE_OFFER_STATUS.OPEN, + page_size: 100, + }); + const ENTERPRISE_OFFERS_URL = `${APP_CONFIG.ECOMMERCE_BASE_URL}/api/v2/enterprise/${mockEnterpriseId}/enterprise-learner-offers/?${queryParams.toString()}`; + + it('returns enterprise offers', async () => { + const enterpriseOffers = { + results: [{ id: 123 }], + }; + axiosMock.onGet(ENTERPRISE_OFFERS_URL).reply(200, enterpriseOffers); + const result = await fetchEnterpriseOffers(mockEnterpriseId); + expect(result).toEqual(enterpriseOffers); + }); +}); + +describe('fetchRedeemablePolicies', () => { + it('returns redeemable policies', async () => { + const userID = 3; + const queryParams = new URLSearchParams({ + enterprise_customer_uuid: mockEnterpriseId, + lms_user_id: userID, + }); + const POLICY_REDEMPTION_URL = `${APP_CONFIG.ENTERPRISE_ACCESS_BASE_URL}/api/v1/policy-redemption/credits_available/?${queryParams.toString()}`; + const mockSubsidyExpirationDate = dayjs().add(1, 'year').toISOString(); + const redeemablePolicies = [ + { + id: 123, + subsidyExpirationDate: mockSubsidyExpirationDate, + }, + { + id: 456, + subsidyExpirationDate: mockSubsidyExpirationDate, + learnerContentAssignments: [mockContentAssignment], + }, + ]; + axiosMock.onGet(POLICY_REDEMPTION_URL).reply(200, redeemablePolicies); + const result = await fetchRedeemablePolicies(mockEnterpriseId, userID); + const expectedTransformedPolicies = redeemablePolicies.map((policy) => ({ + ...policy, + learnerContentAssignments: policy.learnerContentAssignments?.map((assignment) => ({ + ...assignment, + subsidyExpirationDate: policy.subsidyExpirationDate, + })), + })); + const mockContentAssignmentWithSubsidyExpiration = { + ...mockContentAssignment, + subsidyExpirationDate: mockSubsidyExpirationDate, + }; + const expectedRedeemablePolicies = { + redeemablePolicies: expectedTransformedPolicies, + learnerContentAssignments: { + acceptedAssignments: [], + allocatedAssignments: [mockContentAssignmentWithSubsidyExpiration], + assignments: [mockContentAssignmentWithSubsidyExpiration], + assignmentsForDisplay: [mockContentAssignmentWithSubsidyExpiration], + canceledAssignments: [], + erroredAssignments: [], + expiredAssignments: [], + hasAcceptedAssignments: false, + hasAllocatedAssignments: true, + hasAssignments: true, + hasAssignmentsForDisplay: true, + hasCanceledAssignments: false, + hasErroredAssignments: false, + hasExpiredAssignments: false, + }, + }; + expect(result).toEqual(expectedRedeemablePolicies); + }); +}); diff --git a/src/components/app/data/services/subsidies/subscriptions.js b/src/components/app/data/services/subsidies/subscriptions.js new file mode 100644 index 0000000000..3a0f909ac0 --- /dev/null +++ b/src/components/app/data/services/subsidies/subscriptions.js @@ -0,0 +1,206 @@ +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { logError } from '@edx/frontend-platform/logging'; +import dayjs from 'dayjs'; +import { generatePath, matchPath, redirect } from 'react-router-dom'; +import { features } from '../../../../../config'; +import { LICENSE_STATUS } from '../../../../enterprise-user-subsidy/data/constants'; + +// Subscriptions + +/** + * TODO + * @param {*} activationKey + * @returns + */ +export async function activateLicense(activationKey) { + const queryParams = new URLSearchParams({ activation_key: activationKey }); + const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/license-activation/?${queryParams.toString()}`; + return getAuthenticatedHttpClient().post(url); +} + +/** + * TODO + * @param {*} param0 + * @returns + */ +export async function activateSubscriptionLicense({ + enterpriseCustomer, + subscriptionLicenseToActivate, + requestUrl, +}) { + const licenseActivationRouteMatch = matchPath('/:enterpriseSlug/licenses/:activationKey/activate', requestUrl.pathname); + const dashboardRedirectPath = generatePath('/:enterpriseSlug', { enterpriseSlug: enterpriseCustomer.slug }); + try { + // Activate the user's assigned subscription license. + await activateLicense(subscriptionLicenseToActivate.activationKey); + const autoActivatedSubscriptionLicense = { + ...subscriptionLicenseToActivate, + status: 'activated', + activationDate: dayjs().toISOString(), + }; + sendEnterpriseTrackEvent( + enterpriseCustomer.uuid, + 'edx.ui.enterprise.learner_portal.license-activation.license-activated', + { + // `autoActivated` is true if the user is on a page route *other* than the license activation route. + autoActivated: !licenseActivationRouteMatch, + }, + ); + // If user is on the license activation route, redirect to the dashboard. + if (licenseActivationRouteMatch) { + throw redirect(dashboardRedirectPath); + } + // Otherwise, return the now-activated subscription license. + return autoActivatedSubscriptionLicense; + } catch (error) { + logError(error); + if (licenseActivationRouteMatch) { + throw redirect(dashboardRedirectPath); + } + return null; + } +} + +/** + * Attempts to auto-apply a license for the authenticated user and the specified customer agreement. + * + * @param {string} customerAgreementId The UUID of the customer agreement. + * @returns An object representing the auto-applied license or null if no license was auto-applied. + */ +export async function requestAutoAppliedUserLicense(customerAgreementId) { + const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/customer-agreement/${customerAgreementId}/auto-apply/`; + const response = await getAuthenticatedHttpClient().post(url); + return camelCaseObject(response.data); +} + +/** + * TODO + * @param {*} param0 + */ +export async function getAutoAppliedSubscriptionLicense({ + enterpriseCustomer, + customerAgreement, +}) { + // If the feature flag for auto-applied licenses is not enabled, return early. + if (!features.ENABLE_AUTO_APPLIED_LICENSES) { + return null; + } + + const hasSubscriptionForAutoAppliedLicenses = !!customerAgreement.subscriptionForAutoAppliedLicenses; + const hasIdentityProvider = enterpriseCustomer.identityProvider; + + // If customer agreement has no configured subscription plan for auto-applied + // licenses, or the enterprise customer does not have an identity provider, + // return early. + if (!hasSubscriptionForAutoAppliedLicenses || !hasIdentityProvider) { + return null; + } + + try { + return requestAutoAppliedUserLicense(customerAgreement.uuid); + } catch (error) { + logError(error); + return null; + } +} + +/** + * TODO + * @param {*} param0 + * @returns + */ +export async function activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer, + subscriptionsData, + requestUrl, +}) { + const { + customerAgreement, + licensesByStatus, + } = subscriptionsData; + if (!customerAgreement || customerAgreement.netDaysUntilExpiration <= 0) { + return null; + } + + // Check if learner already has activated license. If so, return early. + const hasActivatedSubscriptionLicense = licensesByStatus[LICENSE_STATUS.ACTIVATED].length > 0; + if (hasActivatedSubscriptionLicense) { + return null; + } + + // Otherwise, check if there is an assigned subscription + // license to activate OR if the user should request an + // auto-applied subscription license. + const subscriptionLicenseToActivate = licensesByStatus[LICENSE_STATUS.ASSIGNED][0]; + if (subscriptionLicenseToActivate) { + return activateSubscriptionLicense({ + enterpriseCustomer, + subscriptionLicenseToActivate, + requestUrl, + }); + } + + const hasRevokedSubscriptionLicense = licensesByStatus[LICENSE_STATUS.REVOKED].length > 0; + if (!hasRevokedSubscriptionLicense) { + return getAutoAppliedSubscriptionLicense({ + enterpriseCustomer, + customerAgreement, + }); + } + + return null; +} + +/** + * TODO + * @returns + * @param enterpriseUUID + */ +export async function fetchSubscriptions(enterpriseUUID) { + const queryParams = new URLSearchParams({ + enterprise_customer_uuid: enterpriseUUID, + include_revoked: true, + }); + const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/learner-licenses/?${queryParams.toString()}`; + const response = await getAuthenticatedHttpClient().get(url); + const { + customerAgreement, + results: subscriptionLicenses, + } = camelCaseObject(response.data); + const licensesByStatus = { + [LICENSE_STATUS.ACTIVATED]: [], + [LICENSE_STATUS.ASSIGNED]: [], + [LICENSE_STATUS.REVOKED]: [], + }; + const subscriptionsData = { + subscriptionLicenses, + customerAgreement, + subscriptionLicense: null, + licensesByStatus, + }; + /** + * Ordering of these status keys (i.e., activated, assigned, revoked) is important as the first + * license found when iterating through each status key in this order will be selected as the + * applicable license for use by the rest of the application. + * + * 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. + */ + subscriptionLicenses.forEach((license) => { + const { subscriptionPlan, status } = license; + const { isActive, daysUntilExpiration } = subscriptionPlan; + const isCurrent = daysUntilExpiration > 0; + const isUnassignedLicense = status === LICENSE_STATUS.UNASSIGNED; + if (isUnassignedLicense || !isCurrent || !isActive) { + return; + } + licensesByStatus[license.status].push(license); + }); + const applicableSubscriptionLicense = Object.values(licensesByStatus).flat()[0]; + subscriptionsData.subscriptionLicense = applicableSubscriptionLicense; + subscriptionsData.licensesByStatus = licensesByStatus; + + return subscriptionsData; +} diff --git a/src/components/app/data/services/subsidies/subscriptions.test.js b/src/components/app/data/services/subsidies/subscriptions.test.js new file mode 100644 index 0000000000..b14f9bedf9 --- /dev/null +++ b/src/components/app/data/services/subsidies/subscriptions.test.js @@ -0,0 +1,322 @@ +import dayjs from 'dayjs'; +import MockDate from 'mockdate'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { activateOrAutoApplySubscriptionLicense, fetchSubscriptions } from '.'; +import { LICENSE_STATUS } from '../../../../enterprise-user-subsidy/data/constants'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + redirect: jest.fn((redirectPath) => redirectPath), +})); + +jest.mock('../../../../../config', () => ({ + ...jest.requireActual('../../../../../config'), + features: { + ENABLE_AUTO_APPLIED_LICENSES: true, + }, +})); + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +const mockEnterpriseId = 'test-enterprise-uuid'; +const mockEnterpriseSlug = 'test-enterprise-slug'; +const mockEnterpriseCustomer = { + uuid: mockEnterpriseId, + slug: mockEnterpriseSlug, +}; +const mockLicenseUUID = 'test-license-uuid'; +const mockLicenseActivationKey = 'test-license-activation-key'; +const mockSubscriptionPlanUUID = 'test-subscription-plan-uuid'; +const mockCustomerAgreement = { + uuid: 'test-customer-agreement-uuid', +}; +const APP_CONFIG = { + LICENSE_MANAGER_URL: 'http://localhost:18170', +}; +jest.mock('@edx/frontend-platform', () => ({ + ...jest.requireActual('@edx/frontend-platform'), + getConfig: jest.fn(() => APP_CONFIG), +})); +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedHttpClient: jest.fn(), +})); + +jest.mock('@edx/frontend-enterprise-utils', () => ({ + ...jest.requireActual('@edx/frontend-enterprise-utils'), + sendEnterpriseTrackEvent: jest.fn(), +})); + +describe('fetchSubscriptions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + licenseStatus: LICENSE_STATUS.ACTIVATED, + isSubscriptionPlanActive: true, + daysUntilExpiration: 30, + }, + { + licenseStatus: LICENSE_STATUS.ACTIVATED, + isSubscriptionPlanActive: false, + daysUntilExpiration: 30, + }, + { + licenseStatus: LICENSE_STATUS.ACTIVATED, + isSubscriptionPlanActive: true, + daysUntilExpiration: 0, + }, + { + licenseStatus: LICENSE_STATUS.UNASSIGNED, + isSubscriptionPlanActive: true, + daysUntilExpiration: 30, + }, + ])('returns subscriptions (%s)', async ({ + licenseStatus, + isSubscriptionPlanActive, + daysUntilExpiration, + }) => { + const mockSubscriptionLicense = { + uuid: 'test-license-uuid', + status: licenseStatus, + subscriptionPlan: { + uuid: 'test-subscription-plan-uuid', + isActive: isSubscriptionPlanActive, + daysUntilExpiration, + }, + }; + const mockResponse = { + customerAgreement: { + uuid: 'test-customer-agreement-uuid', + }, + results: [mockSubscriptionLicense], + }; + const queryParams = new URLSearchParams({ + enterprise_customer_uuid: mockEnterpriseId, + include_revoked: true, + }); + const SUBSCRIPTIONS_URL = `${APP_CONFIG.LICENSE_MANAGER_URL}/api/v1/learner-licenses/?${queryParams.toString()}`; + axiosMock.onGet(SUBSCRIPTIONS_URL).reply(200, mockResponse); + const response = await fetchSubscriptions(mockEnterpriseId); + const expectedLicensesByStatus = { + [LICENSE_STATUS.ACTIVATED]: [], + [LICENSE_STATUS.ASSIGNED]: [], + [LICENSE_STATUS.REVOKED]: [], + }; + const isLicenseApplicable = ( + licenseStatus !== LICENSE_STATUS.UNASSIGNED + && isSubscriptionPlanActive + && daysUntilExpiration > 0 + ); + if (isLicenseApplicable) { + expectedLicensesByStatus[licenseStatus].push(mockSubscriptionLicense); + } + const expectedResult = { + customerAgreement: mockResponse.customerAgreement, + licensesByStatus: expectedLicensesByStatus, + subscriptionLicense: isLicenseApplicable ? mockSubscriptionLicense : undefined, + subscriptionLicenses: [mockSubscriptionLicense], + }; + expect(response).toEqual(expectedResult); + }); +}); + +describe('activateOrAutoApplySubscriptionLicense', () => { + beforeEach(() => { + jest.clearAllMocks(); + MockDate.set(new Date()); + }); + + it('returns null when there is no customer agreement', async () => { + const mockSubscriptionsData = { + customerAgreement: null, + }; + const result = await activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer: mockEnterpriseCustomer, + subscriptionsData: mockSubscriptionsData, + requestUrl: { + pathname: `/${mockEnterpriseSlug}`, + }, + }); + expect(result).toBeNull(); + }); + + it.each([ + { netDaysUntilExpiration: 0 }, + { netDaysUntilExpiration: -30 }, + ])('returns null when there is customer agreement with no current subscription plans', async ({ netDaysUntilExpiration }) => { + const mockSubscriptionsData = { + customerAgreement: { + netDaysUntilExpiration, + }, + }; + const result = await activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer: mockEnterpriseCustomer, + subscriptionsData: mockSubscriptionsData, + requestUrl: { + pathname: `/${mockEnterpriseSlug}`, + }, + }); + expect(result).toBeNull(); + }); + + it('returns null with already activated license', async () => { + const mockLicensesByStatus = { + [LICENSE_STATUS.ACTIVATED]: [{ uuid: 'test-license-uuid' }], + [LICENSE_STATUS.ASSIGNED]: [], + [LICENSE_STATUS.REVOKED]: [], + }; + const mockSubscriptionsData = { + customerAgreement: mockCustomerAgreement, + licensesByStatus: mockLicensesByStatus, + }; + const result = await activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer: mockEnterpriseCustomer, + subscriptionsData: mockSubscriptionsData, + requestUrl: { + pathname: `/${mockEnterpriseSlug}`, + }, + }); + expect(result).toBeNull(); + }); + + it('returns null with revoked license', async () => { + const mockLicensesByStatus = { + [LICENSE_STATUS.ACTIVATED]: [], + [LICENSE_STATUS.ASSIGNED]: [], + [LICENSE_STATUS.REVOKED]: [{ uuid: 'test-license-uuid' }], + }; + const mockSubscriptionsData = { + customerAgreement: mockCustomerAgreement, + licensesByStatus: mockLicensesByStatus, + }; + const result = await activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer: mockEnterpriseCustomer, + subscriptionsData: mockSubscriptionsData, + requestUrl: { + pathname: `/${mockEnterpriseSlug}`, + }, + }); + expect(result).toBeNull(); + }); + + it.each([ + { isLicenseActivationRoute: false }, + { isLicenseActivationRoute: true }, + ])('activates a license (%s)', async ({ isLicenseActivationRoute }) => { + const licenseActivationQueryParams = new URLSearchParams({ + activation_key: mockLicenseActivationKey, + }); + const ACTIVATE_LICENSE_URL = `${APP_CONFIG.LICENSE_MANAGER_URL}/api/v1/license-activation/?${licenseActivationQueryParams.toString()}`; + const mockSubscriptionLicense = { + uuid: mockLicenseUUID, + status: LICENSE_STATUS.ASSIGNED, + activationKey: mockLicenseActivationKey, + }; + const mockLicensesByStatus = { + [LICENSE_STATUS.ACTIVATED]: [], + [LICENSE_STATUS.ASSIGNED]: [mockSubscriptionLicense], + [LICENSE_STATUS.REVOKED]: [], + }; + const mockSubscriptionsData = { + customerAgreement: mockCustomerAgreement, + licensesByStatus: mockLicensesByStatus, + }; + axiosMock.onPost(ACTIVATE_LICENSE_URL).reply(200, {}); + try { + const response = await activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer: mockEnterpriseCustomer, + subscriptionsData: mockSubscriptionsData, + requestUrl: { + pathname: isLicenseActivationRoute + ? `/${mockEnterpriseSlug}/licenses/${mockLicenseActivationKey}/activate` + : `/${mockEnterpriseSlug}`, + }, + }); + expect(response).toEqual({ + ...mockSubscriptionLicense, + status: LICENSE_STATUS.ACTIVATED, + activationDate: dayjs().toISOString(), + }); + } catch (error) { + if (isLicenseActivationRoute) { + expect(error).toEqual(`/${mockEnterpriseSlug}`); + } else { + expect(error).toBeNull(); + } + } + }); + + it.each([ + { + identityProvider: null, + subscriptionForAutoAppliedLicenses: null, + shouldAutoApply: false, + }, + { + identityProvider: null, + subscriptionForAutoAppliedLicenses: mockSubscriptionPlanUUID, + shouldAutoApply: false, + }, + { + identityProvider: 'identity-provider', + subscriptionForAutoAppliedLicenses: null, + shouldAutoApply: false, + }, + { + identityProvider: 'identity-provider', + subscriptionForAutoAppliedLicenses: mockSubscriptionPlanUUID, + shouldAutoApply: true, + }, + ])('auto-applies subscription license (%s)', async ({ + identityProvider, + subscriptionForAutoAppliedLicenses, + shouldAutoApply, + }) => { + const mockLicensesByStatus = { + [LICENSE_STATUS.ACTIVATED]: [], + [LICENSE_STATUS.ASSIGNED]: [], + [LICENSE_STATUS.REVOKED]: [], + }; + const mockCustomerAgreementWithAutoApplied = { + ...mockCustomerAgreement, + subscriptionForAutoAppliedLicenses, + }; + const mockSubscriptionsData = { + customerAgreement: mockCustomerAgreementWithAutoApplied, + licensesByStatus: mockLicensesByStatus, + }; + const mockSubscriptionPlan = { + uuid: mockSubscriptionPlanUUID, + }; + const mockAutoAppliedSubscriptionLicense = { + uuid: mockLicenseUUID, + status: LICENSE_STATUS.ACTIVATED, + subscriptionPlan: mockSubscriptionPlan, + }; + const modifiedMockEnterpriseCustomer = { + ...mockEnterpriseCustomer, + identityProvider, + }; + const AUTO_APPLY_LICENSE_URL = `${APP_CONFIG.LICENSE_MANAGER_URL}/api/v1/customer-agreement/${mockCustomerAgreement.uuid}/auto-apply/`; + axiosMock.onPost(AUTO_APPLY_LICENSE_URL).reply(200, mockAutoAppliedSubscriptionLicense); + const response = await activateOrAutoApplySubscriptionLicense({ + enterpriseCustomer: modifiedMockEnterpriseCustomer, + subscriptionsData: mockSubscriptionsData, + requestUrl: { + pathname: `/${mockEnterpriseSlug}`, + }, + }); + if (shouldAutoApply) { + expect(response).toEqual(mockAutoAppliedSubscriptionLicense); + } else { + expect(response).toBeNull(); + } + }); +}); diff --git a/src/components/app/data/services/user.js b/src/components/app/data/services/user.js new file mode 100644 index 0000000000..7f39b826fb --- /dev/null +++ b/src/components/app/data/services/user.js @@ -0,0 +1,44 @@ +import { logError, logInfo } from '@edx/frontend-platform/logging'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getErrorResponseStatusCode } from '../../../../utils/common'; + +// Notices + +/** + * TODO + * @returns + */ +export const fetchNotices = async () => { + const url = `${getConfig().LMS_BASE_URL}/notices/api/v1/unacknowledged`; + try { + const response = await getAuthenticatedHttpClient().get(url); + const { results } = camelCaseObject(response.data); + if (results.length === 0 || !results[0]) { + return null; + } + return `${results[0]}?next=${window.location.href}`; + } catch (error) { + // we will just swallow error, as that probably means the notices app is not installed. + // Notices are not necessary for the rest of dashboard to function. + const httpErrorStatus = getErrorResponseStatusCode(error); + if (httpErrorStatus === 404) { + logInfo(`${error}. This probably happened because the notices plugin is not installed on platform.`); + } else { + logError(error); + } + return null; + } +}; + +// User Entitlements + +/** + * TODO + * @returns + */ +export async function fetchUserEntitlements() { + const url = `${getConfig().LMS_BASE_URL}/api/entitlements/v1/entitlements/`; + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data); +} diff --git a/src/components/app/data/services/user.test.js b/src/components/app/data/services/user.test.js new file mode 100644 index 0000000000..ab65b29698 --- /dev/null +++ b/src/components/app/data/services/user.test.js @@ -0,0 +1,84 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { logError, logInfo } from '@edx/frontend-platform/logging'; + +import { fetchNotices, fetchUserEntitlements } from './user'; + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +const APP_CONFIG = { + DISCOVERY_API_BASE_URL: 'http://localhost:18381', + LMS_BASE_URL: 'http://localhost:18000', +}; +jest.mock('@edx/frontend-platform', () => ({ + ...jest.requireActual('@edx/frontend-platform'), + getConfig: jest.fn(() => APP_CONFIG), +})); +jest.mock('@edx/frontend-platform/logging', () => ({ + ...jest.requireActual('@edx/frontend-platform/logging'), + logError: jest.fn(), + logInfo: jest.fn(), +})); +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedHttpClient: jest.fn(), +})); + +describe('fetchNotices', () => { + const NOTICES_ENDPOINT = `${APP_CONFIG.LMS_BASE_URL }/notices/api/v1/unacknowledged`; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + mockResponse: { results: [] }, + }, + { + mockResponse: null, + }, + ])('returns empty data results (%s)', async ({ mockResponse }) => { + axiosMock.onGet(NOTICES_ENDPOINT).reply(200, mockResponse); + const noticeRedirectUrl = await fetchNotices(); + expect(noticeRedirectUrl).toEqual(null); + }); + + it('returns notice redirect url', async () => { + const exampleNoticeUrl = 'https://example.com'; + axiosMock.onGet(NOTICES_ENDPOINT).reply(200, { results: [exampleNoticeUrl] }); + const noticeRedirectUrl = await fetchNotices(); + expect(noticeRedirectUrl).toEqual(`${exampleNoticeUrl}?next=${window.location.href}`); + }); + + it('calls logInfo on 404', async () => { + axiosMock.onGet(NOTICES_ENDPOINT).reply(404, {}); + const noticeRedirectUrl = await fetchNotices(); + expect(noticeRedirectUrl).toEqual(null); + expect(logInfo).toHaveBeenCalledTimes(1); + }); + + it('calls logError on 500', async () => { + axiosMock.onGet(NOTICES_ENDPOINT).reply(500, {}); + const noticeRedirectUrl = await fetchNotices(); + expect(noticeRedirectUrl).toEqual(null); + expect(logError).toHaveBeenCalledTimes(1); + }); +}); + +describe('fetchUserEntitlements', () => { + const ENTITLEMENTS_ENDPOINT = `${APP_CONFIG.LMS_BASE_URL}/api/entitlements/v1/entitlements/`; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns user entitlements', async () => { + const mockEntitlements = { results: [] }; + axiosMock.onGet(ENTITLEMENTS_ENDPOINT).reply(200, mockEntitlements); + const entitlements = await fetchUserEntitlements(); + expect(entitlements).toEqual(mockEntitlements); + }); +}); diff --git a/src/components/app/data/services/utils.js b/src/components/app/data/services/utils.js new file mode 100644 index 0000000000..b0c2d7f72b --- /dev/null +++ b/src/components/app/data/services/utils.js @@ -0,0 +1,22 @@ +import { camelCaseObject } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +/** + * Recursive function to fetch all results, traversing a paginated API response. + * @param {string} url Request URL + * @param {Array} [results] Array of results. + * @returns Array of all results for authenticated user. + */ +export async function fetchPaginatedData(url, results = []) { + const response = await getAuthenticatedHttpClient().get(url); + const responseData = camelCaseObject(response.data); + const resultsCopy = [...results]; + resultsCopy.push(...responseData.results); + if (responseData.next) { + return fetchPaginatedData(responseData.next, resultsCopy); + } + return { + results: resultsCopy, + response: responseData, + }; +} diff --git a/src/components/app/data/services/utils.test.js b/src/components/app/data/services/utils.test.js new file mode 100644 index 0000000000..baa268752d --- /dev/null +++ b/src/components/app/data/services/utils.test.js @@ -0,0 +1,85 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { v4 as uuidv4 } from 'uuid'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { fetchPaginatedData } from './utils'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + ...jest.requireActual('@edx/frontend-platform/auth'), + getAuthenticatedHttpClient: jest.fn(), +})); + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +describe('fetchPaginatedData', () => { + const EXAMPLE_ENDPOINT = 'http://example.com/api/v1/data'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns empty data results', async () => { + axiosMock.onGet(EXAMPLE_ENDPOINT).reply(200, { + count: 0, + prev: null, + next: null, + num_pages: 0, + results: [], + }); + const results = await fetchPaginatedData(EXAMPLE_ENDPOINT); + expect(results).toEqual({ + results: [], + response: { + count: 0, + prev: null, + next: null, + numPages: 0, + results: [], + }, + }); + }); + + it('traverses pagination', async () => { + const urlFirstPage = `${EXAMPLE_ENDPOINT}?page=1`; + const urlSecondPage = `${EXAMPLE_ENDPOINT}?page=2`; + const mockResult = { + uuid: uuidv4(), + }; + const mockSecondResult = { + uuid: uuidv4(), + }; + axiosMock.onGet(urlFirstPage).reply(200, { + count: 2, + prev: null, + next: urlSecondPage, + num_pages: 2, + results: [mockResult], + }); + axiosMock.onGet(urlSecondPage).reply(200, { + count: 2, + prev: null, + next: null, + num_pages: 2, + results: [mockSecondResult], + enterprise_features: { + feature_a: true, + }, + }); + const results = await fetchPaginatedData(urlFirstPage); + expect(results).toEqual({ + results: [mockResult, mockSecondResult], + response: { + count: 2, + prev: null, + next: null, + numPages: 2, + results: [mockSecondResult], + enterpriseFeatures: { + featureA: true, + }, + }, + }); + }); +}); diff --git a/src/components/app/data/utils.js b/src/components/app/data/utils.js index bc0beb830c..7b4bbe62da 100644 --- a/src/components/app/data/utils.js +++ b/src/components/app/data/utils.js @@ -102,7 +102,7 @@ export function determineEnterpriseCustomerUserForDisplay({ if (!enterpriseSlug) { return activeEnterpriseCustomerUser; } - if (enterpriseSlug !== activeEnterpriseCustomer.slug && foundEnterpriseCustomerUserForCurrentSlug) { + if (enterpriseSlug !== activeEnterpriseCustomer?.slug && foundEnterpriseCustomerUserForCurrentSlug) { return { enterpriseCustomer: foundEnterpriseCustomerUserForCurrentSlug.enterpriseCustomer, roleAssignments: foundEnterpriseCustomerUserForCurrentSlug.roleAssignments, @@ -192,10 +192,9 @@ export function getAssignmentsByState(assignments = []) { /** * Transform enterprise customer metadata for use by consuming UI components. * @param {Object} enterpriseCustomer - * @param {Object} enterpriseFeatures * @returns */ -export function transformEnterpriseCustomer(enterpriseCustomer, enterpriseFeatures) { +export function transformEnterpriseCustomer(enterpriseCustomer) { // If the learner portal is not enabled for the displayed enterprise customer, return null. This // results in the enterprise learner portal not being accessible for the user, showing a 404 page. if (!enterpriseCustomer.enableLearnerPortal) { @@ -228,7 +227,6 @@ export function transformEnterpriseCustomer(enterpriseCustomer, enterpriseFeatur }, disableSearch, showIntegrationWarning, - enterpriseFeatures, }; } diff --git a/src/components/app/routes/data/utils.test.js b/src/components/app/routes/data/utils.test.js index 71a6a9c515..747d7b2dd8 100644 --- a/src/components/app/routes/data/utils.test.js +++ b/src/components/app/routes/data/utils.test.js @@ -1,15 +1,11 @@ import { transformEnterpriseCustomer } from '../../data'; -const mockEnterpriseFeatures = { - 'example-feature': true, -}; - describe('transformEnterpriseCustomer', () => { it('returns null with disabled learner portal', () => { const enterpriseCustomer = { enableLearnerPortal: false, }; - const result = transformEnterpriseCustomer(enterpriseCustomer, mockEnterpriseFeatures); + const result = transformEnterpriseCustomer(enterpriseCustomer); expect(result).toEqual(null); }); @@ -54,7 +50,7 @@ describe('transformEnterpriseCustomer', () => { tertiaryColor: '#def012', }, }; - const result = transformEnterpriseCustomer(enterpriseCustomer, mockEnterpriseFeatures); + const result = transformEnterpriseCustomer(enterpriseCustomer); expect(result).toEqual({ ...enterpriseCustomer, brandingConfiguration: { @@ -65,7 +61,6 @@ describe('transformEnterpriseCustomer', () => { }, disableSearch: expectedDisableSearch, showIntegrationWarning: expectedShowIntegrationWarning, - enterpriseFeatures: mockEnterpriseFeatures, }); }); }); From b2be0c1ed78b7b295e2ca9362337739f39453791 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Thu, 7 Mar 2024 01:53:40 -0500 Subject: [PATCH 14/16] chore: remove more deadcode --- .../enterprise-user-subsidy/UserSubsidy.jsx | 3 --- .../data/hooks/hooks.js | 27 ++----------------- .../data/hooks/useSubscriptions.js | 2 -- .../data/hooks/useSubscriptions.test.js | 3 --- 4 files changed, 2 insertions(+), 33 deletions(-) diff --git a/src/components/enterprise-user-subsidy/UserSubsidy.jsx b/src/components/enterprise-user-subsidy/UserSubsidy.jsx index a9bd5b2a13..4956ecb3e2 100644 --- a/src/components/enterprise-user-subsidy/UserSubsidy.jsx +++ b/src/components/enterprise-user-subsidy/UserSubsidy.jsx @@ -27,7 +27,6 @@ const UserSubsidy = ({ children }) => { subscriptionLicense, isLoading: isLoadingSubscriptions, showExpirationNotifications, - activateUserLicense, } = useSubscriptions({ enterpriseConfig, authenticatedUser }); // Subsidy Access Policies @@ -84,7 +83,6 @@ const UserSubsidy = ({ children }) => { hasNoEnterpriseOffersBalance, showExpirationNotifications, customerAgreementConfig, - activateUserLicense, redeemableLearnerCreditPolicies, }; }, @@ -101,7 +99,6 @@ const UserSubsidy = ({ children }) => { hasNoEnterpriseOffersBalance, showExpirationNotifications, customerAgreementConfig, - activateUserLicense, redeemableLearnerCreditPolicies, ], ); diff --git a/src/components/enterprise-user-subsidy/data/hooks/hooks.js b/src/components/enterprise-user-subsidy/data/hooks/hooks.js index 3b0659ba9f..439149ea4d 100644 --- a/src/components/enterprise-user-subsidy/data/hooks/hooks.js +++ b/src/components/enterprise-user-subsidy/data/hooks/hooks.js @@ -1,17 +1,15 @@ import { - useCallback, useEffect, useMemo, useReducer, useState, + useEffect, useMemo, useReducer, useState, } from 'react'; import { logError } from '@edx/frontend-platform/logging'; import { camelCaseObject } from '@edx/frontend-platform/utils'; import { useQuery } from '@tanstack/react-query'; -import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { fetchCouponCodeAssignments } from '../../coupons'; import couponCodesReducer, { initialCouponCodesState } from '../../coupons/data/reducer'; import { enterpriseUserSubsidyQueryKeys, LICENSE_STATUS } from '../constants'; import { - activateLicense, fetchCustomerAgreementData, fetchRedeemableLearnerCreditPolicies, fetchSubscriptionLicensesForUser, @@ -112,28 +110,7 @@ export function useSubscriptionLicense({ } }, [customerAgreementConfig, enterpriseId, enterpriseIdentityProvider, isLoadingCustomerAgreementConfig, user]); - const activateUserLicense = useCallback(async (autoActivated = false) => { - try { - await activateLicense(license.activationKey); - - sendEnterpriseTrackEvent( - enterpriseId, - 'edx.ui.enterprise.learner_portal.license-activation.license-activated', - { - autoActivated, - }, - ); - setLicense((prevLicense) => ({ - ...prevLicense, - status: LICENSE_STATUS.ACTIVATED, - })); - } catch (error) { - logError(error); - throw error; - } - }, [enterpriseId, license]); - - return { license, isLoading, activateUserLicense }; + return { license, isLoading }; } /** diff --git a/src/components/enterprise-user-subsidy/data/hooks/useSubscriptions.js b/src/components/enterprise-user-subsidy/data/hooks/useSubscriptions.js index 5dadfef8f6..9a741b6bc8 100644 --- a/src/components/enterprise-user-subsidy/data/hooks/useSubscriptions.js +++ b/src/components/enterprise-user-subsidy/data/hooks/useSubscriptions.js @@ -18,7 +18,6 @@ function useSubscriptions({ const { license: subscriptionLicense, isLoading: isLoadingLicense, - activateUserLicense, } = useSubscriptionLicense({ enterpriseConfig, customerAgreementConfig, @@ -48,7 +47,6 @@ function useSubscriptions({ subscriptionLicense, isLoading: isLoadingCustomerAgreementConfig || isLoadingLicense, showExpirationNotifications, - activateUserLicense, }; } diff --git a/src/components/enterprise-user-subsidy/data/hooks/useSubscriptions.test.js b/src/components/enterprise-user-subsidy/data/hooks/useSubscriptions.test.js index 70891f98d4..385c18ec9f 100644 --- a/src/components/enterprise-user-subsidy/data/hooks/useSubscriptions.test.js +++ b/src/components/enterprise-user-subsidy/data/hooks/useSubscriptions.test.js @@ -56,7 +56,6 @@ describe('useSubscriptions', () => { useSubscriptionLicense.mockReturnValue({ license: undefined, isLoading: isLoadingLicense, - activateUserLicense: jest.fn(), }); const args = { @@ -103,7 +102,6 @@ describe('useSubscriptions', () => { useSubscriptionLicense.mockReturnValue({ license: mockSubscriptionLicense, isLoading: false, - activateUserLicense: jest.fn(), }); hasValidStartExpirationDates.mockReturnValue(isSubscriptionPlanCurrent); const args = { @@ -113,7 +111,6 @@ describe('useSubscriptions', () => { const { result } = renderHook(() => useSubscriptions(args)); expect(result.current).toEqual( expect.objectContaining({ - activateUserLicense: expect.any(Function), customerAgreementConfig: anotherMockCustomerAgreement, isLoading: false, subscriptionLicense: mockSubscriptionLicense, From 39752b4a756fd15e44ec5535e4af5fcfb761bc18 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Thu, 7 Mar 2024 08:52:41 -0500 Subject: [PATCH 15/16] fix: ensure redirect to dashboard works from license activation route --- .../data/services/subsidies/subscriptions.js | 38 +++++++++++++------ .../services/subsidies/subscriptions.test.js | 2 - 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/components/app/data/services/subsidies/subscriptions.js b/src/components/app/data/services/subsidies/subscriptions.js index 3a0f909ac0..d3c803597f 100644 --- a/src/components/app/data/services/subsidies/subscriptions.js +++ b/src/components/app/data/services/subsidies/subscriptions.js @@ -28,10 +28,9 @@ export async function activateLicense(activationKey) { export async function activateSubscriptionLicense({ enterpriseCustomer, subscriptionLicenseToActivate, - requestUrl, + licenseActivationRouteMatch, + dashboardRedirectPath, }) { - const licenseActivationRouteMatch = matchPath('/:enterpriseSlug/licenses/:activationKey/activate', requestUrl.pathname); - const dashboardRedirectPath = generatePath('/:enterpriseSlug', { enterpriseSlug: enterpriseCustomer.slug }); try { // Activate the user's assigned subscription license. await activateLicense(subscriptionLicenseToActivate.activationKey); @@ -50,6 +49,7 @@ export async function activateSubscriptionLicense({ ); // If user is on the license activation route, redirect to the dashboard. if (licenseActivationRouteMatch) { + console.log('redirecting to dashboard', dashboardRedirectPath); throw redirect(dashboardRedirectPath); } // Otherwise, return the now-activated subscription license. @@ -116,41 +116,55 @@ export async function activateOrAutoApplySubscriptionLicense({ subscriptionsData, requestUrl, }) { + const licenseActivationRouteMatch = matchPath('/:enterpriseSlug/licenses/:activationKey/activate', requestUrl.pathname); + const dashboardRedirectPath = generatePath('/:enterpriseSlug', { enterpriseSlug: enterpriseCustomer.slug }); + + const checkLicenseActivationRouteAndRedirectToDashboard = () => { + if (!licenseActivationRouteMatch) { + return null; + } + throw redirect(dashboardRedirectPath); + }; + const { customerAgreement, licensesByStatus, } = subscriptionsData; if (!customerAgreement || customerAgreement.netDaysUntilExpiration <= 0) { - return null; + return checkLicenseActivationRouteAndRedirectToDashboard(); } // Check if learner already has activated license. If so, return early. const hasActivatedSubscriptionLicense = licensesByStatus[LICENSE_STATUS.ACTIVATED].length > 0; if (hasActivatedSubscriptionLicense) { - return null; + return checkLicenseActivationRouteAndRedirectToDashboard(); } - // Otherwise, check if there is an assigned subscription - // license to activate OR if the user should request an - // auto-applied subscription license. + // Otherwise, check if there is an assigned subscription license to + // activate OR if the user should request an auto-applied subscription + // license. + let activatedOrAutoAppliedLicense = null; const subscriptionLicenseToActivate = licensesByStatus[LICENSE_STATUS.ASSIGNED][0]; if (subscriptionLicenseToActivate) { - return activateSubscriptionLicense({ + activatedOrAutoAppliedLicense = await activateSubscriptionLicense({ enterpriseCustomer, subscriptionLicenseToActivate, - requestUrl, + licenseActivationRouteMatch, + dashboardRedirectPath, }); } const hasRevokedSubscriptionLicense = licensesByStatus[LICENSE_STATUS.REVOKED].length > 0; if (!hasRevokedSubscriptionLicense) { - return getAutoAppliedSubscriptionLicense({ + activatedOrAutoAppliedLicense = await getAutoAppliedSubscriptionLicense({ enterpriseCustomer, customerAgreement, }); } - return null; + checkLicenseActivationRouteAndRedirectToDashboard(); + + return activatedOrAutoAppliedLicense; } /** diff --git a/src/components/app/data/services/subsidies/subscriptions.test.js b/src/components/app/data/services/subsidies/subscriptions.test.js index b14f9bedf9..6e260bf7d6 100644 --- a/src/components/app/data/services/subsidies/subscriptions.test.js +++ b/src/components/app/data/services/subsidies/subscriptions.test.js @@ -247,8 +247,6 @@ describe('activateOrAutoApplySubscriptionLicense', () => { } catch (error) { if (isLicenseActivationRoute) { expect(error).toEqual(`/${mockEnterpriseSlug}`); - } else { - expect(error).toBeNull(); } } }); From cb35436c1d98474ae9d4b06a2c998c83348dbd87 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Thu, 7 Mar 2024 08:53:18 -0500 Subject: [PATCH 16/16] chore: quality --- src/components/app/data/services/subsidies/subscriptions.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/app/data/services/subsidies/subscriptions.js b/src/components/app/data/services/subsidies/subscriptions.js index d3c803597f..f5effbd01d 100644 --- a/src/components/app/data/services/subsidies/subscriptions.js +++ b/src/components/app/data/services/subsidies/subscriptions.js @@ -49,7 +49,6 @@ export async function activateSubscriptionLicense({ ); // If user is on the license activation route, redirect to the dashboard. if (licenseActivationRouteMatch) { - console.log('redirecting to dashboard', dashboardRedirectPath); throw redirect(dashboardRedirectPath); } // Otherwise, return the now-activated subscription license.