Skip to content

Commit

Permalink
Merge pull request #1153 from openedx/asheehan-edx/ENT-8038
Browse files Browse the repository at this point in the history
feat: auto setting active linked enterprise
  • Loading branch information
alex-sheehan-edx authored Jan 19, 2024
2 parents cc98d50 + 80ebfb1 commit a2dcdc7
Show file tree
Hide file tree
Showing 11 changed files with 273 additions and 4 deletions.
6 changes: 5 additions & 1 deletion src/components/App/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
} from 'react-router-dom';
import { Helmet } from 'react-helmet';
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
Expand All @@ -25,9 +26,12 @@ import { SystemWideWarningBanner } from '../system-wide-banner';

import store from '../../data/store';
import { ROUTE_NAMES } from '../EnterpriseApp/data/constants';
import { defaultQueryClientRetryHandler } from '../../utils';
import { defaultQueryClientRetryHandler, queryCacheOnErrorHandler } from '../../utils';

const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: queryCacheOnErrorHandler,
}),
defaultOptions: {
queries: {
retry: defaultQueryClientRetryHandler,
Expand Down
11 changes: 10 additions & 1 deletion src/components/EnterpriseApp/EnterpriseAppContextProvider.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React, { createContext, useMemo } from 'react';
import React, { createContext, useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
import { AppContext } from '@edx/frontend-platform/react';

import { EnterpriseSubsidiesContext, useEnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext';
import { SubsidyRequestsContext, useSubsidyRequestsContext } from '../subsidy-requests/SubsidyRequestsContext';
import {
useEnterpriseCurationContext,
useUpdateActiveEnterpriseForUser,
} from './data/hooks';
import EnterpriseAppSkeleton from './EnterpriseAppSkeleton';

Expand Down Expand Up @@ -49,6 +51,7 @@ const EnterpriseAppContextProvider = ({
enablePortalLearnerCreditManagementScreen,
children,
}) => {
const { authenticatedUser } = useContext(AppContext);
// subsidies for the enterprise customer
const enterpriseSubsidiesContext = useEnterpriseSubsidiesContext({
enterpriseId,
Expand All @@ -68,10 +71,16 @@ const EnterpriseAppContextProvider = ({
curationTitleForCreation: enterpriseName,
});

const { isLoading: isUpdatingActiveEnterprise } = useUpdateActiveEnterpriseForUser({
enterpriseId,
user: authenticatedUser,
});

const isLoading = (
subsidyRequestsContext.isLoading
|| enterpriseSubsidiesContext.isLoading
|| enterpriseCurationContext.isLoading
|| isUpdatingActiveEnterprise
);

// [tech debt] consolidate the other context values (e.g., useSubsidyRequestsContext)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,31 +23,43 @@ describe('<EnterpriseAppContextProvider />', () => {
isLoadingEnterpriseSubsidies: true,
isLoadingSubsidyRequests: false,
isLoadingEnterpriseCuration: false,
isLoadingUpdateActiveEnterpriseForUser: false,
},
{
isLoadingEnterpriseSubsidies: false,
isLoadingSubsidyRequests: true,
isLoadingEnterpriseCuration: false,
isLoadingUpdateActiveEnterpriseForUser: false,
},
{
isLoadingEnterpriseSubsidies: false,
isLoadingSubsidyRequests: false,
isLoadingEnterpriseCuration: true,
isLoadingUpdateActiveEnterpriseForUser: false,
},
{
isLoadingEnterpriseSubsidies: true,
isLoadingSubsidyRequests: true,
isLoadingEnterpriseCuration: false,
isLoadingUpdateActiveEnterpriseForUser: false,
},
{
isLoadingEnterpriseSubsidies: false,
isLoadingSubsidyRequests: false,
isLoadingEnterpriseCuration: false,
isLoadingUpdateActiveEnterpriseForUser: true,
},
{
isLoadingEnterpriseSubsidies: true,
isLoadingSubsidyRequests: true,
isLoadingEnterpriseCuration: true,
isLoadingUpdateActiveEnterpriseForUser: true,
},
])('renders <EnterpriseAppSkeleton /> when: %s', async ({
isLoadingEnterpriseSubsidies,
isLoadingSubsidyRequests,
isLoadingEnterpriseCuration,
isLoadingUpdateActiveEnterpriseForUser,
}) => {
const mockUseEnterpriseSubsidiesContext = jest.spyOn(enterpriseSubsidiesContext, 'useEnterpriseSubsidiesContext').mockReturnValue({
isLoading: isLoadingEnterpriseSubsidies,
Expand All @@ -62,6 +74,11 @@ describe('<EnterpriseAppContextProvider />', () => {
isLoading: isLoadingEnterpriseCuration,
},
);
const mockUseUpdateActiveEnterpriseForUser = jest.spyOn(hooks, 'useUpdateActiveEnterpriseForUser').mockReturnValue(
{
isLoading: isLoadingUpdateActiveEnterpriseForUser,
},
);

render(
<EnterpriseAppContextProvider
Expand All @@ -75,11 +92,17 @@ describe('<EnterpriseAppContextProvider />', () => {
);

await waitFor(() => {
expect(mockUseUpdateActiveEnterpriseForUser).toHaveBeenCalled();
expect(mockUseSubsidyRequestsContext).toHaveBeenCalled();
expect(mockUseEnterpriseSubsidiesContext).toHaveBeenCalled();
expect(mockUseEnterpriseCurationContext).toHaveBeenCalled();

if (isLoadingEnterpriseSubsidies || isLoadingSubsidyRequests || isLoadingEnterpriseCuration) {
if (
isLoadingEnterpriseSubsidies
|| isLoadingSubsidyRequests
|| isLoadingEnterpriseCuration
|| isLoadingUpdateActiveEnterpriseForUser
) {
expect(screen.getByText('Loading...'));
} else {
expect(screen.getByText('children'));
Expand Down
1 change: 1 addition & 0 deletions src/components/EnterpriseApp/data/hooks/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default as useEnterpriseCuration } from './useEnterpriseCuration';
export { default as useEnterpriseCurationContext } from './useEnterpriseCurationContext';
export { default as useUpdateActiveEnterpriseForUser } from './useUpdateActiveEnterpriseForUser';
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect } from 'react';

import {
useMutation, useQuery,
} from '@tanstack/react-query';
import { logError } from '@edx/frontend-platform/logging';

import LmsApiService from '../../../../data/services/LmsApiService';

const useUpdateActiveEnterpriseForUser = ({ enterpriseId, user }) => {
// Sets up POST call to update active enterprise.
const { mutate, isLoading: isUpdatingActiveEnterprise } = useMutation({
mutationFn: () => LmsApiService.updateUserActiveEnterprise(enterpriseId),
onError: () => {
logError("Failed to update user's active enterprise");
},
});
const { username } = user;
const {
data,
isLoading: isLoadingActiveEnterprise,
} = useQuery({
queryKey: ['activeLinkedEnterpriseCustomer', username],
queryFn: () => LmsApiService.getActiveLinkedEnterprise(username),
meta: {
errorMessage: "Failed to fetch user's active enterprise",
},
});

useEffect(() => {
if (!data) { return; }
if (data.uuid !== enterpriseId) {
mutate(enterpriseId);
}
}, [data, enterpriseId, mutate]);

return {
isLoading: isLoadingActiveEnterprise || isUpdatingActiveEnterprise,
};
};

export default useUpdateActiveEnterpriseForUser;
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { renderHook } from '@testing-library/react-hooks';
import { QueryClientProvider } from '@tanstack/react-query';
import { logError } from '@edx/frontend-platform/logging';
import { useUpdateActiveEnterpriseForUser } from './index';
import LmsApiService from '../../../../data/services/LmsApiService';
import { queryClient } from '../../../test/testUtils';

jest.mock('../../../../data/services/LmsApiService');
jest.mock('@edx/frontend-platform/logging', () => ({
...jest.requireActual('@edx/frontend-platform/logging'),
logError: jest.fn(),
}));

describe('useUpdateActiveEnterpriseForUser', () => {
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient()}>
{children}
</QueryClientProvider>
);
const mockEnterpriseId = 'enterprise-uuid';
const mockUser = { username: 'joe_shmoe' };
const connectedEnterprise = 'someID';
beforeEach(() => {
LmsApiService.getActiveLinkedEnterprise.mockResolvedValue({ uuid: connectedEnterprise });
});

afterEach(() => jest.clearAllMocks());

it("should update user's active enterprise if it differs from the current enterprise", async () => {
const { result, waitForNextUpdate } = renderHook(
() => useUpdateActiveEnterpriseForUser({
enterpriseId: mockEnterpriseId,
user: mockUser,
}),
{ wrapper },
);
expect(result.current.isLoading).toBe(true);

await waitForNextUpdate();

expect(LmsApiService.updateUserActiveEnterprise).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toBe(false);
});

it('should do nothing if active enterprise is the same as current enterprise', async () => {
// Pass the value of the enterprise ID returned by ``getActiveLinkedEnterprise`` to the hook
const { waitForNextUpdate } = renderHook(
() => useUpdateActiveEnterpriseForUser({
enterpriseId: connectedEnterprise,
user: mockUser,
}),
{ wrapper },
);
await waitForNextUpdate();
expect(LmsApiService.updateUserActiveEnterprise).toHaveBeenCalledTimes(0);
});

it('should handle useMutation errors', async () => {
LmsApiService.updateUserActiveEnterprise.mockRejectedValueOnce(Error('uh oh'));
const { result, waitForNextUpdate } = renderHook(
() => useUpdateActiveEnterpriseForUser({
enterpriseId: mockEnterpriseId,
user: mockUser,
}),
{ wrapper },
);
expect(result.current.isLoading).toBe(true);

await waitForNextUpdate();

expect(LmsApiService.updateUserActiveEnterprise).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toBe(false);
expect(logError).toHaveBeenCalledTimes(1);
});
it('should handle useQuery errors', async () => {
LmsApiService.getActiveLinkedEnterprise.mockRejectedValueOnce(Error('uh oh'));
const { result, waitForNextUpdate } = renderHook(
() => useUpdateActiveEnterpriseForUser({
enterpriseId: mockEnterpriseId,
user: mockUser,
}),
{ wrapper },
);
expect(result.current.isLoading).toBe(true);

await waitForNextUpdate();

expect(LmsApiService.getActiveLinkedEnterprise).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toBe(false);
expect(logError).toHaveBeenCalledWith("Failed to fetch user's active enterprise");
});
});
6 changes: 5 additions & 1 deletion src/components/test/testUtils.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { render, screen as rtlScreen } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { QueryCache, QueryClient } from '@tanstack/react-query';
import { queryCacheOnErrorHandler } from '../../utils';

// TODO: this could likely be replaced by `renderWithRouter` from `@edx/frontend-enterprise-utils`.
export function renderWithRouter(
Expand Down Expand Up @@ -46,6 +47,9 @@ export const getButtonElement = (buttonText, options = {}) => {

export function queryClient(options = {}) {
return new QueryClient({
queryCache: new QueryCache({
onError: queryCacheOnErrorHandler,
}),
defaultOptions: {
queries: {
retry: false,
Expand Down
34 changes: 34 additions & 0 deletions src/data/services/LmsApiService.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { logError } from '@edx/frontend-platform/logging';

import { configuration } from '../../config';
import generateFormattedStatusUrl from './apiServiceUtils';
Expand Down Expand Up @@ -384,6 +385,39 @@ class LmsApiService {
const url = `${LmsApiService.baseUrl}/enterprise/api/v1/analytics-summary/${enterpriseUUID}`;
return LmsApiService.apiClient().post(url, formData);
}

static updateUserActiveEnterprise = (enterpriseId) => {
const url = `${configuration.LMS_BASE_URL}/enterprise/select/active/`;
const formData = new FormData();
formData.append('enterprise', enterpriseId);

return LmsApiService.apiClient().post(
url,
formData,
);
};

static fetchEnterpriseLearnerData(options) {
const enterpriseLearnerUrl = `${configuration.LMS_BASE_URL}/enterprise/api/v1/enterprise-learner/`;
const queryParams = new URLSearchParams({
...options,
page: 1,
});
const url = `${enterpriseLearnerUrl}?${queryParams.toString()}`;
return LmsApiService.apiClient().get(url);
}

static async getActiveLinkedEnterprise(username) {
const response = await this.fetchEnterpriseLearnerData({ username });
const transformedResponse = camelCaseObject(response.data);
const enterprisesForLearner = transformedResponse.results;
const activeLinkedEnterprise = enterprisesForLearner.find(enterprise => enterprise.active);
if (!activeLinkedEnterprise) {
logError(`${username} does not have any active linked enterprise customers`);
return null;
}
return activeLinkedEnterprise.enterpriseCustomer;
}
}

export default LmsApiService;
32 changes: 32 additions & 0 deletions src/data/services/tests/LmsApiService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import { configuration } from '../../../config';

const lmsBaseUrl = `${configuration.LMS_BASE_URL}`;
const mockEnterpriseUUID = 'test-enterprise-id';
const mockUsername = 'test_username';

const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);

axiosMock.onAny().reply(200);
axios.patch = jest.fn();
axios.post = jest.fn();
axios.get = jest.fn();

describe('LmsApiService', () => {
test('updateEnterpriseCustomer calls the LMS to update the enterprise customer', () => {
Expand Down Expand Up @@ -41,4 +44,33 @@ describe('LmsApiService', () => {
{ primary_color: '#A8DABC' },
);
});
test('updateUserActiveEnterprise calls the LMS to update the active linked enterprise org', () => {
LmsApiService.updateUserActiveEnterprise(
mockEnterpriseUUID,
);
const expectedFormData = new FormData();
expectedFormData.append('enterprise', mockEnterpriseUUID);
expect(axios.post).toBeCalledWith(
`${lmsBaseUrl}/enterprise/select/active/`,
expectedFormData,
);
});
test('fetchEnterpriseLearnerData calls the LMS to fetch learner data', () => {
LmsApiService.fetchEnterpriseLearnerData({ username: mockUsername });
expect(axios.get).toBeCalledWith(
`${lmsBaseUrl}/enterprise/api/v1/enterprise-learner/?username=${mockUsername}&page=1`,
);
});
test('getActiveLinkedEnterprise returns the actively linked enterprise', async () => {
axios.get.mockReturnValue({
data: {
results: [{
active: true,
enterpriseCustomer: { uuid: 'test-uuid' },
}],
},
});
const activeCustomer = await LmsApiService.getActiveLinkedEnterprise(mockUsername);
expect(activeCustomer).toEqual({ uuid: 'test-uuid' });
});
});
Loading

0 comments on commit a2dcdc7

Please sign in to comment.