From 68eb52443ca7bf086d4093bfd5b069a883d98971 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Thu, 7 Mar 2024 10:10:21 -0500 Subject: [PATCH] feat: migrates license activation and auto-apply to route loaders, plus general cleanup (#990) --- src/components/app/App.jsx | 3 +- .../app/AuthenticatedUserSubsidyPage.jsx | 2 - .../app/AuthenticatedUserSubsidyPage.test.jsx | 5 +- .../app/EnterpriseAppPageRoutes.jsx | 2 - src/components/app/Layout.jsx | 18 +- src/components/app/Layout.test.jsx | 147 +++++++ src/components/app/data/createAppRouter.jsx | 80 ---- .../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/data/queries/extractEnterpriseId.js | 46 ++ src/components/app/data/queries/index.js | 4 + src/components/app/data/queries/queries.js | 232 ++++++++++ .../app/data/queries}/queryKeyFactory.js | 11 +- .../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 | 219 ++++++++++ .../services/subsidies/subscriptions.test.js | 320 ++++++++++++++ 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 | 170 +++++++- src/components/app/routes/createAppRouter.jsx | 70 +++ .../app/routes/createAppRouter.test.jsx | 124 ++++++ 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 | 59 --- .../queries/enterpriseCourseEnrollments.js | 16 - .../routes/data/queries/enterpriseLearner.js | 12 - .../app/routes/data/queries/index.js | 112 ----- .../app/routes/data/queries/notices.js | 12 - .../queries/subsidies/browseAndRequest.js | 63 --- .../data/queries/subsidies/couponCodes.js | 18 - .../queries/subsidies/enterpriseOffers.js | 18 - .../routes/data/queries/subsidies/index.js | 6 - .../routes/data/queries/subsidies/policies.js | 20 - .../data/queries/subsidies/subscriptions.js | 18 - .../routes/data/queries/userEntitlements.js | 10 - src/components/app/routes/data/services.js | 404 ------------------ .../app/routes/data/tests/services.test.js | 55 --- src/components/app/routes/data/utils.js | 312 +++++++------- .../app/routes/data/{tests => }/utils.test.js | 11 +- src/components/app/routes/index.js | 7 + .../app/routes/loaders/courseLoader.js | 5 +- .../app/routes/loaders/dashboardLoader.js | 7 +- src/components/app/routes/loaders/index.js | 1 - .../app/routes/loaders/rootLoader.js | 38 +- .../loaders/tests/courseLoader.test.jsx | 7 +- .../loaders/tests/dashboardLoader.test.jsx | 5 +- .../routes/loaders/tests/rootLoader.test.jsx | 7 +- ...ctiveEnterpriseCustomerUserLoader.test.jsx | 167 -------- ...pdateActiveEnterpriseCustomerUserLoader.js | 74 ---- .../AutoActivateLicense.jsx | 43 -- .../enterprise-user-subsidy/UserSubsidy.jsx | 3 - .../enterprise-user-subsidy/data/constants.js | 1 + .../data/hooks/hooks.js | 75 +--- .../data/hooks/hooks.test.jsx | 149 ------- .../data/hooks/useSubscriptions.js | 2 - .../data/hooks/useSubscriptions.test.js | 3 - .../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 ----- src/utils/tests.jsx | 9 +- 93 files changed, 2698 insertions(+), 2185 deletions(-) create mode 100644 src/components/app/Layout.test.jsx delete mode 100644 src/components/app/data/createAppRouter.jsx create mode 100644 src/components/app/data/queries/extractEnterpriseId.js create mode 100644 src/components/app/data/queries/index.js create mode 100644 src/components/app/data/queries/queries.js rename src/{utils => components/app/data/queries}/queryKeyFactory.js (93%) 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 create mode 100644 src/components/app/routes/createAppRouter.jsx create mode 100644 src/components/app/routes/createAppRouter.test.jsx 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/index.js delete mode 100644 src/components/app/routes/data/queries/notices.js delete mode 100644 src/components/app/routes/data/queries/subsidies/browseAndRequest.js delete mode 100644 src/components/app/routes/data/queries/subsidies/couponCodes.js delete mode 100644 src/components/app/routes/data/queries/subsidies/enterpriseOffers.js delete mode 100644 src/components/app/routes/data/queries/subsidies/index.js delete mode 100644 src/components/app/routes/data/queries/subsidies/policies.js delete mode 100644 src/components/app/routes/data/queries/subsidies/subscriptions.js delete mode 100644 src/components/app/routes/data/queries/userEntitlements.js delete mode 100644 src/components/app/routes/data/services.js delete mode 100644 src/components/app/routes/data/tests/services.test.js rename src/components/app/routes/data/{tests => }/utils.test.js (89%) delete mode 100644 src/components/app/routes/loaders/tests/updateActiveEnterpriseCustomerUserLoader.test.jsx delete mode 100644 src/components/app/routes/loaders/updateActiveEnterpriseCustomerUserLoader.js 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/App.jsx b/src/components/app/App.jsx index faff30e6ad..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 { RouterFallback } from './routes'; +import { RouterFallback, createAppRouter } from './routes'; /* eslint-disable max-len */ // const EnterpriseAppPageRoutes = lazy(() => import(/* webpackChunkName: "enterprise-app-routes" */ './EnterpriseAppPageRoutes')); 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/Layout.jsx b/src/components/app/Layout.jsx index 95b55e61ea..d9d7932d39 100644 --- a/src/components/app/Layout.jsx +++ b/src/components/app/Layout.jsx @@ -1,13 +1,11 @@ -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'; 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 +14,7 @@ export const TITLE_TEMPLATE = '%s - edX'; export const DEFAULT_TITLE = 'edX'; const Layout = () => { - const { authenticatedUser, config } = useContext(AppContext); + const config = getConfig(); const { data: enterpriseLearnerData } = useEnterpriseLearner(); const brandStyles = useStylesForCustomBrandColors(enterpriseLearnerData.enterpriseCustomer); @@ -27,18 +25,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/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/data/createAppRouter.jsx b/src/components/app/data/createAppRouter.jsx deleted file mode 100644 index 5c39ed0bc7..0000000000 --- a/src/components/app/data/createAppRouter.jsx +++ /dev/null @@ -1,80 +0,0 @@ -import { PageWrap } from '@edx/frontend-platform/react'; -import { - Outlet, - Route, - createBrowserRouter, - createRoutesFromElements, -} from 'react-router-dom'; - -import RouteErrorBoundary from '../routes/RouteErrorBoundary'; -import { - makeCourseLoader, - makeRootLoader, - makeUpdateActiveEnterpriseCustomerUserLoader, - makeDashboardLoader, -} from '../routes/loaders'; -import Root from '../Root'; -import Layout from '../Layout'; -import NotFoundPage from '../../NotFoundPage'; - -/** - * TODO - * @param {Object} queryClient - * @returns - */ -export default function createAppRouter(queryClient) { - const router = createBrowserRouter( - createRoutesFromElements( - } - errorElement={} - > - } - > - } - > - { - 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), - }; - }} - /> - } /> - - - } /> - , - ), - ); - return router; -} 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/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 new file mode 100644 index 0000000000..f25d0683a2 --- /dev/null +++ b/src/components/app/data/queries/index.js @@ -0,0 +1,4 @@ +export { default as extractEnterpriseId } from './extractEnterpriseId'; +export { default as queries } from './queryKeyFactory'; + +export * from './queries'; diff --git a/src/components/app/data/queries/queries.js b/src/components/app/data/queries/queries.js new file mode 100644 index 0000000000..5e668c2f4a --- /dev/null +++ b/src/components/app/data/queries/queries.js @@ -0,0 +1,232 @@ +import { getAvailableCourseRuns } from '../../../course/data/utils'; +import { SUBSIDY_REQUEST_STATE } from '../../../enterprise-subsidy-requests'; +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); +} + +/** + * 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/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/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..f5effbd01d --- /dev/null +++ b/src/components/app/data/services/subsidies/subscriptions.js @@ -0,0 +1,219 @@ +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, + licenseActivationRouteMatch, + dashboardRedirectPath, +}) { + 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 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 checkLicenseActivationRouteAndRedirectToDashboard(); + } + + // Check if learner already has activated license. If so, return early. + const hasActivatedSubscriptionLicense = licensesByStatus[LICENSE_STATUS.ACTIVATED].length > 0; + if (hasActivatedSubscriptionLicense) { + return checkLicenseActivationRouteAndRedirectToDashboard(); + } + + // 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) { + activatedOrAutoAppliedLicense = await activateSubscriptionLicense({ + enterpriseCustomer, + subscriptionLicenseToActivate, + licenseActivationRouteMatch, + dashboardRedirectPath, + }); + } + + const hasRevokedSubscriptionLicense = licensesByStatus[LICENSE_STATUS.REVOKED].length > 0; + if (!hasRevokedSubscriptionLicense) { + activatedOrAutoAppliedLicense = await getAutoAppliedSubscriptionLicense({ + enterpriseCustomer, + customerAgreement, + }); + } + + checkLicenseActivationRouteAndRedirectToDashboard(); + + return activatedOrAutoAppliedLicense; +} + +/** + * 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..6e260bf7d6 --- /dev/null +++ b/src/components/app/data/services/subsidies/subscriptions.test.js @@ -0,0 +1,320 @@ +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}`); + } + } + }); + + 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 c643670935..7b4bbe62da 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,169 @@ 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 + * @returns + */ +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) { + 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, + }; +} + +/** + * 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/createAppRouter.jsx b/src/components/app/routes/createAppRouter.jsx new file mode 100644 index 0000000000..c1957ccfa2 --- /dev/null +++ b/src/components/app/routes/createAppRouter.jsx @@ -0,0 +1,70 @@ +import { PageWrap } from '@edx/frontend-platform/react'; +import { + Route, createBrowserRouter, createRoutesFromElements, +} from 'react-router-dom'; + +import RouteErrorBoundary from './RouteErrorBoundary'; +import { + makeCourseLoader, + makeRootLoader, + makeDashboardLoader, +} from './loaders'; +import Root from '../Root'; +import Layout from '../Layout'; +import NotFoundPage from '../../NotFoundPage'; + +/** + * TODO + * @param {Object} queryClient + * @returns + */ +export default function createAppRouter(queryClient) { + const router = createBrowserRouter( + createRoutesFromElements( + } + errorElement={} + > + } + > + { + const { default: DashboardRoute } = await import('./DashboardRoute'); + return { + Component: DashboardRoute, + loader: makeDashboardLoader(queryClient), + }; + }} + /> + { + const { default: SearchRoute } = await import('./SearchRoute'); + return { + Component: SearchRoute, + }; + }} + /> + { + const { default: CourseRoute } = await import('./CourseRoute'); + return { + Component: CourseRoute, + loader: makeCourseLoader(queryClient), + }; + }} + /> + } /> + + } /> + , + ), + ); + return router; +} 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/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 49a0fe80e1..0000000000 --- a/src/components/app/routes/data/queries/ensureEnterpriseAppData.js +++ /dev/null @@ -1,59 +0,0 @@ -import { getConfig } from '@edx/frontend-platform/config'; -import queryContentHighlightsConfiguration from './contentHighlights'; -import { - queryCouponCodeRequests, - queryCouponCodes, - queryEnterpriseLearnerOffers, - queryLicenseRequests, - queryRedeemablePolicies, - querySubscriptions, - queryBrowseAndRequestConfiguration, -} from './subsidies'; -import queryNotices from './notices'; - -export default function ensureEnterpriseAppData({ - enterpriseCustomer, - userId, - userEmail, - queryClient, -}) { - const enterpriseAppData = [ - // Enterprise Customer User Subsidies - queryClient.ensureQueryData( - querySubscriptions(enterpriseCustomer.uuid), - ), - 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) { - enterpriseAppData.push( - queryClient.ensureQueryData( - queryNotices(), - ), - ); - } - 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/index.js b/src/components/app/routes/data/queries/index.js deleted file mode 100644 index 1d9879d5d3..0000000000 --- a/src/components/app/routes/data/queries/index.js +++ /dev/null @@ -1,112 +0,0 @@ -import { updateUserActiveEnterprise } from '../services'; -import ensureEnterpriseAppData from './ensureEnterpriseAppData'; -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, -}; - -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. - * @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/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/subsidies/browseAndRequest.js b/src/components/app/routes/data/queries/subsidies/browseAndRequest.js deleted file mode 100644 index e24e1cec35..0000000000 --- a/src/components/app/routes/data/queries/subsidies/browseAndRequest.js +++ /dev/null @@ -1,63 +0,0 @@ -import { queries } from '../../../../../../utils/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/routes/data/queries/subsidies/couponCodes.js b/src/components/app/routes/data/queries/subsidies/couponCodes.js deleted file mode 100644 index a2cd214463..0000000000 --- a/src/components/app/routes/data/queries/subsidies/couponCodes.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.subsidies - * ._ctx.couponCodes - * @returns - */ -export default function queryCouponCodes(enterpriseUuid) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.subsidies - ._ctx.couponCodes; -} diff --git a/src/components/app/routes/data/queries/subsidies/enterpriseOffers.js b/src/components/app/routes/data/queries/subsidies/enterpriseOffers.js deleted file mode 100644 index d5201d09d5..0000000000 --- a/src/components/app/routes/data/queries/subsidies/enterpriseOffers.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.subsidies - * ._ctx.enterpriseOffers - * @returns - */ -export default function queryEnterpriseLearnerOffers(enterpriseUuid) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.subsidies - ._ctx.enterpriseOffers; -} diff --git a/src/components/app/routes/data/queries/subsidies/index.js b/src/components/app/routes/data/queries/subsidies/index.js deleted file mode 100644 index 1559111d3d..0000000000 --- a/src/components/app/routes/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/routes/data/queries/subsidies/policies.js b/src/components/app/routes/data/queries/subsidies/policies.js deleted file mode 100644 index 42e3b88a96..0000000000 --- a/src/components/app/routes/data/queries/subsidies/policies.js +++ /dev/null @@ -1,20 +0,0 @@ -import { queries } from '../../../../../../utils/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/routes/data/queries/subsidies/subscriptions.js b/src/components/app/routes/data/queries/subsidies/subscriptions.js deleted file mode 100644 index f9c5165f76..0000000000 --- a/src/components/app/routes/data/queries/subsidies/subscriptions.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.subsidies - * ._ctx.subscriptions - * @returns - */ -export default function querySubscriptions(enterpriseUuid) { - return queries - .enterprise - .enterpriseCustomer(enterpriseUuid) - ._ctx.subsidies - ._ctx.subscriptions; -} 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/services.js b/src/components/app/routes/data/services.js deleted file mode 100644 index f0848175a3..0000000000 --- a/src/components/app/routes/data/services.js +++ /dev/null @@ -1,404 +0,0 @@ -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, -} 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'; - -// Enterprise Course Enrollments -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 -/** - * Recursive function to fetch all linked enterprise customer users, traversing paginated results. - * @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. - */ -async function fetchData(url, linkedEnterprises = []) { - const response = await getAuthenticatedHttpClient().get(url); - const responseData = camelCaseObject(response.data); - const linkedEnterprisesCopy = [...linkedEnterprises]; - linkedEnterprisesCopy.push(...responseData.results); - if (responseData.next) { - return fetchData(responseData.next, linkedEnterprisesCopy); - } - return { - results: linkedEnterprisesCopy, - enterpriseFeatures: responseData.enterpriseFeatures, - }; -} - -/** - * 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: linkedEnterpriseCustomersUsers, - enterpriseFeatures, - } = await fetchData(url); - const activeLinkedEnterpriseCustomerUser = linkedEnterpriseCustomersUsers.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( - enterpriseCustomerUser => enterpriseCustomerUser.enterpriseCustomer.slug === enterpriseSlug, - ); - - const { - enterpriseCustomer, - roleAssignments, - } = determineEnterpriseCustomerUserForDisplay({ - activeEnterpriseCustomer, - activeEnterpriseCustomerUserRoleAssignments, - enterpriseSlug, - foundEnterpriseCustomerUserForCurrentSlug, - }); - return { - enterpriseCustomer: transformEnterpriseCustomer(enterpriseCustomer, enterpriseFeatures), - enterpriseCustomerUserRoleAssignments: roleAssignments, - activeEnterpriseCustomer, - activeEnterpriseCustomerUserRoleAssignments, - allLinkedEnterpriseCustomerUsers: linkedEnterpriseCustomersUsers, - }; -} - -// 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 Metadata -/** - * 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; - } -} - -// 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; - } -} - -// 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 - * @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 -async function fetchSubscriptionLicensesForUser(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 response = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(response.data); -} - -/** - * TODO - * @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, - }; -} - -// 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; - } -}; - -/** - * 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 config = getConfig(); - const url = `${config.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/routes/data/tests/services.test.js deleted file mode 100644 index 2e57529d38..0000000000 --- a/src/components/app/routes/data/tests/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/routes/data/utils.js b/src/components/app/routes/data/utils.js index f82bc14e9f..66361b7721 100644 --- a/src/components/app/routes/data/utils.js +++ b/src/components/app/routes/data/utils.js @@ -9,14 +9,115 @@ import { import { configure as configureLogging, getLoggingService, - logError, NewRelicLoggingService, } from '@edx/frontend-platform/logging'; 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 { + 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. @@ -82,6 +183,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, { @@ -135,169 +237,73 @@ export async function ensureAuthenticatedUser(requestUrl, params) { } /** - * Helper function to determine which linked enterprise customer user record - * should be used for display in the UI. - * @param {*} param0 + * TODO + * @param {*} enterpriseSlug + * @param {*} activeEnterpriseCustomer + * @param {*} allLinkedEnterpriseCustomerUsers + * @param {*} requestUrl + * @param {*} queryClient + * @param {*} updateUserActiveEnterprise * @returns */ -export function determineEnterpriseCustomerUserForDisplay({ - activeEnterpriseCustomer, - activeEnterpriseCustomerUserRoleAssignments, +export async function ensureActiveEnterpriseCustomerUser({ enterpriseSlug, - foundEnterpriseCustomerUserForCurrentSlug, + activeEnterpriseCustomer, + allLinkedEnterpriseCustomerUsers, + requestUrl, + queryClient, + username, + // updateUserActiveEnterprise, }) { - 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 - * @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) { + // If the enterprise slug in the URL matches the active enterprise customer's slug, return early. + if (enterpriseSlug === activeEnterpriseCustomer.slug) { return null; } - // Otherwise, learner portal is enabled, so transform the enterprise customer data. - const disableSearch = !!( - !enterpriseCustomer.enableIntegratedCustomerLearnerPortalSearch - && enterpriseCustomer.identityProvider + // 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, ); - 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, + if (!foundEnterpriseCustomerUserForSlug) { + throw redirect(generatePath('/:enterpriseSlug/*', { + enterpriseSlug: activeEnterpriseCustomer.slug, + '*': requestUrl.pathname.split('/').filter(pathPart => !!pathPart).slice(1).join('/'), })); - 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 { + enterpriseCustomer: nextActiveEnterpriseCustomer, + roleAssignments: nextActiveEnterpriseCustomerRoleAssignments, + } = foundEnterpriseCustomerUserForSlug; + // 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, + active: ( + ecu.enterpriseCustomer.uuid === nextActiveEnterpriseCustomer.uuid + ), + }), + ); - 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; + // 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: nextActiveEnterpriseCustomer, + enterpriseCustomerUserRoleAssignments: nextActiveEnterpriseCustomerRoleAssignments, + activeEnterpriseCustomer: nextActiveEnterpriseCustomer, + activeEnterpriseCustomerUserRoleAssignments: nextActiveEnterpriseCustomerRoleAssignments, + allLinkedEnterpriseCustomerUsers: updatedLinkedEnterpriseCustomerUsers, + }); return { - assignments, - hasAssignments, - allocatedAssignments, - hasAllocatedAssignments, - acceptedAssignments, - hasAcceptedAssignments, - canceledAssignments, - hasCanceledAssignments, - expiredAssignments, - hasExpiredAssignments, - erroredAssignments, - hasErroredAssignments, - assignmentsForDisplay, - hasAssignmentsForDisplay, + enterpriseCustomer: nextActiveEnterpriseCustomer, + updatedLinkedEnterpriseCustomerUsers, }; } diff --git a/src/components/app/routes/data/tests/utils.test.js b/src/components/app/routes/data/utils.test.js similarity index 89% rename from src/components/app/routes/data/tests/utils.test.js rename to src/components/app/routes/data/utils.test.js index 078f6e0ea9..747d7b2dd8 100644 --- a/src/components/app/routes/data/tests/utils.test.js +++ b/src/components/app/routes/data/utils.test.js @@ -1,15 +1,11 @@ -import { transformEnterpriseCustomer } from '../utils'; - -const mockEnterpriseFeatures = { - 'example-feature': true, -}; +import { transformEnterpriseCustomer } from '../../data'; 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, }); }); }); diff --git a/src/components/app/routes/index.js b/src/components/app/routes/index.js index a48c6f0c7e..25a3b393f6 100644 --- a/src/components/app/routes/index.js +++ b/src/components/app/routes/index.js @@ -1,2 +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'; 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/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..bbab7824ad 100644 --- a/src/components/app/routes/loaders/rootLoader.js +++ b/src/components/app/routes/loaders/rootLoader.js @@ -1,8 +1,10 @@ -import { ensureEnterpriseAppData, queryEnterpriseLearner } from '../data/queries'; +import { queryEnterpriseLearner } from '../../data'; import { ensureAuthenticatedUser, + ensureEnterpriseAppData, redirectToRemoveTrailingSlash, redirectToSearchPageForNewUser, + ensureActiveEnterpriseCustomerUser, } from '../data'; /** @@ -26,20 +28,46 @@ 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; // 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, + }); + // 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/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/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/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/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/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'; diff --git a/src/components/enterprise-user-subsidy/data/hooks/hooks.js b/src/components/enterprise-user-subsidy/data/hooks/hooks.js index 59c2491645..439149ea4d 100644 --- a/src/components/enterprise-user-subsidy/data/hooks/hooks.js +++ b/src/components/enterprise-user-subsidy/data/hooks/hooks.js @@ -1,21 +1,18 @@ 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, - requestAutoAppliedLicense, } from '../service'; import { features } from '../../../../config'; import { fetchCouponsOverview } from '../../coupons/data/service'; @@ -57,28 +54,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 +85,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; } @@ -156,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/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/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, 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(); - }); -}); 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(); }