Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: auto setting active linked enterprise #1153

Merged
merged 1 commit into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) => {
alex-sheehan-edx marked this conversation as resolved.
Show resolved Hide resolved
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) {
alex-sheehan-edx marked this conversation as resolved.
Show resolved Hide resolved
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;
alex-sheehan-edx marked this conversation as resolved.
Show resolved Hide resolved
}
}

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